diff --git a/.github/workflows/build-docker.yaml b/.github/workflows/build-docker.yaml index a846fae05..c2dd40bb2 100644 --- a/.github/workflows/build-docker.yaml +++ b/.github/workflows/build-docker.yaml @@ -38,19 +38,8 @@ jobs: # Use the current directory as context, as checked out by actions/checkout -- needed for setuptools_scm context: . - - name: Smoke test - start server and ping it - run: | - docker compose -f docker/compose.yaml up --build --detach - timeout 120 bash -c 'until curl -sf http://localhost:8000; do sleep 2; done' - curl -f http://localhost:8000 - env: - LNT_DB_PASSWORD: smoke-test-password - LNT_AUTH_TOKEN: smoke-test-token - - - name: Dump logs on failure - if: failure() - run: docker compose -f docker/compose.yaml logs - - - name: Teardown - if: always() - run: docker compose -f docker/compose.yaml down + - name: Install tox + run: pip install tox + + - name: Smoke test - v5 Docker deployment + run: tox -e docker diff --git a/.github/workflows/tox.yaml b/.github/workflows/tox.yaml index 0d1c0a229..37c943e36 100644 --- a/.github/workflows/tox.yaml +++ b/.github/workflows/tox.yaml @@ -25,6 +25,10 @@ jobs: uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0 with: python-version: ${{ matrix.python-version }} + - name: Set up Node.js + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 + with: + node-version: '22' - name: Install dependencies run: | python -m pip install --upgrade pip @@ -37,3 +41,5 @@ jobs: run: tox -e mypy - name: Tox py3 run: tox -e py3 + - name: Tox js + run: tox -e js diff --git a/.gitignore b/.gitignore index efa0c2187..b48532a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,8 @@ lnt/server/ui/static/docs test_run_tmp tests/**/Output venv +lnt/server/ui/v5/frontend/node_modules/ +lnt/server/ui/v5/static/v5/v5.js +lnt/server/ui/v5/static/v5/v5.js.map +lnt/server/ui/v5/static/v5/v5.css +lnt/server/ui/v5/static/v5/favicon.ico diff --git a/MANIFEST.in b/MANIFEST.in index 6f331465f..824b35d2e 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -4,6 +4,7 @@ recursive-include docs * recursive-include tests * recursive-include examples * recursive-include lnt/server/ui/static *.css *.js *.html *.ico *.txt +recursive-include lnt/server/ui/v5/static *.css *.js *.map recursive-include lnt/server/ui/templates *.html recursive-include lnt/server/db/migrations * include lnt/server/ui/static/flot/Makefile diff --git a/deployment/README.md b/deployment/README.md index aa62f2cdf..3b9ef9a7f 100644 --- a/deployment/README.md +++ b/deployment/README.md @@ -44,8 +44,8 @@ The Docker Compose service itself should now be running, and it can be inspected ```bash docker ps -docker logs webserver -docker logs dbserver # usually less interesting +docker compose -f docker/compose.yaml logs webserver +docker compose -f docker/compose.yaml logs db # usually less interesting ``` The database is stored in an independent EBS storage that gets attached and detached diff --git a/deployment/compose.prod.yaml b/deployment/compose.prod.yaml index bf3f4ab0d..5af111d2e 100644 --- a/deployment/compose.prod.yaml +++ b/deployment/compose.prod.yaml @@ -9,7 +9,7 @@ services: webserver: build: !reset - image: ghcr.io/llvm/llvm-lnt:${LNT_IMAGE:-latest} + image: ghcr.io/ldionne/llvm-lnt:${LNT_IMAGE:-latest} volumes: instance: diff --git a/deployment/main.tf b/deployment/main.tf index a4baa46df..13600f629 100644 --- a/deployment/main.tf +++ b/deployment/main.tf @@ -4,7 +4,7 @@ terraform { backend "s3" { - bucket = "lnt.llvm.org-terraform-state-prod" + bucket = "lnt.llvm.org-terraform-state-ldionne" key = "terraform.tfstate" region = "us-west-2" encrypt = true @@ -37,7 +37,7 @@ data "aws_secretsmanager_secret_version" "lnt_secrets_latest" { locals { # The Docker image to use for the webserver part of the LNT service - lnt_image = "56a3c8974301d2c70cc14676bf29974bb623ca6c" + lnt_image = "v5" # The port on the EC2 instance used by the Docker webserver for communication lnt_external_port = "80" diff --git a/docker/compose.yaml b/docker/compose.yaml index 0c999650f..4d015c5b1 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -21,6 +21,9 @@ # # Additionally, the following aspects can be customized: # +# LNT_HOST_PORT +# The host port to expose the webserver on. Defaults to '8000'. +# # LNT_NGINX_CONFIG # The path to the configuration file to use for Nginx. By default, './nginx.conf' # is used, however this must be specified whenever running from a host where the @@ -35,13 +38,12 @@ name: llvm-lnt services: webserver: - container_name: webserver build: context: ../ dockerfile: docker/lnt.dockerfile environment: - DB_USER=lntuser - - DB_HOST=dbserver + - DB_HOST=db - DB_NAME=lnt - DB_PASSWORD_FILE=/run/secrets/lnt-db-password - AUTH_TOKEN_FILE=/run/secrets/lnt-auth-token @@ -56,12 +58,12 @@ services: volumes: - instance:/var/lib/lnt ports: - - "8000:8000" + - "${LNT_HOST_PORT:-8000}:8000" networks: - lnt_network + platform: linux/amd64 db: - container_name: dbserver image: docker.io/postgres:18-alpine environment: - POSTGRES_PASSWORD_FILE=/run/secrets/lnt-db-password diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh index 226084f5f..059387f6b 100755 --- a/docker/docker-entrypoint.sh +++ b/docker/docker-entrypoint.sh @@ -6,22 +6,25 @@ password="$(cat ${DB_PASSWORD_FILE})" token="$(cat ${AUTH_TOKEN_FILE})" DB_PATH="postgres://${DB_USER}:${password}@${DB_HOST}" +INSTANCE_DIR=/var/lib/lnt/instance + # Set up the instance the first time this gets run. -if [ ! -e /var/lib/lnt/instance/lnt.cfg ]; then +if [ ! -e "${INSTANCE_DIR}/lnt.cfg" ]; then lnt-wait-db "${DB_PATH}/${DB_NAME}" - lnt create /var/lib/lnt/instance \ - --wsgi lnt_wsgi.py \ - --tmp-dir /tmp/lnt \ - --db-dir "${DB_PATH}" \ - --default-db "${DB_NAME}" \ - --api-auth-token "${token}" + lnt create "${INSTANCE_DIR}" \ + --wsgi lnt_wsgi.py \ + --tmp-dir /tmp/lnt \ + --db-dir "${DB_PATH}" \ + --default-db "${DB_NAME}" \ + --api-auth-token "${token}" \ + --db-version 5.0 fi # Run the server under gunicorn. -cd /var/lib/lnt/instance +cd "${INSTANCE_DIR}" exec gunicorn lnt_wsgi:application \ --bind 0.0.0.0:8000 \ - --workers 8 \ + --workers 8 \ --timeout 300 \ --name lnt_server \ --access-logfile - \ diff --git a/docs/api.rst b/docs/api.rst index 4bb49ec94..f476701f3 100644 --- a/docs/api.rst +++ b/docs/api.rst @@ -5,6 +5,27 @@ Accessing Data outside of LNT: REST API LNT provides comprehensive REST APIs to access and manage data stored in the LNT database. +v5 API (Recommended) +-------------------- + +The v5 API is the latest REST API for LNT. It provides interactive documentation via +Swagger UI, available at:: + + http:///api/v5/openapi/swagger-ui + +The OpenAPI 3.0 specification is also available in JSON format at:: + + http:///api/v5/openapi/openapi.json + +The v5 API uses Bearer token authentication, natural keys (machine names, test names) +instead of internal IDs, cursor-based pagination, and a standardized error format. +Refer to the Swagger UI for the complete list of endpoints and their parameters. + +v4 API (Legacy) +--------------- + +The v4 API is documented below for reference. New integrations should use the v5 API. + Quick Reference --------------- diff --git a/docs/design/README.md b/docs/design/README.md new file mode 100644 index 000000000..f11a007f5 --- /dev/null +++ b/docs/design/README.md @@ -0,0 +1,81 @@ +# v5 Design Documentation + +This directory contains the design documentation for the LNT v5 project. It is +the authoritative reference for what v5 does and why. + +## What is v5? + +LNT v5 is a ground-up redesign of the database, REST API, and web UI layers. +Key changes from v4: + +- **Database**: PostgreSQL-only, schema-in-DB, the "Commit" concept replaces + "Order" (cleanly separating identity, ordering, and display), no FieldChange + table (regressions use direct indicators). +- **API**: flask-smorest with OpenAPI 3.x, cursor-based pagination, bearer + token auth with scope hierarchy, all JSON responses. +- **UI**: Single-page application in vanilla TypeScript + Vite, client-side + routing, all data from the v5 REST API. + +v4 and v5 coexist in the same codebase, selected by `db_version` in `lnt.cfg` +(`'0.4'` for v4, `'5.0'` for v5). They are fully disjoint: a v4 instance +serves only v4 views, and a v5 instance serves only v5 API endpoints and the +v5 frontend. + +## Design Principles + +- **PostgreSQL only**. No SQLite or MySQL support. +- **SQLAlchemy 1.3** (same version as v4, to avoid upgrade risk). +- **Python 3.10+** idioms: type hints (`X | Y`, `list[T]`), dataclasses, + f-strings, `match` where appropriate. +- **No backward compatibility** with v4 formats or APIs. Clean break. +- **No auto-detection** of regressions. External tools create regressions via + the API. + +## Document Map + +### Database Layer — [`db/`](db/) + +| Document | Contents | +|----------|----------| +| [Data Model](db/data-model.md) | Architecture, Commit concept, schema storage and format, all table definitions | +| [Operations](db/operations.md) | Run submission, commit metadata, search, time-series queries, ordinal management, v4 migration, deferred features | + +### REST API — [`api/`](api/) + +| Document | Contents | +|----------|----------| +| [Infrastructure](api/infrastructure.md) | Framework (flask-smorest), URL structure, pagination, filtering, response format, authentication, testing, deferred features, AI orientation | +| [Endpoints](api/endpoints.md) | All entity endpoint specifications: machines, commits, runs, tests, samples, profiles, regressions, time series, schema, admin | + +### Web UI — [`ui/`](ui/) + +| Document | Contents | +|----------|----------| +| [Architecture](ui/architecture.md) | SPA design, client-side routing, Flask backend routes, navigation bar, frontend code structure, build config, implementation phases | +| [Dashboard](ui/dashboard.md) | Landing page with sparkline trend overview across test suites | +| [Browsing Pages](ui/browsing.md) | Test Suites page (suite picker + tabs), Machine Detail, Run Detail, Commit Detail, Regression Detail, and inline regression list/triage | +| [Graph](ui/graph.md) | Time-series visualization: multi-machine, lazy loading, test selection, baselines, regression annotations | +| [Compare](ui/compare.md) | Side-by-side comparison of two commits/runs: selection panel, ratio chart, geomean summary, bidirectional sync | +| [Admin](ui/admin.md) | API key management, test suite schema management | + +### Historical Discussion + +| Document | Contents | +|----------|----------| +| [Discussion: Orders](v5-discussion-about-orders.md) | Exploration of approaches that led to the Commit concept (design rationale) | + + +## v4 Features NOT Carried Forward + +These v4 pages are intentionally omitted from the v5 UI: + +| v4 Feature | Rationale | +|------------|-----------| +| Daily Report | Subsumed by Dashboard + Graph. The dashboard shows sparkline trends; the graph page shows detailed time-series. | +| Latest Runs Report | Subsumed by Dashboard (sparkline trend overview) and Test Suites page (Recent Activity tab). | +| Summary Report | Low usage, "WIP" in v4. Can be added later if needed. | +| Matrix View | Niche use case. The Graph page with per-test drill-down covers the same need. | +| Global Status | Subsumed by Dashboard (sparkline trend overview with per-machine traces). | +| Profile Admin | Operational concern, not a core user workflow. Keep in v4. | +| Submit Run page | Runs are submitted via CLI (`lnt submit`) or API. A form-based UI is rarely used. | +| Rules page | Read-only diagnostic page. Keep in v4 for ops. | diff --git a/docs/design/api/endpoints.md b/docs/design/api/endpoints.md new file mode 100644 index 000000000..ca161a3ee --- /dev/null +++ b/docs/design/api/endpoints.md @@ -0,0 +1,278 @@ +# v5 REST API: Endpoints + +This document specifies all entity endpoints in the v5 REST API. + +For framework, pagination, auth, and other infrastructure, see +[`infrastructure.md`](infrastructure.md). + + +## Machines + +``` +GET /machines -- List (filterable, simple pagination) +POST /machines -- Create machine independently +GET /machines/{machine_name} -- Detail +PATCH /machines/{machine_name} -- Update metadata/parameters (including rename) +DELETE /machines/{machine_name} -- Delete machine and its runs +GET /machines/{machine_name}/runs -- List runs for this machine (cursor-paginated) +``` + +Machines are also created implicitly if a run is submitted for a nonexistent machine. + + +## Commits + +``` +GET /commits -- List (cursor-paginated, searchable) +POST /commits -- Create with metadata (commit_fields) +GET /commits/{value} -- Detail (includes previous/next commit by ordinal) +PATCH /commits/{value} -- Update ordinal, tag, and/or commit_fields +DELETE /commits/{value} -- Delete commit (cascades to runs/samples; 409 if referenced by regressions) +POST /commits/resolve -- Batch resolve commit strings to summaries +``` + +The `{value}` in the path is the commit identity string. Commits are also +created implicitly during run submission. Ordinals and tags are always NULL +on creation and assigned exclusively via PATCH (see +[D11 in db/operations.md](../db/operations.md#d11-ordinal-management)). + +Filters: `search=` (case-insensitive substring match on commit string, tag, and +searchable commit fields; see D9), `machine=` (only commits with at least one +run on this machine; 404 if machine not found), `has_profiles=` (boolean; +`true` returns only commits where at least one run has profile data, `false` +returns only commits where no run has profile data; when combined with +`machine=`, only considers runs on that machine). Sort: `sort=ordinal` sorts +by ordinal ascending and excludes commits with NULL ordinals; default sort is +by internal ID. + +### Batch Resolve + +`POST /commits/resolve` accepts a JSON body `{"commits": ["abc", "def", ...]}` +(at least one commit string) and returns each found commit's summary +in a dict keyed by commit string: + +```json +{ + "results": { + "abc": {"commit": "abc", "ordinal": 42, "fields": {"git_sha": "..."}}, + "def": {"commit": "def", "ordinal": null, "fields": {}} + }, + "not_found": ["unknown"] +} +``` + +Each value in `results` has the same shape as `CommitSummarySchema` +(`{commit, ordinal, tag, fields}`). Commit strings not found in the database +are returned in a separate `not_found` list. Duplicates in the request +are deduplicated; each commit appears at most once in the response. + +Auth scope: `read`. Not paginated (response is bounded by request size). + + +## Runs + +``` +GET /runs -- List (cursor-paginated, filterable by machine=, commit=, after=, before=, has_profiles=) +POST /runs -- Submit run (accepts optional client UUID or generates one, returns it) +GET /runs/{uuid} -- Detail +DELETE /runs/{uuid} -- Delete run +``` + +The UUID is either provided by the client in the submission body or generated +server-side (UUID v4) when omitted. Client-provided UUIDs must be in standard +`8-4-4-4-12` hyphenated hex format and are normalized to lowercase. If a run +with the same UUID already exists, the server returns 409 Conflict. The +submission endpoint requires JSON format with `format_version '5'`. Legacy +formats (v0, v1, v2) and non-JSON payloads are rejected. There is no +`on_existing_run` parameter -- v5 always creates a new run (multiple runs per +machine+commit are allowed). Deleting a run cascades to its samples and +profiles. + +`has_profiles=` (boolean): `true` returns only runs that have at least one +profile attached; `false` returns only runs without profiles. + + +## Tests + +``` +GET /tests -- List (cursor-paginated, filterable) +``` + +Read-only. Tests are created implicitly via run submission. + +Filters: `search=` (case-insensitive substring match on test name; see D9), `machine=` (only tests with data +for this machine), `metric=` (only tests with non-NULL values for this metric). + + +## Samples + +Samples are always accessed through their parent run -- they have no external +identifier of their own. + +``` +GET /runs/{uuid}/samples -- All samples for a run (cursor-paginated) +GET /runs/{uuid}/tests/{test_name}/samples -- Samples for a specific test in a run +``` + +Read-only. Samples are created as part of run submission. + + +## Profiles + +Profiles store hardware performance counter data at the instruction level. +Each profile is identified by a server-generated UUID. The UUID-based approach +enables stable bookmarkable identifiers for profile data endpoints, while the +listing endpoint provides the bridge from human-readable run+test coordinates +to UUIDs. + +### Listing (per run) + +``` +GET /runs/{uuid}/profiles -- List profiles for a run +``` + +Returns an array of `{test, uuid}` objects for all profiles attached to +the given run. No pagination (bounded by tests-per-run). Auth: `read`. + +### Profile Data (by UUID) + +``` +GET /profiles/{uuid} -- Metadata + top-level counters +GET /profiles/{uuid}/functions -- Function list with counters +GET /profiles/{uuid}/functions/{fn_name} -- Disassembly + per-instruction counters +``` + +All three endpoints require `read` scope. + +**Metadata response** (`GET /profiles/{uuid}`): +- `uuid`, `test` (test name), `run_uuid`, `counters` (dict of counter + name -> integer value), `disassembly_format` (string) + +**Functions response** (`GET /profiles/{uuid}/functions`): +- `functions`: array of `{name, counters, length}` where `counters` is + a dict of counter name -> float (percentage), `length` is instruction + count. Sorted by total counter value descending (hottest first). + +**Function detail response** (`GET /profiles/{uuid}/functions/{fn_name}`): +- `name`, `counters` (function-level aggregates), `disassembly_format`, + `instructions`: array of `{address, counters, text}` per instruction. + Function names may contain special characters (C++ mangled names); the + frontend must `encodeURIComponent` the name. + +**Error handling**: If the stored profile blob is corrupt and cannot be +deserialized, the profile data endpoints return 500 with a descriptive +error message. + +Profiles are submitted as base64-encoded data within the run submission +payload (see D6 in db/operations.md). No separate upload endpoint. + + +## Regressions + +``` +GET /regressions -- List (cursor-paginated, filterable by state=, machine=, test=, metric=, commit=, has_commit=) +POST /regressions -- Create (accepts title, bug, notes, state, commit, indicators) +GET /regressions/{uuid} -- Detail (indicators embedded) +PATCH /regressions/{uuid} -- Update title, bug, notes, state, commit +DELETE /regressions/{uuid} -- Delete (cascades indicators) +POST /regressions/{uuid}/indicators -- Add indicator(s) (batch) +DELETE /regressions/{uuid}/indicators -- Remove indicator(s) (batch, UUIDs in body) +``` + +Auth scopes: read=GET, triage=POST/PATCH/DELETE and indicator management. + +Regressions are identified by server-generated UUID. + +**Regression states** (string enum): +`detected`, `active`, `not_to_be_fixed`, `fixed`, `false_positive` + +State transitions are unconstrained -- any state can be set to any other +state via PATCH. + +**Create request body:** +- `title` (string, optional -- auto-generated if omitted) +- `bug` (string, optional -- URL to external bug tracker) +- `notes` (string, optional -- investigation findings, A/B results, etc.) +- `state` (string, optional -- default: `detected`) +- `commit` (string, optional -- suspected introduction commit, resolved by value) +- `indicators` (array, optional -- list of `{machine, test, metric}` objects, + all resolved by name) + +**Detail response** (`GET /regressions/{uuid}`): +- `uuid`, `title`, `bug`, `notes`, `state` +- `commit` (commit identity string, or null) +- `indicators`: list of `{uuid, machine, test, metric}` + +**List response items** include: `uuid`, `title`, `bug`, `state`, `commit`, +`machine_count`, `test_count`. The `notes` field is included in detail +responses only, not in list. + +**Indicator add request** (`POST /regressions/{uuid}/indicators`): +- Array of `{machine, test, metric}` objects. Each object is one indicator. + Duplicates (same regression+machine+test+metric) are silently ignored. + +**Indicator remove request** (`DELETE /regressions/{uuid}/indicators`): +- Body: `{"indicator_uuids": ["...", "..."]}` + + +## Time Series + +### Query + +``` +POST /query +``` + +Body (JSON): `{metric, machine, test, commit, after_commit, before_commit, + after_time, before_time, sort, limit, cursor}` + +The `metric` field is required; all other fields are optional. The `test` +field accepts a list of names for disjunction queries. The `commit` field +filters for an exact commit match and cannot be combined with +`after_commit`/`before_commit`. Time and commit range filters use exclusive +bounds (strictly after / strictly before the given value). + +Returns cursor-paginated time-series data for graphing. Each data point +contains: `test`, `machine`, `metric`, `value`, `commit`, `ordinal`, +`run_uuid`, `submitted_at`. + +Sort fields: `test`, `commit` (by ordinal), `submitted_at`. When `sort` is +omitted, results are returned in an arbitrary but stable order suitable for +cursor pagination; no data is excluded. When `commit` is included in the sort, +samples for commits without ordinals are excluded (they have no meaningful +position in ordinal order). + +### Trends (Aggregated) + +``` +POST /trends +``` + +Body (JSON): `{metric, machine, last_n}` + +The `metric` field is required and must have type `real`; `status` and `hash` +metrics are rejected with 400. All other fields are optional. Unlike the query +endpoint's single machine string, `machine` accepts a list of names -- the +Dashboard needs data for multiple machines in one call. `last_n` (integer, +min 1, max 10000) limits the result to the most recent N commits by ordinal. +Only commits with a non-null ordinal are included. + +Returns geomean-aggregated trend data per (machine, commit). Not paginated -- +the result set is bounded by (machines x last_n), typically < 5000 rows. Each +item contains: machine name, commit string, ordinal (always present, never +null), submitted_at (latest run submission time, may be null), and geomean +value. + +Geomean is computed in SQL: `exp(avg(ln(positive_values)))`, skipping +zero/negative values. + + +## Schema and Fields + +Schema definitions and metric field metadata are returned as part of the test +suite detail response (`GET /api/v5/test-suites/{name}`) rather than as +standalone endpoints. The response includes a `"schema"` object containing +`machine_fields`, `commit_fields`, and `metrics` (with `name`, `type`, +`display_name`, `unit`, `unit_abbrev`, `bigger_is_better` for each). + +There are no separate `/fields` or `/schema` endpoints. diff --git a/docs/design/api/infrastructure.md b/docs/design/api/infrastructure.md new file mode 100644 index 000000000..aa245a702 --- /dev/null +++ b/docs/design/api/infrastructure.md @@ -0,0 +1,105 @@ +# v5 REST API: Infrastructure + +This document covers the framework, URL structure, pagination, filtering, +response format, authentication, testing strategy, and deferred features for +the v5 REST API. + +For endpoint specifications, see [`endpoints.md`](endpoints.md). + + +## R1: Framework and Standards + +- Implement using flask-smorest within the existing Flask application +- OpenAPI 3.x specification auto-generated from code annotations and marshmallow schemas +- Target Python 3.10+ +- New code lives in `lnt/server/api/v5/` package, completely separate from existing API code +- Existing v4 API remains unchanged -- no modifications to `api.py` or its behavior +- Reuse existing database models (SQLAlchemy schemas in `testsuitedb.py`, `testsuite.py`, etc.) but do not treat reuse of existing API implementation code as a requirement -- write clean new implementations where appropriate +- CORS headers enabled on all v5 endpoints (`Access-Control-Allow-Origin: *`) + + +## R2: URL Structure and Identifiers + +- Base path: `/api/v5/{testsuite}/` +- Always uses the default database (no `db_` prefix) +- Entities addressed by natural keys (machine name, test name) or UUIDs (runs, regressions) -- never by internal auto-increment database IDs. Run UUIDs may be client-provided or server-generated; all other UUIDs are server-generated. +- A discovery endpoint at `GET /api/v5/` lists available test suites with links to their resources + + +## R4: Pagination + +- Cursor-based pagination for unbounded lists: runs, tests, commits, samples, regressions, regression indicators, time series +- Simple offset-based or unpaginated for bounded/small lists: machines, API keys +- Cursor-paginated response envelope: `{"items": [...], "cursor": {"next": "...", "previous": "..."}}` +- Default page size 25 with configurable `limit` parameter (max 10,000) +- Cursors are opaque strings (clients must not parse them) + + +## R5: Filtering and Sorting + +- Named query parameters per endpoint, documented in OpenAPI spec +- Supported filter types per endpoint (examples): + - `machine=`, `test=`, `metric=`, `search=` (case-insensitive substring; see D9) + - `after=`, `before=` (for timestamps and order values; exclusive) + - `state=` (for regressions, supports multiple values: `?state=active&state=detected`) + - `commit=`, `has_commit=` (for regressions), `has_profiles=` (for commits and runs) + - `sort=` (prefix with `-` for descending: `sort=-start_time`) +- Exact filters and available sort fields defined per endpoint in the OpenAPI spec +- Filtering by a nonexistent entity name (`machine=`, `test=`) returns 404. Filtering by an unknown metric returns 400. + + +## R6: Response Format + +- All responses in JSON +- Standardized error format: + `{"error": {"code": "not_found", "message": "Machine 'foo' not found in test suite 'nts'"}}` +- Standard HTTP status codes: 200, 201, 204, 304, 400, 401, 403, 404, 409, 422 +- ETag headers on GET responses; support `If-None-Match` for conditional requests returning 304 Not Modified when data hasn't changed + + +## R7: Authentication and Authorization + +- `Authorization: Bearer ` header on all requests +- API keys with scopes (each scope includes all scopes above it): + - **read** -- all GET endpoints + - **submit** -- submit runs (`POST /runs`), create commits (`POST /commits`) + - **triage** -- create/update/delete regressions, manage regression indicators + - **manage** -- create/update/delete machines; update/delete commits; delete runs + - **admin** -- create/revoke API keys +- Keys stored hashed in the database +- Admin endpoints (outside any test suite): + +``` +GET /api/v5/admin/api-keys -- List keys (admin) +POST /api/v5/admin/api-keys -- Create key (admin), returns the raw token once +DELETE /api/v5/admin/api-keys/{prefix} -- Revoke key (admin) +``` + + +## R8: Testing + +- All API endpoints must have automated tests +- Use current best practices for Flask API testing (e.g. pytest with Flask test client, or similar) +- Tests should cover: happy paths, error cases, authentication/authorization, pagination, filtering + + +## R9: Not in Scope (Deferred) + +- Webhooks / change notifications +- Multi-database support +- Rate limiting +- Run comparison / derived analytics endpoints +- Report endpoints (daily, summary, latest runs) +- Machine merge + + +## R10: AI Agent Orientation + +- Serve a plain-text orientation document at `GET /llms.txt` (following the + llms.txt convention, analogous to robots.txt) +- Content: what LNT is, key domain concepts, API structure, common workflows, + and links to Swagger UI / OpenAPI spec +- Static content, no authentication required +- Served as `text/plain` with UTF-8 charset +- Registered as a plain Flask blueprint (not flask-smorest) so it does not + appear in the OpenAPI spec diff --git a/docs/design/db/data-model.md b/docs/design/db/data-model.md new file mode 100644 index 000000000..74d98e820 --- /dev/null +++ b/docs/design/db/data-model.md @@ -0,0 +1,297 @@ +# v5 Database Layer: Data Model + +This document defines the v5 database architecture, the Commit concept, schema +storage and format, and all table definitions. + +For the exploration and discussion that led to the Commit concept, see +[`../v5-discussion-about-orders.md`](../v5-discussion-about-orders.md). +For operations (submission, queries, migration), see +[`operations.md`](operations.md). + + +## D1: Architecture and Separation + +- The v5 DB layer lives in `lnt/server/db/v5/`, a self-contained package with + its own schema parsing, models, and CRUD interface. +- No imports from v4 DB code (`lnt.server.db.testsuite`, `testsuitedb`, + `v4db`, `regression`). The v5 package is fully independent. +- v4 and v5 coexist in the same codebase, selected by `db_version` in the LNT + config file (`'0.4'` for v4, `'5.0'` for v5). +- A v5 instance serves only v5 API endpoints. A v4 instance serves v4 views + and the v4 REST API only. The v5 REST API and v5 frontend are available + only on v5 instances. +- Postgres only. No SQLite or MySQL support. +- SQLAlchemy 1.3 (same version as v4, to avoid upgrade risk). +- All new code uses Python 3.10+ idioms: type hints (`X | Y`, `list[T]`), + dataclasses, f-strings, `match` where appropriate. + + +## D2: The Commit Concept (replaces Orders) + +The v4 "Order" concept conflated three concerns: identity (what groups runs), +ordering (sequential position for time-series), and display (what the UI shows). +The v5 "Commit" concept cleanly separates these. + +- **Commit**: A named point that groups runs. The `commit` column is a single + string (e.g., a Git SHA, version number, or ad-hoc label like + `"experiment-vectorizer-v2"`). It is the identity of the commit. By default, + the UI also uses it for display, but a `commit_field` marked `display: true` + overrides what is shown (see D4). Every run must have a commit. +- **Ordinal**: An optional integer that places the commit in a total order. + Always assigned via PATCH, never inferred from the commit string (even if the + string is numeric). `NULL` means unordered. + +Two tiers of runs: +1. Run with ordered commit (ordinal set): full time-series participation. +2. Run with unordered commit (ordinal NULL): grouped but not positioned in the + time series. Used for throwaway A/B comparisons (use an ad-hoc commit + string like `"experiment-vectorizer-v2"`), or as the transient state before + an external process assigns an ordinal. + +Cleanup: unordered commits that are no longer needed can be deleted via the API, +which cascades to their runs and samples. + + +## D3: Schema Storage and Lifecycle + +Test suite schemas are created via the API (`POST /api/v5/test-suites`) and +persisted in the database, not on the filesystem. The `schemas/*.yaml` files +in the repository are documentation/examples only -- the server does not read +them. + +**Global tables** (not per-suite, shared across all suites): + +| Table | Columns | +|---|---| +| `v5_schema` | `name` (String PK), `schema_json` (Text), `created_at` (DateTime) | +| `v5_schema_version` | `id` (Integer PK, always 1), `version` (Integer) | + +On startup, `V5DB` reads all rows from `v5_schema`, parses each into a +`TestSuiteSchema`, and builds in-memory models. The `v5_schema_version` counter +is cached. + +**Multi-process safety**: In a multi-worker deployment (e.g., gunicorn), when +one worker creates or deletes a suite, it bumps the `v5_schema_version` counter +in the same transaction. Every v5 request path -- API endpoints and SPA shell +pages alike -- must compare its cached version counter against the database +before reading the in-memory suite registry. When a mismatch is detected, all +schemas are reloaded from the database. The check is a single-row integer read +per request. + + +## D4: Schema Format + +Each test suite is defined by a YAML schema file. The v5 format is a clean +break from v4 (no backward compatibility required). + +```yaml +name: nts + +metrics: +- name: compile_time + type: real # real | status | hash + display_name: Compile Time + unit: seconds + unit_abbrev: s + bigger_is_better: false +- name: execution_time + type: real +- name: compile_status + type: status + +machine_fields: +- name: hardware + searchable: true +- name: os + searchable: true + +commit_fields: +- name: git_sha + searchable: true +- name: author + searchable: true +- name: commit_message + type: text # default (String 256) | text | integer | datetime +- name: commit_timestamp + type: datetime +``` + +Key differences from v4: +- `run_fields` with `order: true` is gone. The commit is a built-in concept. +- `run_fields` section is removed entirely -- extra run data goes in + `run_parameters` (JSONB). +- `commit_fields` defines optional typed metadata columns on the Commit table. +- `searchable: true` on commit_fields or machine_fields enables `?search=` + substring matching on the corresponding list API endpoint (see D9). +- `display: true` on at most one commit_field is a hint for the UI: when set + and the field has a non-null value, the UI shows that value instead of the + raw commit string (e.g., a shortened SHA, a version tag). This is purely a + UI concern -- the DB layer does not treat display fields specially. +- No `format_version` in the schema file (only one format exists for v5). + + +## D5: Data Model + +Per-suite tables are dynamically named (e.g., `nts_Commit`, `nts_Run`). + +**Timestamp convention**: All `DateTime` columns store timezone-aware UTC +timestamps (`TIMESTAMP WITH TIME ZONE` in PostgreSQL). Implementations +must ensure timestamps are converted to UTC before storage. API responses +serialize timestamps as ISO 8601 with `Z` suffix +(e.g., `"2026-04-15T14:30:00Z"`). + +### `{suite}_Commit` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| commit | String(256) | unique, not null | +| ordinal | Integer | nullable, unique | +| tag | String(256) | nullable, indexed (partial: WHERE tag IS NOT NULL) | +| _(dynamic)_ | per commit_fields | nullable | + +- `commit` is the identity string provided by submitters. Used as the default + display value in the UI unless a `commit_field` with `display: true` is + defined and populated. +- `ordinal` has a regular unique constraint. Ordinals are assigned once by an + external process and are not expected to be reassigned. +- `tag` is an optional human-readable label (e.g., "release-18.1"). Set + exclusively via ``PATCH /commits/{value}`` (never during submission). + Multiple commits may share the same tag. The tag is always included in + ``?search=`` substring matching (see D9). When set, the UI appends it to the display + value as `` (tag)``. +- Dynamic columns are created from `commit_fields` in the schema. +- No linked list (NextOrder/PreviousOrder from v4 are gone). +- Commits are deletable. Deleting a commit cascades to its runs (and their + samples). Commits referenced by a Regression's commit_id cannot be deleted + (the API returns 409). +- Schema-defined `commit_fields` names must not collide with built-in column + names (`id`, `commit`, `ordinal`, `tag`). The schema parser rejects these. + +### `{suite}_Machine` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| name | String(256) | unique, not null | +| parameters | JSONB | not null, default `{}` | +| _(dynamic)_ | per machine_fields | nullable | + +- `name` uniqueness is enforced (fixes a v4 bug). +- `parameters` stores extra key-value data as Postgres JSONB. +- Schema-defined `machine_fields` names must not collide with built-in column + names (`id`, `name`, `parameters`). The schema parser rejects these. + +### `{suite}_Run` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| uuid | String(36) | unique, not null, indexed | +| machine_id | Integer FK -> Machine | not null, indexed | +| commit_id | Integer FK -> Commit | not null, indexed | +| submitted_at | DateTime | not null | +| run_parameters | JSONB | not null, default `{}` | + +- Every run must have a commit (`commit_id` is not null). +- `submitted_at` replaces v4's `start_time`/`end_time` (client-side timing + goes in `run_parameters` if needed). +- Cascade: deleting a machine cascades to its runs; deleting a commit cascades + to its runs. Deleting a run cascades to its samples. + +### `{suite}_Test` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| name | String(256) | unique, not null | + +### `{suite}_Sample` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| run_id | Integer FK -> Run | not null | +| test_id | Integer FK -> Test | not null | +| _(dynamic)_ | per metrics | nullable | + +- Compound index on `(run_id, test_id)` — covers "all samples for a run". +- Compound index on `(test_id, run_id)` — covers time-series queries. +- Dynamic columns from schema metrics: `real` -> Float, `status` -> Integer, + `hash` -> String(256). + +### `{suite}_Regression` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| uuid | String(36) | unique, not null, indexed | +| title | String(256) | nullable | +| bug | String(256) | nullable | +| notes | Text | nullable | +| state | Integer | not null, indexed | +| commit_id | Integer FK -> Commit | nullable, indexed | + +Regression state values: + +| Value | Name | +|-------|------------------| +| 0 | detected | +| 1 | active | +| 2 | not_to_be_fixed | +| 3 | fixed | +| 4 | false_positive | + +The DB layer validates state values on create and update. + +### `{suite}_RegressionIndicator` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| uuid | String(36) | unique, not null, indexed | +| regression_id | Integer FK -> Regression | not null, indexed | +| machine_id | Integer FK -> Machine | not null | +| test_id | Integer FK -> Test | not null | +| metric | String(256) | not null | + +- Unique constraint on `(regression_id, machine_id, test_id, metric)`. +- Each indicator represents one (machine, test, metric) combination + affected by the regression. + +### `{suite}_Profile` + +| Column | Type | Constraints | +|--------|------|-------------| +| id | Integer | PK | +| uuid | String(36) | unique, not null, indexed | +| run_id | Integer FK -> Run | not null | +| test_id | Integer FK -> Test | not null, indexed | +| created_at | DateTime(tz) | not null | +| data | LargeBinary | not null, deferred | + +- Unique constraint on `(run_id, test_id)` -- at most one profile per + run+test pair. +- `data` stores the profile binary blob (base64-decoded on submission). + Uses SQLAlchemy `deferred()` so normal queries do not load the blob. + Any query that needs the blob must use explicit `undefer(Profile.data)`. +- `uuid` is server-generated, used by the API for profile data endpoints. + (Unlike Run UUIDs, which may be client-provided, Profile and Regression + UUIDs are always server-generated.) +- No `counters` cache column -- top-level counters are extracted by + deserializing the blob on demand (cheap: the binary format stores + counters in an uncompressed section). +- Cascade: deleting a run cascades to its profiles; deleting a test + cascades to its profiles. +- Maximum accepted profile size on submission: 50 MB (decoded). Submissions + exceeding this are rejected with 413. + +### Tables Dropped from v4 + +- **Baseline**: v5 comparisons are stateless API operations. +- **ChangeIgnore**: Dropped. Noise dismissal happens at the regression level + via the `false_positive` state with notes. +- **FieldChange**: Dropped. Regressions directly reference affected machines, + tests, and metrics via RegressionIndicator. +- **Profile**: Redesigned for v5 (see `{suite}_Profile` above). +- **Order**: Replaced by Commit. diff --git a/docs/design/db/operations.md b/docs/design/db/operations.md new file mode 100644 index 000000000..53e233835 --- /dev/null +++ b/docs/design/db/operations.md @@ -0,0 +1,199 @@ +# v5 Database Layer: Operations + +This document covers how data flows through the v5 database: submission, metadata +management, search, time-series queries, ordinal management, and migration from v4. + +For the data model and table definitions, see [`data-model.md`](data-model.md). + + +## D6: Submission Format + +Runs are submitted as JSON via `POST /api/v5/{suite}/runs`. + +```json +{ + "format_version": "5", + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "machine": { + "name": "my-machine", + "hardware": "x86_64", + "os": "linux" + }, + "commit": "abc123def456", + "commit_fields": { + "git_sha": "abc123def456789...", + "author": "Jane Doe", + "commit_message": "Fix vectorizer regression" + }, + "run_parameters": { + "build_config": "Release" + }, + "tests": [ + { + "name": "test.suite/benchmark", + "execution_time": 1.23, + "compile_time": 0.45, + "profile": "" + } + ] +} +``` + +- `format_version`: Required, must be `"5"`. +- `uuid`: Optional. Client-provided UUID for the run, in standard `8-4-4-4-12` + hyphenated hex format (e.g., output of `uuidgen`). Case-insensitive on input; + normalized to lowercase for storage. Any UUID version is accepted (v4, v5, + v7, etc.) -- only the format is validated. If a run with the same UUID + already exists in the test suite, the server returns 409 Conflict. If omitted, + the server generates a random UUID v4. +- `machine`: Required. `name` is required; other keys match `machine_fields` + from the schema and are stored in the corresponding columns. Keys that do not + match any `machine_fields` entry go into the `parameters` JSONB blob. +- `commit`: Required string. Identifies which commit this run belongs to. +- `commit_fields`: Optional. Keys match `commit_fields` from the schema. + First-write-wins: if the commit already exists, metadata is not overwritten. + Use PATCH on the commit to update metadata after creation. +- `run_parameters`: Optional. Stored as JSONB on the Run. +- `tests`: Required. Each entry has `name` plus metric values. Metric values + may be scalars or arrays. An array value (e.g. `"execution_time": [0.1, 0.2]`) + creates one Sample row per element. All arrays in a single test entry must + have the same length; scalar values are repeated across the resulting rows. + Metrics with null values must be omitted from the test entry (not sent as + `"metric": null`); only include metrics that have actual values. An optional + `profile` field may contain base64-encoded profile binary data; if present, + a Profile row is created and linked to the run+test. The `profile` key is + a reserved name and must not collide with metric names. + + +## D7: Commit Metadata Population + +Commit metadata (`commit_fields`) can be set via two paths: + +1. **Inline during run submission**: The `commit_fields` dict in the submission + JSON populates metadata on the Commit record when it is first created. + If the commit already exists, metadata is NOT overwritten (first-write-wins). +2. **Via PATCH**: `PATCH /api/v5/{suite}/commits/{value}` can set or update + metadata fields at any time, overwriting existing values. + +Ordinals are set exclusively via PATCH (see D11). + + +## D8: No Regression Auto-Detection + +All Regressions and their indicators are created, updated, and deleted via the +API. There is no auto-detection in the v5 DB layer -- it provides CRUD only. + +Regression detection is the responsibility of an external process (a separate +tool or AI agent) that analyzes time-series data and creates Regressions via +the API when it detects significant changes. + + +## D9: Search + +List endpoints for commits, machines, and tests support a unified `?search=` +parameter. + +- `GET /commits?search=abc` matches `commit` column, `tag` column, OR any + `searchable` commit_field via case-insensitive substring matching (OR + semantics). +- `GET /machines?search=x86` matches `name` column OR any `searchable` + machine_field via case-insensitive substring matching (OR semantics). +- `GET /tests?search=bench` matches the `name` column via case-insensitive + substring matching. +- This replaces v4's ad-hoc `tag_prefix`, `name_prefix`, `name_contains` + parameters with a consistent pattern. + + +## D10: Time-Series Queries + +The primary query pattern is: "give me metric values for (machine, test, +metric) ordered by commit ordinal." + +This is `Sample JOIN Run JOIN Commit` filtered by `machine_id` and `test_id`, +ordered by `Commit.ordinal`. + +When sorting by ordinal, commits without ordinals are excluded (they have no +meaningful position). When not sorting by ordinal, all runs are included +regardless of their commit's ordinal. + +Cursor-based pagination requires a deterministic, non-repeating row ordering. +The server appends an internal unique tiebreaker to the caller's sort +specification to guarantee this. The tiebreaker is opaque to clients; cursors +are treated as opaque tokens. When no sort parameter is provided, results are +returned in an arbitrary but deterministic order suitable for pagination, and +no data is excluded. + + +## D11: Ordinal Management + +- Ordinals are always `NULL` on commit creation. +- Ordinals are assigned exclusively via `PATCH /commits/{value}`. +- Even numeric commit strings (e.g., `"311066"`) do not auto-assign ordinals. +- The unique constraint on ordinal is a regular (non-deferred) constraint. + Ordinals are assigned once by an external process and are not expected to + be reassigned. +- `previous` and `next` navigation on a commit is computed by querying for + the nearest lower/higher ordinal (not a linked list). + + +## D12: Migration from v4 + +A separate offline tool (`lnt admin migrate-to-v5`) converts a v4 database to +a v5 Postgres database: + +- Orders -> Commits: primary order field value becomes the commit string, + linked-list position becomes the ordinal. +- v4 `tag` column on Order -> v5 `Commit.tag` directly. +- Run.order_id -> Run.commit_id; Run.start_time -> Run.submitted_at. +- v4 FieldChange + RegressionIndicator -> v5 RegressionIndicator (machine_id, + test_id, metric resolved from FieldChange; field_change_id FK removed). +- Baseline, ChangeIgnore, Profile tables are not migrated. + + +## D13: Profile Submission and Storage + +Profiles are submitted inline as part of run submission. Each test entry in +the submission JSON may include a `"profile"` field containing base64-encoded +profile binary data. + +On submission: +1. The `profile` field is recognized as a reserved key (not a metric) and + excluded from metric name validation. +2. The base64 data is decoded to raw bytes. Invalid base64 is rejected + with 400. +3. The format version byte is validated (must be 2). Invalid format is + rejected with 422. +4. A Profile row is created with `(run_id, test_id, created_at, data)`. +5. The unique constraint on `(run_id, test_id)` prevents duplicate profiles. + +Profiles are read-only after creation -- there is no PATCH endpoint. +Deleting a run cascades to its profiles. + +Profile data is stored as Postgres BYTEA via SQLAlchemy LargeBinary. +Postgres automatically applies TOAST compression for large values. The +`deferred()` column loading ensures the blob is not fetched by default. + + +## D14: Concurrent Submission + +Run submission (`POST /runs`) is atomic from the API user's perspective: it +either fully succeeds (201) or fully fails with no partial side effects. + +Machines, commits, and tests are created via a get-or-create pattern. When +two concurrent sessions race to create the same entity, the loser's INSERT +hits a unique constraint violation. All three get-or-create methods handle +this with **savepoint-based retry**: + +1. The INSERT is wrapped in `session.begin_nested()` (Postgres SAVEPOINT). +2. On `IntegrityError`, only the savepoint is rolled back -- prior work in + the same transaction (e.g., a machine created earlier in the same + `import_run` call) is preserved. +3. The method re-queries by name and returns the row created by the winner. + +This makes concurrent submissions for the same machine, commit, or test +names safe. No client-side retry is needed. + +For tests specifically, a batch resolution path exists that resolves all test +names in O(1) DB round-trips regardless of test count. It provides the same +concurrency safety guarantee as the single-test path: concurrent submissions +with overlapping test sets never produce errors or partial results. diff --git a/docs/design/ui/admin.md b/docs/design/ui/admin.md new file mode 100644 index 000000000..ba5f864f6 --- /dev/null +++ b/docs/design/ui/admin.md @@ -0,0 +1,26 @@ +# v5 Web UI: Admin Page + +Page specification for the Admin page. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). + + +## Admin -- `/v5/admin` + +Not test-suite specific. Served at `/v5/admin` (outside the `{ts}` namespace) +with its own Flask route. The SPA shell is served without a testsuite; the +admin page reads the list of available test suites from the HTML +`data-testsuites` attribute. + +| Tab | Shows | API Calls | +|-----|-------|-----------| +| API Keys | List, create, revoke API keys (global to instance) | `GET/POST/DELETE admin/api-keys` | +| Test Suites | Suite selector, schema viewer, delete suite | `GET/DELETE test-suites` | +| Create Suite | Name input + JSON schema definition textarea | `POST test-suites` | + +**Test Suites tab details**: +- **Suite selector**: Dropdown to switch between test suites. Selecting a suite loads and displays its schema (metrics, commit fields, machine fields, run fields). +- **Delete suite**: A delete button per suite. Clicking it shows an inline confirmation panel explaining that deleting a suite permanently destroys all machines, runs, commits, samples, and regressions, and is irreversible. The user must type the exact suite name to confirm. Calls `DELETE /api/v5/test-suites/{name}?confirm=true`. Requires `manage` scope. + +**Create Suite tab**: +- A name input and a JSON textarea where the user pastes the full suite definition (name, metrics, commit_fields, machine_fields). The JSON format matches the `POST /api/v5/test-suites` API. On success, switches to the Schemas tab with the new suite auto-selected. Requires a token with `manage` scope. diff --git a/docs/design/ui/architecture.md b/docs/design/ui/architecture.md new file mode 100644 index 000000000..b30b93d8d --- /dev/null +++ b/docs/design/ui/architecture.md @@ -0,0 +1,230 @@ +# v5 Web UI: Architecture + +This document covers the SPA architecture, client-side routing, Flask backend +routes, navigation bar, v4/v5 toggle, frontend code structure, and +implementation phases. + +For individual page specifications, see the other documents in this directory: +[Dashboard](dashboard.md), [Browsing Pages](browsing.md), [Graph](graph.md), +[Compare](compare.md), [Profiles](profiles.md), [Admin](admin.md). + + +## Context + +LNT's current web UI is built on v4 Flask/Jinja2 server-rendered pages with +jQuery 1.7 and Bootstrap 2. It works but feels dated. The v5 REST API is now +complete and a single v5 page already exists (the Compare SPA). This plan +designs a complete new UI built exclusively on the v5 API. + + +## Single-Page Application + +**One SPA with client-side routing**, extending the pattern proven by the +existing Compare page. + +- **Framework**: Vanilla TypeScript (no React/Vue) -- matches the existing Compare SPA +- **Build**: Vite, single IIFE bundle (`v5.js` + `v5.css`) +- **Charts**: Plotly.js (loaded from CDN) +- **Routing**: Simple path-based client-side router using History API. All internal links set their `href` to the real URL and intercept plain clicks for SPA navigation (no full page reload). Modified clicks (Cmd+Click, Ctrl+Click, Shift+Click, middle-click) bypass the SPA router and let the browser handle them natively (e.g. open in a new tab). +- **State**: URL query params for shareable deep-links; localStorage for auth token + +**Design consistency**: All pages should share a consistent look and feel, using the v5 Compare page as the reference for UI patterns -- comboboxes, metric selectors, table styling, progress/error feedback, color scheme, and layout spacing. Reuse the same components across pages rather than reinventing per-page. Pages with selection controls (dropdowns, filters, aggregation settings) wrap them in a shared controls panel -- a lightly shaded box with a border -- so the settings area is visually distinct from the page content. + +**Text filtering**: All client-side text filter and search inputs share a +unified filtering behavior. Plain text performs case-insensitive substring +matching. Prefixing the input with `re:` (case-sensitive literal prefix) +switches to case-insensitive regex matching. When regex mode is active (the +input starts with `re:`), a small inline "regex" badge appears at the right +edge of the input. The badge is blue for valid regex and red for invalid regex +syntax. Invalid regex patterns also show a red halo on the input border. This +convention applies uniformly to all text filter inputs across the UI: test name +filters, machine name filters, regression title searches, indicator filters, +combobox suggestion filters, and function name filters. The `re:` prefix is not +consumed or hidden -- the user sees it in the input and it is included in URL +state. + +**Text filtering performance**: All pages with large tables (Compare, Graph) +must keep filter typing responsive even with thousands of rows. Typing in a +filter input must produce a visible table update within a single animation +frame. Chart updates may be deferred to avoid blocking the input. + +**Authentication**: The v5 API allows unauthenticated reads by default (configurable via `require_auth_for_reads` in `lnt.cfg`). All pages in the current scope are read-only, so no authentication is needed. The SPA navigation bar includes a Settings panel with a Bearer token input (stored in localStorage) for the Admin page and future write-capable pages (regression triage, etc.). + +**Why SPA over server-rendered pages:** +- Avoids full-page reloads (and re-downloading Plotly) when navigating +- Shared state (auth token, test suite context) lives naturally in the app +- The Compare page already proves vanilla TS + Vite works well +- All data comes from the v5 REST API -- no server-side rendering needed + + +## Flask Backend: Suite-Agnostic and Suite-Scoped Routes + +The v5 API only supports the default DB, so v5 frontend routes do not include +`db_` prefixes. + +Suite-agnostic pages (dashboard, test suites, admin, graph, compare) are served +at top-level `/v5/` routes with `data-testsuite=""`, while suite-scoped pages +use the catch-all route which passes the test suite name as `data-testsuite`. + +```python +# lnt/server/ui/v5/views.py +@v5_frontend.route("/v5/", strict_slashes=False) +@v5_frontend.route("/v5/test-suites", strict_slashes=False) +@v5_frontend.route("/v5/admin", strict_slashes=False) +@v5_frontend.route("/v5/graph", strict_slashes=False) +@v5_frontend.route("/v5/compare", strict_slashes=False) +@v5_frontend.route("/v5/profiles", strict_slashes=False) +def v5_global(): + ...renders v5_app.html shell with empty testsuite... + +@v5_frontend.route("/v5//") +@v5_frontend.route("/v5//") +def v5_app(testsuite_name, subpath=None): + ...renders v5_app.html shell for suite-scoped pages... +``` + +The existing Compare page route (`v5_compare`) also needs to be updated to +remove its `db_` variant. + +The shell template (`v5_app.html`) is a standalone HTML page (it does NOT +extend `layout.html`) and mounts `
` with the SPA bundle. This +avoids inheriting v4 CSS/JS (Bootstrap 2, jQuery, DataTables) and layout +artifacts (fixed-navbar margins, sticky footer). + + +## Page Hierarchy + +``` +/v5/ Dashboard (landing page -- sparkline trend overview) +/v5/test-suites?suite={ts}&tab=... Test Suites (suite picker + browsing tabs) +/v5/{ts}/ Suite root (redirects to /v5/test-suites?suite={ts}) +/v5/{ts}/machines/{name} Machine Detail +/v5/{ts}/runs/{uuid} Run Detail +/v5/{ts}/commits/{value} Commit Detail +/v5/{ts}/regressions/{uuid} Regression Detail +/v5/graph?suite={ts}&machine=... Graph (time series) -- suite-agnostic +/v5/compare?suite_a={ts}&... Compare -- suite-agnostic +/v5/profiles?suite_a={ts}&... Profiles (A/B profile viewer) -- suite-agnostic +/v5/admin Admin (API keys, schemas -- not test-suite specific) +``` + + +## Navigation Bar + +``` +[LNT] [Test Suites] [Graph] [Compare] [Profiles] [API] <----> [Admin] [Settings] +``` + +All navbar links are suite-agnostic. The navbar behavior depends on the page context: + +- **Suite-agnostic context** (`/v5/...` without a suite): All navbar links use SPA navigation. API opens in a new tab. +- **Suite-scoped context** (`/v5/{ts}/...`): All navbar links use full-page navigation (since they target `/v5/...` which is outside the suite basePath `/v5/{ts}`). + +Graph, Compare, and Profiles links append `?suite={ts}` / `?suite_a={ts}` when +navigated from suite-scoped context, pre-filling the current suite. The Test +Suites link appends `?suite={ts}` to preserve the suite context. + + +## v4/v5 Toggle + +v4 and v5 are fully disjoint: in v4 mode no v5 code is registered, and in +v5 mode no v4 code is registered. There are no cross-links between the two +UIs. + + +## Frontend Code Structure + +``` +lnt/server/ui/v5/frontend/src/ ++-- main.ts Entry point, SPA bootstrap ++-- router.ts Client-side URL routing (History API) ++-- api.ts Extend existing API client ++-- types.ts Extend existing types ++-- state.ts Extend existing URL state management ++-- events.ts Extend existing custom events ++-- utils.ts Extend existing utilities (el(), formatValue(), etc.) ++-- style.css Extend existing styles ++-- pages/ +| +-- home.ts Suite-agnostic dashboard (sparkline trend overview) +| +-- test-suites.ts Suite-agnostic test suites page (picker + tabs) +| +-- machine-detail.ts +| +-- run-detail.ts +| +-- commit-detail.ts +| +-- graph.ts +| +-- compare.ts Compare page module (auto-compare, caching, row toggling) +| +-- regression-list.ts Regression tab renderer (called by test-suites.ts) +| +-- regression-detail.ts +| +-- admin.ts +| +-- profiles.ts Profile viewer page (A/B comparison) ++-- components/ + +-- combobox.ts Generic combobox base (ARIA, keyboard, dismiss, halo) + +-- commit-combobox.ts Commit typeahead (exact-match validation, display map) + +-- machine-combobox.ts Machine typeahead (fetch-once, filter-locally) + +-- regression-combobox.ts Regression typeahead (fetch-once, UUID selection) + +-- nav.ts Navigation bar + +-- data-table.ts Reusable sortable/filterable table + +-- sparkline-card.ts Lightweight Plotly sparkline for Dashboard + +-- time-series-chart.ts Plotly time-series chart component + +-- metric-selector.ts Reusable metric drop-down (supports optional placeholder) + +-- commit-search.ts Commit search with tag-based autocomplete + +-- pagination.ts Cursor/offset pagination controls + +-- profile-viewer.ts Profile disassembly renderer (straight-line + CFG) + +-- profile-cfg.ts Control-flow graph renderer (D3-based) + +-- profile-stats.ts Top-level counter comparison bar +``` + +### Reuse from Existing Compare Page + +| Existing Module | Reuse Strategy | +|----------------|----------------| +| `api.ts` | Extend with new endpoint functions | +| `types.ts` | Extend with new interfaces | +| `components/combobox.ts` | Generic combobox base shared by all typeahead selectors (ARIA, keyboard nav, blur/outside-click dismiss, validation halo) | +| `utils.ts` | Reuse `el()`, `formatValue()`, aggregation functions | +| `chart.ts` | Compare page bar chart (extended with text filter, zoom preservation) | +| `table.ts` | Compare page table (extended with row toggling, geomean, summary message) | +| `comparison.ts`, `selection.ts` | Core comparison logic and selection panel, wrapped by `pages/compare.ts` | + + +### Build Config Change + +```typescript +// vite.config.ts -- output changes from comparison.js to v5.js +lib: { + entry: resolve(__dirname, 'src/main.ts'), + formats: ['iife'], + name: 'LNTv5', + fileName: () => 'v5.js', +}, +outDir: resolve(__dirname, '../static/v5'), +``` + + +## API Additions Needed + +The Profiles page (Phase 7) requires new API endpoints: profile listing per +run, profile metadata/functions/detail by UUID. These are specified in +[endpoints.md](../api/endpoints.md). All other workflows can be served by +the existing v5 API. + + +## Implementation Phases + +| Phase | Pages | Foundation Work | +|-------|-------|-----------------| +| 1 | (none visible) | SPA shell, router, nav bar, Flask catch-all route, build config | +| 2 | Test Suites (picker + tabs), Machine Detail, Run Detail, Commit Detail | Core browsing -- data-table component, pagination, suite picker | +| 3 | Graph | Time-series chart component, combobox integration, aggregation controls, regression annotations | +| 4 | Compare | Absorb existing compare page into SPA as page module, add geomean summary | +| 5 | Regression Detail | Full regression management page, cross-page integration | +| 6 | Admin, polish | API key management, error handling, loading states | +| 7 | Profiles | DB model, binary format parser, API endpoints, Profiles page (A/B picker, stats bar, straight-line + CFG view), Compare/Run Detail integration | + + +## Verification + +After each phase, verify by: +1. Running the dev server (`lnt runserver`) and navigating to `http://localhost:8000/v5/{ts}/` +2. Checking that SPA routing works (browser back/forward, direct URL access) +3. Checking that all API calls succeed (browser DevTools Network tab) +4. Running Vitest unit tests: `cd lnt/server/ui/v5/frontend && npm test` diff --git a/docs/design/ui/browsing.md b/docs/design/ui/browsing.md new file mode 100644 index 000000000..97f8fa941 --- /dev/null +++ b/docs/design/ui/browsing.md @@ -0,0 +1,201 @@ +# v5 Web UI: Browsing Pages + +Page specifications for the data browsing pages: Test Suites, Machine Detail, +Run Detail, and Commit Detail. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). +Related pages: [Graph](graph.md), [Compare](compare.md). + + +## Test Suites -- `/v5/test-suites?suite={ts}&tab=...` + +The primary entry point for browsing test suite data. Suite-agnostic page with +an internal suite picker and tabbed content. + +**Suite picker**: A row of prominent card/button elements, one per test suite +(from `data-testsuites`). Clicking a card selects it (highlighted) and shows +the tab bar below. When no suite is selected, only the suite picker is visible. + +**Tabs**: [Recent Activity] [Machines] [Runs] [Commits] [Regressions]. Default +tab is Recent Activity. + +**URL state**: `?suite={ts}&tab=machines&search=foo&offset=0` -- all state is +in query params. On mount, reads params to restore state. On changes, updates +URL via `replaceState`. + +| Tab | Content | API | Search/Filter | +|-----|---------|-----|---------------| +| Recent Activity | Last 25 runs sorted by time; "Load more" for next page | `GET runs?sort=-start_time&limit=25` | None | +| Machines | Searchable machine list with offset pagination | `GET machines?search=...&limit=25&offset=...` | Substring match (see D9) | +| Runs | Run list with cursor pagination | `GET runs?machine=...&sort=-start_time&limit=25` | Machine name (exact) | +| Commits | Commit list with cursor pagination | `GET commits?search=...&limit=25` | Search (substring match on commit, tag, searchable fields; see D9) | +| Regressions | Full regression triage interface (see below) | `GET regressions?state=...&limit=25` | State chips, machine combobox, metric selector, has_commit checkbox, title search | + +**Columns per tab:** +- **Recent Activity**: Machine, Commit (primary value), Start Time, UUID (truncated, linked) +- **Machines**: Name (linked), Info (key-value summary) +- **Runs**: UUID (truncated, linked), Machine, Commit (primary value), Start Time +- **Commits**: Commit Value (primary field, linked), Tag +- **Regressions**: Title (linked to regression detail), State (badge), Commit + (display value, linked to commit detail), Machine count, Test count, Bug (external link), + Delete button (auth-gated) + +"Primary value" / "primary field" means the display-field-resolved value: +if the schema defines a commit_field with ``display: true`` and the commit +has a non-null value for that field, show it instead of the raw commit +string (see D4 in db/data-model.md). When no display field is defined, +or the field is not populated for a given commit, fall back to the raw +commit string. Links always use the raw commit string in the URL. + +**Regressions tab details**: + +The Regressions tab embeds the full regression triage UI directly in the Test +Suites page (there is no standalone Regression List page). + +**Filters** (control panel above table): +- State: multi-select chips (detected, active, not_to_be_fixed, fixed, + false_positive) -- toggleable, all deselected by default +- Machine: combobox with typeahead +- Metric: dropdown +- Has commit: checkbox (surfaces regressions with unset commit) +- Free-text search on title (client-side, debounced) + +**Actions**: +- "New Regression" button (auth-gated) -> toggles an inline create form with + title, bug, state, commit fields. On successful creation, navigates to the + new regression's detail page. +- Row click -> navigates to regression detail page. +- Delete: per-row button with confirmation prompt (auth-gated). + +**Pagination**: Cursor-based, consistent with other list tabs. + +**Detail navigation**: Clicking an item navigates to the full suite-scoped +detail page (e.g., `/v5/{ts}/machines/{name}`) via full page navigation. This +crosses from suite-agnostic context to suite-scoped context. + +**Suite root redirect**: `/v5/{ts}/` redirects to `/v5/test-suites?suite={ts}`. + +**Links out**: Machine Detail, Run Detail, Commit Detail. + + +## Machine Detail -- `/v5/{ts}/machines/{name}` + +Deep dive into a single machine. Machine names are guaranteed unique. + +| Section | Shows | API Calls | +|---------|-------|-----------| +| Metadata | Machine info key-value pairs | `GET machines/{name}` | +| Run History | Paginated table of runs (newest first) -- commit column shows display value (primary value) | `GET machines/{name}/runs?sort=-start_time` | + +**Action links**: "View Graph" (pre-filled machine), "Compare" (pre-selected +machine), and "Delete Machine" button. Clicking "Delete Machine" shows a +confirmation prompt (below the action row) requiring the user to type the machine +name. Deletion requires a valid API token with `manage` scope. On success, +navigates to the test suites page. While the delete is in progress, a message +reassures the user that deletion may take a while for machines with many runs. + +**Links out**: Run Detail, Commit Detail, Graph (with machine pre-filled), +Compare (with machine pre-selected), Regression Detail. + +**Active regressions**: Below the action links, a section showing non-resolved +regressions (state: detected, active) with at least one indicator on this +machine. Each links to its regression detail page. A "Show all" link navigates +to the regressions tab pre-filtered by this machine. + + +## Run Detail -- `/v5/{ts}/runs/{uuid}` + +All data from a single test execution. + +| Section | Shows | API Calls | +|---------|-------|-----------| +| Metadata | Machine, commit (display value), start/end time, parameters | `GET runs/{uuid}` | +| Metric Selector | Drop-down to choose which metric to display (like Compare page) | `GET test-suites/{ts}` (fields from `schema.metrics`) | +| Test Filter | Text input for substring matching on test names | (client-side) | +| Samples Table | All samples + selected metric value, sorted by test name by default | `GET runs/{uuid}/samples` | + +The metric selector drop-down controls which metric column is shown in the +samples table, consistent with how the Compare page handles metric selection. + +Samples are loaded progressively -- the table renders immediately with the +first page and grows as more pages arrive, with a progress indicator showing +the count. Multiple samples for the same test (repetitions) appear as separate +rows. + +**Action links**: "Compare with..." (pre-selects this run's machine and commit +on side A) and "Delete Run" button. Clicking "Delete Run" shows a confirmation +prompt (below the action row) requiring the user to type the first 8 characters +of the run UUID. Deletion requires a valid API token with `manage` scope. On +success, navigates to the machine detail page. + +**Profile links**: Tests with profiles show a "Profile" link/icon in the +samples table. Profile presence is determined by calling +`GET /runs/{uuid}/profiles` (fetched once on page load, cached). The link +navigates to `/v5/profiles?suite_a={ts}&run_a={uuid}&test_a={test}`. + +**Links out**: Machine Detail, Commit Detail, Graph (test pre-filled), +Profiles (pre-populated with run + test), Compare (side A pre-selected). + + +## Commit Detail -- `/v5/{ts}/commits/{value}` + +The "what happened at this commit?" page. Key investigation page for developers. + +- **Heading** shows the raw commit string (not the display value), since + this page identifies a specific commit by its raw identity. + +- Commit field values displayed prominently +- **Tag display + editing**: Show the commit's tag (if set) prominently (e.g., "Tag: release-18.1"). An inline edit button allows setting or clearing the tag via ``PATCH /commits/{value}``. Editing requires an API token with `manage` scope (from Settings); show an auth error if the token is missing or insufficient. The tag also appears in the display value throughout the UI as `` (tag)``. +- **Navigation**: Prev/Next buttons (using the API's `previous_commit`/`next_commit` from the commit detail response) +- **Summary**: N runs across M machines +- **Machine filter**: Text input for substring matching on machine names, filters the runs table. The summary updates to reflect filtered counts (e.g., "5 of 12 runs across 2 of 8 machines"). +- **Runs table**: Columns: machine (link to Machine Detail), run UUID (link to Run Detail), start time +- API: `GET commits/{value}`, `PATCH commits/{value}` (tag editing), `GET runs?commit={value}` +- **Links out**: Run Detail, Machine Detail, Regression Detail + +**Regressions at this commit**: Below the runs table, a section listing +regressions where `commit` matches this commit's value. Each links to its +regression detail page. + + +## Regression Detail -- `/v5/{ts}/regressions/{uuid}` + +Investigation and management page for a single regression. + +**Page header**: Shows "Regression: {title}" when a title is set, or +"Regression: {uuid_short}" as fallback. Updates dynamically when the title is +edited. + +**Header section** (editable fields): +- Title: inline-editable text. Enter key saves. +- State: dropdown selector (detected, active, not_to_be_fixed, fixed, false_positive) +- Bug: URL input (opens in new tab when set). Enter key saves. +- Commit: display value shown (linked to commit detail page). Combobox with API search for editing (shows display values in dropdown). Nullable. +- Notes: text display with Edit button. Edit mode shows textarea + Save/Cancel. Ctrl/Cmd+Enter saves. Display preserves line breaks (pre-wrap). + +**Delete regression**: Button with type-to-confirm prompt. Requires `triage` +scope. On success, navigates to the regressions tab. + +**Add indicators panel**: +- Metric: dropdown selector +- Machines: checkbox list with filter input (multi-select, shift+click range) +- Tests: checkbox list with filter input (multi-select, shift+click range), filtered by selected machines and metric +- Preview: "This will add N indicators" (machines × tests cross-product) +- "Add" button creates all (machine × test × metric) indicator combinations +- Duplicates (same machine+test+metric already on this regression) are silently ignored + +**Indicators table**: +- Heading: "Indicators (X tests across Y machines across Z metrics)" — + unique counts computed from the indicators, excluding null machine/test + values (from deleted entities). Shows plain "Indicators" when empty. + When a filter is active: "Indicators (showing N of X tests across ...)". +- Filter: text input above the table for substring matching on machine + name, test name, or metric (OR logic, case-insensitive). Filters the + table rows client-side. Not shown when there are no indicators. +- Columns: select checkbox, Machine, Test, Metric, "View on graph" link, remove button (×) +- Select-all checkbox in header (with indeterminate state for partial selection) +- Shift+click range selection on checkboxes +- Batch "Remove selected" button +- "View on graph" link per indicator: opens Graph page pre-populated with the indicator's machine, test, metric, and the regression's commit as context + +Auth: requires `triage` scope for all modifications. diff --git a/docs/design/ui/compare.md b/docs/design/ui/compare.md new file mode 100644 index 000000000..204aa08ad --- /dev/null +++ b/docs/design/ui/compare.md @@ -0,0 +1,427 @@ +# v5 Web UI: Compare Page + +Page specification for the Compare page at `/v5/compare`. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). +Related pages: [Graph](graph.md), [Browsing Pages](browsing.md), +[Regressions](regressions.md). + + +## Compare -- `/v5/compare?suite_a={ts}&...` + +Side-by-side comparison of two commits (or runs). The existing code in +`comparison.ts`, `selection.ts`, `table.ts`, `chart.ts` becomes a page module. +The SPA router delegates to it. This page is suite-agnostic -- each side can +independently select its suite. + + +### Selection Panel + +Each side (A and B) has independent controls: +- **Suite**: dropdown selector populated from `data-testsuites`. Changing the suite clears the machine, commit, and runs for that side and re-populates the machine combobox from the new suite's machines endpoint. Clearing the suite also clears cached fields and commits for that side so stale metrics don't linger. +- **Commit**: combobox (searchable dropdown) over commit values. When the schema defines a commit_field with ``display: true``, the dropdown items show the display value (e.g. short SHA) while the internal selection uses the raw commit string; when no display field is defined or not populated, the raw commit string is shown. The text filter matches against both the raw commit string and the display value. Filters suggestions to only show commits where the selected machine has runs. When a machine is pre-selected from URL state, its commits are fetched on creation so the dropdown is correctly filtered from the start. **Disabled until a machine is selected** -- shows "Select a machine first" placeholder. Re-disabled if the machine is cleared. Clearing the commit also clears the runs for that side. +- **Machine**: combobox over machine names. The full machine list for the selected suite is fetched once and filtered locally by case-insensitive substring as the user types (instant, no per-keystroke API calls). **Disabled until a suite is selected** -- shows "Select a suite first" placeholder. Clearing the machine text and blurring resets downstream state (commit, runs) and disables the commit input. +- **Runs**: checkbox list of runs for the selected commit+machine, populated by `GET /api/v5/{ts}/runs?machine=M&commit=O`. Empty list shown when no runs exist. All runs are selected by default. The only exception is URL state restoration: if the shared URL specifies a subset of runs, that selection is restored. Each run shows its timestamp and a short UUID linking to the Run Detail page. Before a commit is selected, a hint message ("Select a commit first") is shown instead. +- **Run aggregation**: strategy for aggregating across selected runs (median/mean/min/max); grayed out when only one run selected + +A **Swap sides** button (circular, showing arrows) sits between the two sides. Clicking it exchanges all of side A's state (commit, machine, runs, run aggregation) with side B's, updates the URL, re-renders the selection panel, and triggers auto-compare. This is useful for quickly reversing the baseline/new direction. + +Global controls (shared across both sides): +- **Metric**: single-select dropdown; one metric at a time, applies to both table and chart. Shows the **union** of metrics from both sides' suites. Only metrics with `type === 'real'` are shown (filtered client-side). Before any suite is selected, the metric area shows a "Select a suite to load metrics..." hint instead of an empty dropdown. +- **Sample aggregation**: strategy for aggregating multiple samples within a single run (default: median). When a test appears multiple times in a run's samples, this strategy produces a single value per test per run. +- **Hide noise**: checkbox (always visible, outside the collapsible section) + that hides noise-classified rows from the table and chart entirely. +- **Noise filtering** (collapsible disclosure, collapsed by default): expands + downward as a floating overlay so the other controls (Metric, Sample + aggregation, etc.) remain vertically aligned with the summary label. Contains + three independent knobs. A test is classified as **noise** if it fails ANY + enabled knob (equivalently, a test is "signal" only if it passes ALL enabled + knobs). When all knobs are disabled, no test is classified as noise. Each knob + has an enable checkbox and a value input: + - **Delta % below** (disabled by default, value: 1%): tests where + |Delta %| < threshold are noise. Skipped when Delta % is unavailable (see + "Zero baseline" bullet in the table section). Input must be >= 0. Hovering + on the label shows a help tooltip: "Tests where the absolute percentage + change is below this threshold are considered noise." + - **P-value above** (disabled by default, value: 0.05): tests where the + Welch's t-test p-value exceeds alpha are noise (the difference is not + statistically significant). Uses all raw per-sample values from each side, + pooled across selected runs, before any aggregation. Skipped when either + side has fewer than 2 samples. Input must be in [0, 1]. Hovering on the + label shows a help tooltip: "Welch's t-test on raw samples from both + sides. Tests with p-value above the threshold are considered noise (the + difference is not statistically significant). Requires at least 2 samples + per side." + - **Absolute below** (disabled by default, value: 0): tests where + max(|Value A|, |Value B|) < floor are noise, where Value A and Value B are + the final aggregated values displayed in the table. The value is in the + metric's raw unit (the user sets it accordingly). Input must be >= 0. + Hovering on the label shows a help tooltip: "Tests where both sides' + aggregated values are below this floor are considered noise. Useful for + filtering out measurements too small to be meaningful." + + Edge-case behavior for noise classification: + - **Identical values**: when delta is exactly zero and a noise knob catches + it (e.g., the Delta % knob with any threshold > 0%), the test is classified + as noise with a noise reason. When no noise knob fires (all knobs disabled, + or all thresholds are 0), the test is classified as `unchanged`. + - **Zero variance (p-value knob)**: when both sides have zero variance and + equal means, the p-value cannot be computed and the knob is skipped. When + both sides have zero variance but different means, the change is + deterministic and the knob passes (effectively p-value = 0). When only one + side has zero variance, the test proceeds normally. + - **Raw sample pooling**: a single run with N samples contributes n=N to + the pooled sample set for the p-value calculation. Samples are pooled + across all selected runs per side, before any aggregation. + +- **Test filter**: text input for substring matching on test names, applied to both table and chart + +There is no Compare button. The comparison triggers automatically whenever the +state becomes valid (both sides have runs and a metric is selected), like the +Graph page's auto-plot. Changing the machine, commit, metric, or aggregation +settings re-triggers the comparison. Previous in-flight fetches are aborted. + + +### Comparison Table + +| Column | Description | +|----------|----------------------------------------------------------| +| Test | Test name | +| Value A | Aggregated metric value from side A | +| Value B | Aggregated metric value from side B | +| Delta | `vB - vA` | +| Delta % | `(vB - vA) / |vA| * 100`; see Computation Reference for sign convention and edge cases | +| Ratio | `vB / vA`; same quantity plotted on the chart as `log2(Ratio)` | +| Status | Improved / Regressed / Unchanged / Noise / N/A; see Computation Reference for classification rules | + +- **Geomean summary row**: see Computation Reference for precise formulas. The geomean summary row is never classified as noise. + + +#### Computation Reference + +**Aggregation pipeline.** The values `vA` and `vB` shown in the table are +produced by a two-stage aggregation pipeline: +1. **Sample aggregation** (within each run): when a test appears multiple + times in a run's samples, the sample aggregation function + (median/mean/min/max) reduces them to one value per test per run. +2. **Run aggregation** (across selected runs): per-run values are reduced + by the run aggregation function (median/mean/min/max, independently + selectable per side) to produce the final `vA` and `vB`. + +**Per-test derived columns** (given `vA`, `vB` as defined above): + +| Quantity | Formula | Domain | +|------------|--------------------------|----------------------------------| +| Delta | `vB - vA` | always defined | +| Delta % | `(vB - vA) / |vA| * 100` | undefined (N/A) when `vA = 0` | +| Ratio | `vB / vA` | undefined (N/A) when `vA = 0` | + +Notes: +- Delta % uses `|vA|` (not `vA`) in the denominator so its sign always + matches the sign of Delta, even when the baseline is negative. Without + the absolute value, a negative baseline would flip the percentage sign. +- For positive baselines, `Delta % = (Ratio - 1) * 100`, so Delta % and + Ratio carry the same information in different forms. + +**Zero baseline.** When `vA = 0`, Delta is still computed, but Delta %, +Ratio, and Status are all `N/A`. This classification happens before noise +classification -- noise knobs are never evaluated for zero-baseline tests. + +**Status classification** (checked in this order, after zero-baseline +tests have already been classified as `N/A`): +1. If any enabled noise knob triggers -> `noise` +2. If `Delta = 0` -> `unchanged` +3. If `bigger_is_better` and `Delta > 0` -> `improved` +4. If `bigger_is_better` and `Delta < 0` -> `regressed` +5. If not `bigger_is_better` and `Delta < 0` -> `improved` +6. If not `bigger_is_better` and `Delta > 0` -> `regressed` + +Status uses the sign of Delta (not Ratio) combined with `bigger_is_better`. + +**Geomean summary row.** Computed over N valid tests where both sides are +present, both values are non-zero, and ratio is defined: + +| Quantity | Formula | +|-----------------|---------------------------------------------| +| Geomean A | `exp(mean(ln(\|vA_i\|)))` for i = 1..N | +| Geomean B | `exp(mean(ln(\|vB_i\|)))` for i = 1..N | +| Ratio (geomean) | `exp(mean(ln(\|ratio_i\|)))` for i = 1..N | +| Delta | `Geomean B - Geomean A` | +| Delta % | `(Delta / \|Geomean A\|) * 100` | + +Absolute values are taken before computing the geometric mean so that +negative metric values do not produce undefined logarithms. The Ratio +column shows the geometric mean of per-test ratios (the multiplicative +average), which differs from `Geomean B / Geomean A`. The former weights +all tests equally regardless of absolute magnitude; the latter is +dominated by tests with large absolute values. For example, given two +tests with ratios 2.0 and 0.5, the geomean of ratios is +`sqrt(2.0 * 0.5) = 1.0` (no net change), while the ratio of geomeans +depends on the magnitude of the values. + +**Chart Y-axis.** The chart plots `log2(Ratio)` = `log2(vB / vA)`. The +log2 scale makes equal multiplicative changes symmetric: a 2x speedup +(ratio = 0.5) and a 2x slowdown (ratio = 2.0) appear at -1 and +1 +respectively, equidistant from zero. Tick labels show the equivalent +percentage change at "nice" values (+/-1%, +/-5%, +/-10%, etc.). + +A test is excluded from the chart when any of these conditions hold: +- Only one side has the test (not present on both sides) +- `vA = 0` (ratio undefined) +- Ratio <= 0 (log2 undefined -- occurs when `vA` and `vB` have opposite + signs, or when `vB = 0`) + +**Noise band on chart.** When the Delta % knob is enabled, horizontal +dashed lines are drawn at the log2-space equivalents of the threshold: +- Upper line: `log2(1 + threshold/100)` +- Lower line: `log2(1 - threshold/100)` when threshold < 100%; + otherwise `-log2(1 + threshold/100)` (forced symmetric, because + `log2(1 - t/100)` is undefined when `t >= 100%`) + +For small thresholds these lines are approximately symmetric (e.g. 5% +maps to +0.070 / -0.074). The asymmetry grows with larger thresholds. +A test whose bar falls inside the band has `|Delta %| < threshold`. +- Sortable by any column (click header) +- Color-coded status: green = improved, red = regressed (direction respects the metric's `bigger_is_better` flag) +- **Noise handling**: rows classified as noise by any enabled noise filtering knob are visually distinguished by the grey "noise" label in the Status column. The "Hide noise" checkbox removes them from the table and chart entirely (not rendered in the DOM). +- **Noise tooltip**: hovering over the Status cell of a noise-classified row shows a tooltip listing all knobs that triggered, e.g. "Delta 0.3% below 1% threshold", "p-value 0.12 above 0.05", "max(|A|, |B|) = 0.4 below floor of 1". All triggered knobs are shown, not just the first. +- **Sample count tooltips**: Value A and Value B cells show a native browser tooltip (via `title` attribute) indicating how many raw samples and contributing runs produced the aggregated value, e.g. "6 samples across 2 runs". Delta, Delta %, and Ratio cells show both sides: "A: 6 samples across 2 runs, B: 4 samples across 1 run". Sample count is the total number of raw sample values pooled across contributing runs (before any aggregation). Run count is the number of selected runs that have data for that specific test (not all selected runs). The geomean summary row and missing-test rows have no sample count tooltips. Singular/plural is applied ("1 sample across 1 run" vs "6 samples across 2 runs"). +- **Missing tests**: tests present in only one side show "\u2014" for the missing side's values. These are grayed out in a separate section at the bottom, excluded from the chart. This includes tests absent due to cross-suite comparison (different suites may have different test sets). The section header shows "Missing tests (N)" with the total count. When a text filter or chart zoom is active, the header updates to "Missing tests (M of N matching)" where M is the number of missing tests matching the filter and N is the total. Since missing tests are excluded from the chart, chart zoom always hides all missing rows (none can be in the zoomed range). +- **Null metrics**: when a test has a sample but no value for the selected metric, display "N/A" in the table and exclude from the chart +- **Zero baseline**: when Value A is 0, display "N/A" for Delta %, Ratio, and Status (raw values are still shown) +- **Interactive rows**: Clicking a row toggles its visibility on the chart. Double-clicking a row isolates it (hides all others), like the Graph page's legend table. Manually-hidden rows (toggled by clicking) are shown grayed out in the table (not removed from the DOM). The "Hide noise" checkbox is a separate filter that removes noise rows from the DOM entirely. The two filters are independent: manual toggles persist across hideNoise changes, and changing noise filtering knobs correctly hides/unhides tests as their status changes. +- **Summary message**: A message above the table rows shows a count, consistent with the Graph page's legend message: "150 tests" when all visible, "120 of 150 tests visible" when some are hidden, or "42 of 150 tests matching" when a text filter or chart zoom is active. Counts reflect only tests present in the table — noise-hidden tests (removed by "Hide noise") are excluded from both the numerator and denominator. +- **Copy as CSV**: A small clipboard icon button (right-justified on the summary message row) copies the visible comparison table as CSV to the clipboard. The exported CSV contains exactly the rows visible in the table (respecting noise hiding, manual click-hiding, text filter, and chart zoom), in the current sort order, with the geomean summary as the first data row. Columns match the table: Test, Value A, Value B, Delta, Delta %, Ratio, Status. The button provides brief visual feedback indicating success or failure. Hidden when no rows are visible. +- **Profile column**: When both sides have profile data for a test, a + "Profile" link appears. Clicking it navigates to the Profiles page + pre-populated with both sides' run and test: + `/v5/profiles?suite_a={ts_a}&run_a={uuid_a}&test_a={test}&suite_b={ts_b}&run_b={uuid_b}&test_b={test}` + When only one side has a profile, the link pre-populates just that side. + The link is omitted when neither side has a profile for that test. +- **Filter performance**: Typing in the test filter must feel instant even with + thousands of tests. The table updates immediately on each keystroke; the chart + may update asynchronously (within one animation frame) to avoid blocking input. + + +### Chart + +Sorted ratio chart (relative performance chart): +- **X-axis**: tests, sorted by B/A ratio +- **Y-axis**: `log2(Ratio)` -- see Computation Reference for definition, symmetry rationale, and chart exclusion criteria. Tick labels show percentage change at "nice" values (+/-1%, +/-5%, +/-10%, +/-50%, +/-100%, etc.), auto-adapting to the data range +- Rendered as a connected line (not discrete bars) for readability at scale + +Interactivity: +- **Hover**: tooltip showing test name, exact ratio, and absolute values for both sides +- **Zoom / drag-select**: filters the comparison table to show only the tests in the visible range +- **Noise band**: see Computation Reference for how the Delta % threshold is converted to log2 space. The p-value and absolute floor knobs do not have chart-level visualization. +- **Text filter**: the chart applies the text filter from the selection panel; the text filter stacks with the chart zoom filter (intersection) +- **Zoom preservation**: changing noise filtering knobs, aggregation functions, text filter, or toggling row visibility preserves the current chart zoom. The user can double-click the chart to reset zoom. +- **Adaptive tick labels on zoom**: tick labels recompute dynamically when the user zooms -- zooming into a narrow range shows fine-grained percentage ticks (+/-1%, +/-2%), while the full view shows coarser ticks (+/-50%, +/-100%). Double-click reset restores ticks for the full data range. +- **Empty state**: when there is no data to chart (no comparison triggered yet, or no tests match), the chart area displays "No data to chart." -- consistent with the Graph page's empty-state pattern. + + +### Comparison Summary Bar + +A horizontal summary bar between the chart and the comparison table shows the +count of tests in each status category, with percentages for comparable +categories: + +| Category | Counts rows where | Dot color | +|------------|--------------------------------------------|-----------| +| Improved | `status === 'improved'` | `#2ca02c` | +| Regressed | `status === 'regressed'` | `#d62728` | +| Noise | `status === 'noise'` | `#999999` | +| Unchanged | `status === 'unchanged'` | `#999999` | +| Only in A | `sidePresent === 'a_only'` | `#888888` | +| Only in B | `sidePresent === 'b_only'` | `#888888` | +| N/A | `status === 'na'` | `#888888` | + +**Comparable categories** (Improved, Regressed, Noise, Unchanged) show a +colored dot, label, and "count (pct%)" where the denominator is the sum of +comparable categories only (within the filtered set). Percentages use one +decimal place, except whole numbers drop the trailing `.0` (e.g. `25%` not +`25.0%`). Percentages among comparable categories sum to ~100% (one-decimal +rounding may cause minor drift). When there are no comparable tests +(`comparableTotal = 0`), comparable categories show just the count with no +percentage. A tooltip on the `.summary-count` span explains the denominator. + +**Non-comparable categories** (Only in A, Only in B, N/A) show a colored +dot, label, and count only — no percentage. + +**Filtering behavior**: the summary bar respects the text filter and chart zoom +(counts reflect only tests visible in those filters). The comparable-category +denominator is the comparable count within the filtered set. The bar does NOT +respect the "Hide noise" toggle -- noise and unchanged tests are always +counted. This ensures the user can see the full status breakdown even when +noise rows are hidden from the table and chart. The source of truth is the +full comparison result (`lastRows`), filtered by text filter and chart zoom. + +**Zero-count categories**: shown with reduced opacity (0.5) for visual muting +rather than hidden, providing layout stability. + +**Empty state**: when no comparison data exists (total = 0), the summary bar +renders nothing (empty container). When `total > 0` but all tests are +non-comparable (`comparableTotal = 0`), all categories render with bare +counts and no percentages. + + +### Bidirectional Chart-Table Sync + +The chart and table always represent the same dataset: +- **Chart -> Table**: zooming or drag-selecting on the chart filters the table to the matching tests +- **Table -> Chart**: the text filter and row toggles update the chart to show only visible, matching tests +- **Hover sync**: hovering on a chart point highlights the table row (scrolls into view); hovering on a table row highlights the chart point + + +### Data Flow + +1. Page loads: fetch metric metadata via `GET test-suites/{ts}` (fields from `schema.metrics`). Commits are fetched per-machine via `GET commits?machine={name}` (cursor-paginated) when a machine is selected, to populate the commit combobox with only the commits relevant to that machine. +2. User selects commit and machine on each side. On each change, fetch `GET runs?machine=M&commit=O` to populate the runs checkbox list. If no runs exist, show an empty list. +3. Once both sides have runs and a metric is selected, comparison triggers automatically. Fetch sample data for each selected run via `GET runs/{uuid}/samples` (cursor-paginated with `limit=10000`). Show a progress indicator during fetch. +4. Client-side: aggregate samples (within-run via sample aggregation), aggregate across runs (via run aggregation), join on test name, compute derived columns (delta, ratio, status, p-value when the knob is enabled). +5. Render table and chart. +6. Subsequent filter/sort/zoom operations are client-side (data already loaded). +7. If the user changes selections while data is loading, abort the in-flight requests before starting new ones. +8. For the Profile column, call `GET /runs/{uuid}/profiles` for each side's + runs (fired in parallel via `Promise.all`). Cache per run UUID alongside + the sample cache. Match profiles to test names to determine which rows + get a Profile link. + +**Per-run sample caching**: Fetched samples are cached per run UUID. Changing +the metric, aggregation function, noise filtering settings, or run selection +re-aggregates and re-compares from cache without any API calls. Only selecting +a new commit or machine (which produces different run UUIDs) triggers new +fetches, and only for runs not already in the cache. + + +### URL State + +All selection state is encoded as query parameters for shareability: +- `suite_a`, `commit_a`, `machine_a`, `runs_a` (comma-separated UUIDs), `run_agg_a` +- `suite_b`, `commit_b`, `machine_b`, `runs_b`, `run_agg_b` +- `metric`, `sample_agg` +- `noise_pct`, `noise_pval`, `noise_floor` (knob values; omitted when at defaults: 1, 0.05, 0 respectively), `noise_pct_on`, `noise_pval_on`, `noise_floor_on` (knob enabled state; all default to disabled, so `_on` params only appear as `1` when enabled), `hide_noise` +- Filter/sort state as applicable + +Auth token is stored in `localStorage`, not in URL state (to avoid leaking +credentials when sharing URLs). All URL updates use `replaceState` (not +`pushState`) so the browser Back button navigates between pages, not between +individual setting changes. + + +**Links out**: Machine Detail, Run Detail, Graph (with machine pre-filled), +Regression Detail, Profiles (pre-populated A/B from comparison row). + + +### Shadow Trace (Comparison Overlay) + +A shadow trace overlays a pinned comparison on the chart, allowing the user to +visually compare how a ratio profile changed between two versions of side B +against a shared baseline (side A). For example: pin GCC vs LLVM20, then change +side B to LLVM21 to see both comparisons overlaid. + +**Workflow:** +1. User sets up a comparison and sees the chart. +2. Clicks "Pin as Shadow" (small button in the top-right of the Side B panel + header). Current side B selection is captured as the shadow. +3. The pin button hides. A chip badge appears above the chart (outside the + settings area): "Shadow: {commit} on {machine}" with a dismiss (×) button. +4. User changes side B. The chart now shows main comparison as bars and the + shadow comparison as a thin line trace. +5. To remove the shadow, click × on the chip. To change it, dismiss then re-pin. + +**Pin button placement:** +- Inside the Side B selection panel, top-right of the "Side B (New)" heading. +- Small button, visible only when a comparison is active and no shadow is + currently pinned. + +**Shadow chip placement:** +- In a toolbar row between the progress/error area and the chart, outside any + settings panel. Chip with a dismiss (×) button. + +**Shadow trace rendering:** +- Thin line trace, independently sorted by its own ratio ascending, producing + a smooth curve. It does NOT share X positions with the main bars — each + trace uses its own sequential X positions (0..N). The X-axis range + accommodates whichever trace has more points. +- Muted blue color, distinct from the green/red/grey status-coded bars. +- No legend displayed. The shadow chip above the chart identifies the trace. +- Shadow Y values are included in the Y-axis range calculation for tick + generation. +- Text filter, hide-noise, and manual row toggles apply to the shadow trace + the same as the main. + +**Hover:** +- Hovering a shadow point shows the same info as the main bars: test name, + ratio, value A, value B, delta %, with a "(shadow)" label to distinguish. +- Table-to-chart hover sync targets the main trace only. + +**Scope:** +- Chart-only: no table columns, no summary bar changes. +- "Add to Regression" panel operates on the main comparison only. + +**Same side A enforced:** +- Any change to side A (including swapping sides) auto-unpins the shadow. + +**Settings interactions:** + +| Setting changed | Shadow behavior | +|-----------------------|--------------------------------------------------| +| Metric | Full recompute (both main and shadow) | +| Sample aggregation | Full recompute (both main and shadow) | +| Run aggregation (A) | Full recompute (both main and shadow) | +| Run aggregation (B) | Recompute main only; shadow uses its pinned value| +| Noise config | Reclassify both main and shadow | +| Hide noise | Shadow visibility follows main (chart filter) | +| Test filter | Shadow visibility follows main (chart filter) | +| Sort | No effect (chart always sorts by ratio) | +| Side A change | Auto-unpin shadow | +| Side B change | Recompute main; shadow unchanged | +| Swap sides | Auto-unpin shadow | + +`sampleAgg` is a global visualization preference — both main and shadow respond +to it equally. `runAgg` is per-side: the shadow's `runAgg` is frozen at pin +time. + +**Data flow:** +- On pin: a deep copy of the current side B selection is stored as the shadow. + The shadow's side B samples are already in the sample cache. +- On recompute: the shadow reuses the main comparison's cached side A + aggregation, then aggregates only the shadow's side B samples independently. +- Cache eviction preserves shadow-referenced run UUIDs alongside the main + selection's UUIDs. +- On page load from a URL with shadow parameters, shadow samples are fetched + in parallel with the main samples. Shadow fetch failures are tolerated — + the main comparison still renders. + +**URL encoding:** +- Shadow side B is encoded with the same scheme as side A and side B, using + the suffix `shadow_b` (e.g., `suite_shadow_b`, `commit_shadow_b`, + `runs_shadow_b`, `run_agg_shadow_b`). +- The shadow display label is not stored in the URL — it is derived from the + shadow's commit and machine at render time. + + +### Add to Regression + +A collapsible panel (button: "Add to regression" in the controls area). When +expanded, offers: +- "Create new regression" -- pre-fills commit, machines, tests, and metrics from the current comparison into a new regression +- "Add to existing" -- a regression search combobox; adds the comparison's indicators to the selected regression. The regression search combobox follows the same pattern as the machine combobox: it fetches the regression list once on creation (limit 500), filters locally on each keystroke, and uses the standard combobox ARIA and keyboard behavior (collapse on select, ArrowDown/ArrowUp/Enter/Escape, close on blur and outside click). On selection, the input shows the selected regression's title. Enter on the input is a no-op (the user must select from the dropdown list, since regressions are identified by UUID). + +Only tests currently visible in the comparison table are included as +indicators (tests that are noise-hidden, manually-hidden, or excluded by the +text filter are not included). + +On successful creation, the feedback shows "Regression created: " followed by a +clickable link to the new regression's detail page. The link text is the +regression title if one was provided, otherwise the first 8 characters of the +UUID. Clicking the link performs a full page load (crossing from suite-agnostic +to suite-scoped context). The title input is cleared after successful creation. + +On successful addition of indicators to an existing regression, the feedback +shows "Added N indicator(s) to " followed by a clickable link to the +regression detail page. The link text is the regression title if available, +otherwise the first 8 characters of the UUID. + +The panel collapses back to the button when done. diff --git a/docs/design/ui/dashboard.md b/docs/design/ui/dashboard.md new file mode 100644 index 000000000..8ec4ab858 --- /dev/null +++ b/docs/design/ui/dashboard.md @@ -0,0 +1,36 @@ +# v5 Web UI: Dashboard + +Page specification for the Dashboard at `/v5/`. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). +Related pages: [Graph](graph.md), [Compare](compare.md). + + +## Dashboard -- `/v5/` + +Suite-agnostic landing page providing an at-a-glance visual overview of +performance trends across all test suites. + +**Layout**: +- Page header "Dashboard" with a commit range preset selector (Last 100 / Last 500 / Last 1000 buttons, default Last 500) at top-right, persisted in URL as `?range=500`. +- One section per test suite (ordered alphabetically, matching `getTestsuites()`). +- Each suite section contains a responsive grid of sparkline cards -- one card per metric defined in the suite schema. + +**Sparkline cards**: +- Each card shows a small time-series chart (~300x160px) with the metric name (and unit, if available) as the card title. +- Up to 5 traces per chart, one per most-recently-active machine (determined from recent runs sorted by start_time). Each trace is a colored line. +- X-axis: sequential position (evenly spaced, no axis labels); commit string shown on hover. Y-axis: geometric mean of all test values at each commit for that machine+metric combination. +- Hover tooltip shows the machine name, commit string, and value. +- Clicking a sparkline navigates to the Graph page pre-populated with that suite, metric, and the displayed machines. Clicking directly on a specific trace navigates with just that machine. +- Loading state: placeholder skeleton while data is being fetched. +- Error state: "Failed to load" message if fetching fails. + +**Why per-machine traces (not a single aggregate)**: Per-machine traces surface machine-specific regressions that a single aggregate line would hide. With only 5 traces, readability is fine. The dashboard's purpose is anomaly detection. + +**Data flow**: +1. Suite names from `getTestsuites()` (embedded in HTML shell, no API call). +2. Per suite: `getTestSuiteInfo()` for the metrics schema, `getRunsPage(sort=-start_time, limit=50)` to find the 5 most recently active machines. +3. Per suite x metric: `fetchTrends()` calls `POST /api/v5/{ts}/trends` with the metric, machine list, and `last_n` filter. The server groups all samples by (machine, commit) and returns the geomean per group for the most recent N commits by ordinal. The frontend groups the response by machine, sorts by ordinal, and assigns sequential x-positions (0, 1, 2, ...) for even spacing into `SparklineTrace[]`. +4. Sparklines render progressively as each metric's data arrives. + +**Geomean**: `exp(mean(ln(values)))`, skipping zero/negative values. Computed server-side in the trends endpoint. Shared utility in `utils.ts` also used by the Compare page. diff --git a/docs/design/ui/graph.md b/docs/design/ui/graph.md new file mode 100644 index 000000000..a5f94e910 --- /dev/null +++ b/docs/design/ui/graph.md @@ -0,0 +1,215 @@ +# v5 Web UI: Graph Page + +Page specification for the Graph (time series) page at `/v5/graph`. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). +Related pages: [Compare](compare.md), [Regressions](regressions.md), +[Browsing Pages](browsing.md). + + +## Graph (Time Series) -- `/v5/graph?suite={ts}&machine={m}&metric={f}` + +The primary performance-over-time visualization. Replaces v4's graph page. This +page is suite-agnostic -- the suite is a query parameter, not a path segment. + +- **Suite selector**: A required dropdown at the top of the page, populated from the `data-testsuites` HTML attribute. All other controls (machine, metric, test filter, aggregation, baselines) are disabled until a suite is selected. Changing the suite clears the machine list, all caches, and the chart. When the page is loaded with `suite=` in the URL, the dropdown is pre-selected. + +- **Machine chip input**: The machine selector is a chip-based multi-select input. The user types a machine name (with typeahead suggestions) and presses Enter to add it. Each added machine appears as a chip with an x button to remove it. Multiple machines can be added to overlay their data on the same chart. Removing the last machine clears the chart. The metric selector is shared across all machines -- the same metric is plotted for every machine. The full machine list is fetched once when the combobox is created and filtered locally by case-insensitive substring as the user types (instant, no per-keystroke API calls). A "Loading machines..." hint is shown until the initial fetch completes. + +- **Input validation**: Machine and commit comboboxes show a red halo (`.combobox-invalid` -- red border + box-shadow) whenever the suggestion dropdown is empty, meaning no machine or commit matches the typed text. Acceptance (Enter key, blur/change) is blocked while the halo is showing. The halo updates in real-time on every keystroke. Clicking a dropdown suggestion always clears the halo and accepts the value. For commit comboboxes, acceptance via Enter or blur additionally requires an exact match against available commit values -- a partial substring match (e.g. typing "789" when the commit is "566789") is rejected with the red halo even though suggestions are visible. All comboboxes support ArrowDown/ArrowUp keyboard navigation through suggestions, with Enter to select the focused item. + +- **Explicit test selection**: There is no "Plot" button or auto-plot. When at least one machine and a metric are selected, the test table is populated with ALL matching tests (no cap). **Nothing is plotted by default** -- the chart starts empty with the x-axis scaffold. The user explicitly selects which tests to plot by clicking rows in the test table. Data is fetched on-demand when tests are selected. The metric selector initially shows a "-- Select metric --" placeholder (no metric pre-selected), consistent with the Compare page. + +- **Multi-machine trace naming and symbols**: Each trace is named `{test name} - {machine name}` (test name first for natural sorting). Machines are visually distinguished by marker symbols: the first machine uses circles (default), the second triangles, then squares, diamonds, etc. Colors represent test identity, assigned by the test's position in the alphabetically sorted full test list (not just the selected subset). This ensures stable colors -- adding or removing a selection does not shuffle existing colors. The same test on different machines shares the same color but has a different marker shape. + +- **Test filter**: A text filter (like the Compare page) that controls which tests appear in the test table. The filter matches on **test name only** (not machine name) via case-insensitive substring. Changing the filter prunes selected tests that no longer match -- their traces are removed from the chart. Clearing the filter restores the full test list (previously selected tests remain selected if they match). + +- **X-axis is always commit** (not date -- commits are not necessarily correlated to dates). + When the schema defines a commit_field with ``display: true``, the X-axis + labels, hover tooltips, and baseline chip labels show the display value + (e.g. short SHA) instead of the raw commit string. When no display field + is defined or a commit's display field is not populated, the raw commit + string is shown. + +- Plotly line chart: metric value vs commit, one trace per matching test + +- **Aggregation controls** (consistent with Compare page): + - Run aggregation: how to combine multiple runs at the same commit (median/mean/min/max) + - Sample aggregation: how to combine multiple samples within a run (median/mean/min/max) + + +### Lazy Loading with Progressive Rendering + +Data is fetched on-demand when tests are selected (not eagerly on discovery). +For each selected test, data is fetched via `POST /query` with OR'd test names +and rendered incrementally. When shift-clicking to select a range, the batch +of tests is fetched in a single query. The chart progressively fills in data as +pages arrive via cursor-based pagination. This avoids blocking the UI on large +datasets. + + +### X-axis Scaffolding + +To prevent the x-axis from resizing/shifting as lazy-loaded pages arrive, the +graph page pre-fetches the complete list of commit values for each selected +machine via paginated calls to `GET commits?machine={name}&sort=ordinal`. +This returns commits in ordinal order, excluding commits without ordinals +(which have no meaningful position in a time series). When multiple machines +are selected, the scaffold is the **union** of all machines' commit values, +sorted by ordinal, so the x-axis spans the full range across all machines. +Traces naturally have gaps where their machine has no data at a given commit. +Each machine's scaffold is fetched and cached independently; the union is +recomputed when machines are added or removed. If a scaffold fetch fails for +one machine, that machine's commits are simply not included in the union -- +the chart still works. + + +### Incremental Chart Updates + +The chart component exposes a `ChartHandle` API (via `createTimeSeriesChart`) +that supports incremental updates through `Plotly.react()` -- the chart is +updated in-place as new pages of data arrive, rather than being destroyed and +re-created. + + +### Zoom Preservation During Progressive Loading + +If the user zooms into the chart while data is still loading, the zoom is +preserved across incremental updates. The x-axis range is always preserved (it +was established by the scaffold or by user zoom). The y-axis range is preserved +only when the user has explicitly zoomed; otherwise, it auto-ranges to +accommodate new data as it arrives. Double-clicking the chart resets the zoom to +the full range as usual. + + +### Test Selection Table + +Below the chart, a table lists ALL tests matching the current filter, sorted +alphabetically by test name. One row per test name (not per test x machine +combination -- selecting a test plots it on all active machines). The table is +part of the normal page flow (no scrollable container). A message line above +the rows shows counts (e.g., "3 of 1200 tests selected" or "3 of 1200 tests +selected, loading..."). Each row has: a checkbox cell (checked = +selected/plotted), a symbol cell (colored marker character (circle/triangle/square) only when +selected, empty otherwise), and the test name. The test filter narrows the +table; tests that no longer match are pruned from the selection. + +**Filter performance**: Typing in the test filter must feel instant even with +thousands of tests. Non-matching rows are hidden immediately; the chart updates +asynchronously. + + +### Selection Interactions + +A header "check all" checkbox in the table header selects or deselects all +visible tests (tri-state: unchecked, indeterminate when some selected, checked +when all selected). Clicking a row toggles its selection (and triggers data +fetch if selecting). Shift-clicking selects a contiguous range from the +last-clicked row (additive -- adds to existing selection). Double-clicking +isolates that test (deselects all others); double-clicking the sole selected +test restores all (selects every visible test). Selected tests with data still +loading show a loading indicator. Plotly's built-in legend is disabled; the +table replaces it. Bidirectional hover highlighting: hovering a table row +highlights the corresponding chart trace(s); hovering a chart trace highlights +the table row. Selected tests are NOT persisted in the URL (test names can be +very long); the filter, suite, machine, metric, aggregation, and baselines +remain in the URL. + + +### Client-Side Caching and State Persistence + +Test names, data points, scaffolds, and baseline data are cached locally. Test +names are fetched once per machine/metric combination (all names, no +server-side filter) and filtered client-side. Changing the test filter or +aggregation mode re-renders instantly from cache without any additional API +calls. Adding a second machine starts its own fetch pipeline while the first +machine's data is already displayed. The cache, the selected test set, and the +matching test list are all preserved across page unmount/remount, so navigating +away and pressing browser back renders the previous selection and chart +instantly from cache. All caches and selections are cleared on suite change. + + +### Baselines + +Users can overlay one or more baselines as horizontal dashed lines on the +chart. Each baseline is a (suite, machine, commit) tuple, allowing cross-suite +comparisons. The selector is an expandable panel with cascading dropdowns: +Suite (populated from `data-testsuites`) -> Machine (populated from the +selected suite's machines endpoint) -> Commit (populated from the selected +machine's commits). Added baselines appear as removable chips labeled +`{suite}/{machine}/{display_value}`, where `display_value` is the commit's +display value (e.g. short SHA with tag) when a `commit_field` with +`display: true` is defined, otherwise the raw commit string. Display values +for baseline commits are resolved via `POST /commits/resolve` so they display +correctly when baselines are loaded from the URL. The "+" button uses `align-self: flex-start` so it does +not stretch to the width of the chips. Baseline data is fetched from the +baseline's suite via `POST /api/v5/{suite}/query` with `{machine, metric, +commit, test}` in the JSON body. Each baseline renders as a horizontal dashed +line per test trace, spanning the full chart width, colored to match the +corresponding test's main trace. The baseline's Y value for each test is +computed using the same run aggregation function as the main trace (e.g., +median of all runs at that commit), so the dashed line aligns exactly with the +trace point at that commit. Hovering a dashed line shows a tooltip with: the +baseline suite, machine, commit value, tag (if set), test name, and metric +value. Baselines are encoded in the URL query string for shareability (e.g., +`&baseline=nts::machine1::abc123&baseline=other_suite::machine2::def456`). +Baseline data is fetched asynchronously after the first render, so it does not +block initial chart display. + + +### Concurrent Background Fetches + +Each machine x metric fetch uses its own AbortController, so navigating away or +removing a machine cancels its in-flight requests cleanly without affecting +other machines' fetches. + + +### Hover Behavior + +Hover a data point: tooltip showing test name, machine name, commit value, +aggregated metric value, run count. Hover distance is reduced +(`hoverdistance: 5`, less sticky tooltips) so the tooltip only appears when the +cursor is close to a data point. When hovering over an aggregated point that +represents multiple runs, the individual pre-aggregation values are shown as a +scatter of markers at the same x-position, in the same trace color but faded +(opacity 0.3). This scatter is computed lazily via a callback and displayed as +a temporary Plotly trace that is added on hover and removed on unhover. + + +### Empty State + +When no traces match the current filter/settings, the chart displays a Plotly +annotation overlay ("No data to plot") centered on the chart area, preserving +the x-axis scaffold so the user can see the commit range. + + +### API Calls + +- `POST query` with JSON body `{machine, metric, test, sort, limit, cursor}` (one fetch pipeline per machine, targeted to discovered tests via multi-value `test`) +- `GET tests?machine=...&metric=...&search=...` (test name discovery) +- `GET commits?machine={name}&sort=ordinal` (x-axis scaffold, per machine) +- `GET commits` (tags for baseline suggestions) +- `GET machines` (machine combobox) +- `GET test-suites/{ts}` (fields/metrics) + + +### URL State + +`?suite={ts}&machine={name}&machine={name2}&metric={name}&test_filter={text}&run_agg={fn}&sample_agg={fn}&baseline={suite}::{machine}::{commit}&baseline={suite2}::{machine2}::{commit2}` + +The `machine` parameter is repeated for each selected machine; the `baseline` +parameter is repeated for each baseline. Selected tests are NOT included in the +URL (names can be very long); they are ephemeral page state preserved across +SPA navigation but lost on page reload. + +**Links out**: Compare, Regression Detail + + +### Regression Annotations + +A dropdown toggle "Regressions: Off | Active | All" (default Off) in the +controls panel. When enabled, vertical dashed lines are drawn at the +regression's commit position for regressions with indicators matching the +current graph's test/machine/metric. Lines are color-coded by state +(red=active, yellow=detected, gray=resolved). Hover shows the regression title +and affected tests; click navigates to the regression detail page. diff --git a/docs/design/ui/profiles.md b/docs/design/ui/profiles.md new file mode 100644 index 000000000..f7dcc6379 --- /dev/null +++ b/docs/design/ui/profiles.md @@ -0,0 +1,163 @@ +# v5 Web UI: Profiles Page + +Page specification for the Profiles page at `/v5/profiles`. + +For the SPA architecture and routing, see [`architecture.md`](architecture.md). +Related pages: [Compare](compare.md), [Browsing Pages](browsing.md). + + +## Profiles -- `/v5/profiles?suite_a={ts}&run_a={uuid}&test_a={name}&suite_b={ts}&run_b={uuid}&test_b={name}` + +A/B profile viewer for hardware performance counter data at the instruction +level. Suite-agnostic page. Each side independently selects its own test +suite, enabling cross-suite profile comparison. Side B is optional -- if +only side A is filled, a single profile is displayed. + +URL uses suffix convention (`suite_a`, `run_a`, `test_a`) consistent with the +Compare page's `suite_a`, `commit_a`, etc. All parameters optional. The page +resolves profile UUIDs from the run+test coordinates by calling the listing +endpoint (`GET /runs/{uuid}/profiles`). + + +### Entry Points + +1. **Nav bar**: `[Profiles]` link navigates to `/v5/profiles` with no params. +2. **Compare page**: "Profile" link in the comparison table for tests that + have profiles on both sides. Pre-populates suite_a, run_a, test_a, suite_b, + run_b, test_b. Uses the latest run when multiple runs are selected on a side. +3. **Run Detail page**: Tests with profiles show a "Profile" link in the + samples table, navigating to `/v5/profiles?suite_a={ts}&run_a={uuid}&test_a={test}`. + + +### A/B Picker + +Each side (A and B) has its own cascading selectors. The two sides may +select different test suites. Changing an upstream selector clears +downstream selections: + +1. **Suite**: dropdown from `data-testsuites`. Disabled: never. +2. **Machine**: combobox over machine names for the selected suite. Disabled + until suite is selected. +3. **Commit**: combobox over commits filtered to those with profile-bearing + runs on the selected machine. Populated by + `GET /commits?machine={name}&has_profiles=true` when a machine is + selected. Disabled until machine is selected. +4. **Run**: dropdown of runs for the selected machine+commit that contain + profile data (populated by + `GET /runs?machine=M&commit=C&has_profiles=true`; shows timestamp + + short UUID). Disabled until commit is selected. +5. **Test**: dropdown over tests that have profiles for the selected run + (populated from `GET /runs/{uuid}/profiles`). Disabled until run is + selected. + + +### Top-Level Counter Comparison (Stats Bar) + +When both sides are selected: +- Table showing counter names, value A, value B, and % difference +- Color-coded: green (improvement), red (regression) +- Horizontal bar chart showing % differences per counter + +When only side A is selected: +- Simple table of counter names and values (no comparison) + + +### Function Selector + +A combobox for each side, populated from the profile's function list. +- Sorted by hottest-first (highest counter value for the selected counter) +- Each suggestion shows a colored badge with the counter percentage +- A counter dropdown controls which counter is used for sorting and display + + +### Disassembly View + +Two display modes, selectable via dropdown: + +**Straight-line view**: HTML table with columns: +- Counter value (heat-map colored: white -> yellow -> red) +- Address (hex) +- Instruction text + +**Control-flow graph (CFG) view**: D3-based visualization with: +- Instruction set selector: AArch64, AArch32-T32, RISC-V, X86-64 +- Basic blocks as rectangles with left-side weight sidebar +- Instructions listed vertically within blocks +- Edges between blocks (arrows; backward edges in orange) +- Per-block aggregate counter display + +Display mode selector options: +- Straight-line +- CFG (AArch64) +- CFG (AArch32-T32) +- CFG (RISC-V) +- CFG (X86-64) + +The CFG view requires ISA-specific basic block boundary detection (parsing +instruction semantics to identify branches, jumps, and fall-throughs). The +v4 implementation in `lnt_profile.js` (lines 73-139) provides the reference +regex patterns per ISA. + +**Note**: The CFG view is deferred to a future phase. Only the straight-line +display mode is currently implemented. + + +### Counter Display Modes + +A dropdown to control how counter values are displayed: +- **Relative %**: percentage of function total (default) +- **Absolute**: raw counter values +- **Cumulative**: running sum through instructions + + +### Side-by-Side Layout + +When both sides are filled: +- Stats bar across the top (full width) +- Two columns below: left = side A disassembly, right = side B disassembly +- Each column has its own function selector +- Counter dropdown and display mode are shared (global) + +When only one side: +- Stats table (single column) across the top +- Single disassembly column (full width) + + +### Data Flow + +**Normal cascade (user interaction):** +1. On page load, read URL params. +2. When user selects a machine, call + `GET /commits?machine={name}&has_profiles=true` to populate the commit + picker with only commits that have profiles on that machine. +3. When user selects a commit, call + `GET /runs?machine={name}&commit={value}&has_profiles=true` to populate + the run dropdown with only profile-bearing runs. +4. When user selects a run, call `GET /runs/{uuid}/profiles` to get the + test list. +5. When user selects a test, resolve the profile UUID from the list and + call `GET /profiles/{uuid}` for metadata + counters, and + `GET /profiles/{uuid}/functions` for the function list. +6. When user selects a function, call `GET /profiles/{uuid}/functions/{fn}` + for disassembly data. + +**URL restoration** (page load with `run_a`/`test_a` params): +1. Call `GET /runs/{uuid}` to recover machine + commit. +2. Call `GET /commits?machine={name}&has_profiles=true` to populate + the commit picker. +3. Call `GET /runs?machine={name}&commit={value}&has_profiles=true` to + populate the run dropdown. +4. Call `GET /runs/{uuid}/profiles` to get the test list for the known + run, match the test name, and load the profile. + + +### URL State + +All selection state is encoded as query parameters for shareability: +- `suite_a`, `suite_b`, `run_a`, `test_a`, `run_b`, `test_b` + +Auth token is stored in `localStorage`, not in URL state. All URL updates +use `replaceState` (not `pushState`). + + +**Links out**: Run Detail, Compare. diff --git a/docs/design/v5-discussion-about-orders.md b/docs/design/v5-discussion-about-orders.md new file mode 100644 index 000000000..bc1a05893 --- /dev/null +++ b/docs/design/v5-discussion-about-orders.md @@ -0,0 +1,260 @@ +# Design Discussion: Rethinking Orders and Identifiers + +This document captures the design discussion about decoupling run identity from +ordering in LNT, and introducing richer commit metadata on orders. + +## Problem Statement + +Most LNT test suites define orders as a git distance (an integer). While +functionally correct for sorting, this is not very helpful when trying to make +sense of what an order actually represents. We want to: + +1. Store meaningful commit information (Git SHA, author, message) alongside + orders. +2. Support one-off A/B comparisons without cluttering the order space. +3. Cleanly separate the concepts of "identity" (what groups runs together) + from "ordering" (sequential position for time-series analysis). + +## Current Architecture + +### Order Table + +Each test suite has a per-suite Order table with: +- `ID` (Integer PK) +- `NextOrder` / `PreviousOrder` (Integer FKs forming a linked list) -- in + production +- `ordinal` (Integer) -- introduced on the v5 branch but not yet in production +- Dynamic `String(256)` columns for each order field (e.g., + `llvm_project_revision`) + +### Schema Definition + +Orders are defined by marking a run field with `order: true` in the YAML +schema: + +```yaml +run_fields: +- name: llvm_project_revision + order: true +``` + +There is no type control (always `String(256)`), no metadata concept, and no +way to separate the sort key from the display label. + +### Ordering Mechanism + +`_getOrCreateOrder()` determines an order's position at insertion time by +comparing field values via `convert_revision()`, which parses strings into +numeric tuples. Non-numeric strings (like Git SHAs) get hashed, producing +essentially random ordering. The order field value serves a dual purpose: it is +both the identity (used for lookups and display) and the sort key. + +### Dual-Purpose Problem + +This conflation means: +- You can't store a monotonic sort key (git distance) while displaying a + human-meaningful identifier (SHA). +- Every run must have an order, even throwaway A/B comparisons. +- The system must understand the format of the order value to sort it + (`convert_revision()`). + +## Exploration of Approaches + +### Approach A: Add a `parameters_data` Blob to Order + +Like Machine and Run already have. An external process or PATCH call populates +it with arbitrary JSON (SHA, author, message). Submitters stay unchanged. + +**Verdict**: Simple but doesn't address the display concern or the A/B +comparison problem. + +### Approach B: New `order_fields` Section in the Schema + +A dedicated `order_fields` section defines additional typed columns on the Order +table: + +```yaml +run_fields: +- name: llvm_project_revision + order: true # sort key (unchanged) + +order_fields: # NEW +- name: git_sha + display: true # UI shows this instead of sort key +- name: commit_message + type: text # Text column for large strings +``` + +Order metadata fields are populated via PATCH only, not by run submissions. One +field can be marked `display: true` for the UI. + +**Verdict**: Addresses display and metadata, but doesn't address A/B +comparisons or the fundamental coupling between identity and ordering. + +### Approach C: Runs Submit SHA, External Process Assigns Ordinal + +Flip the model: submitters send what's natural (a Git SHA) as the order field. +The Order is created with no position (`ordinal = NULL`). An external process +later assigns the ordinal. + +**Verdict**: Clean separation, but doesn't address metadata or the A/B +comparison problem on its own. + +### Key Insight: `ordinal` Hasn't Shipped + +The `ordinal` column is a v5 branch addition, not in production. This means we +can design it from scratch to be nullable, natively supporting Approach C +without backward-compatibility constraints. + +### Combined Approach: B + C + +Approaches B and C complement each other: +- **C handles identity + positioning**: What identifies an order (SHA), and how + its position is determined (nullable ordinal, externally assigned). +- **B handles metadata + display**: What additional info lives on the order + (commit message, author), and what the UI shows. + +For new test suites (SHA-based): the order field IS the display value (the +SHA), ordinal is just for sorting. `display: true` isn't needed. + +For existing test suites (git-distance-based): `display: true` on an order +metadata field (like `git_sha`) overrides the numeric sort key in the UI. + +### The A/B Comparison Problem + +A frequently cited use case for LNT is one-off A/B comparisons with +experimental changes. Currently, every run must have a valid order, forcing +users to create dummy orders that clutter the order space. + +This led to the idea of making `order_id` on Run **nullable**: a run without +an order is a standalone data point that exists, has samples, belongs to a +machine, but isn't tied to any position in the project's history. + +This gives three tiers: +1. **Run with ordered identifier** (ordinal set): normal time-series data. +2. **Run with unordered identifier** (ordinal NULL): tied to a commit but not + yet positioned. +3. **Run with no identifier**: standalone/throwaway, for A/B comparisons. + +## Final Design Direction + +### The Core Concept: Identifiers Replace Orders + +Decouple "grouping" from "ordering" entirely: + +1. **Every run has an optional identifier** (a single string). Runs with the + same identifier are grouped together. The identifier has no inherent + ordering semantics. It could be a SHA, a label like + `"wip-experimental-vectorizer"`, or anything else. + +2. **Ordering is optional and external.** Via the API, you can assign an + ordinal to an identifier, which places it in a total order. This unlocks + time-series views, regression detection, etc. + +### Impact on v4 + +The v4 API and views can stay **completely unchanged**: +- v4 submissions always create orders (backward compatible). +- v4 queries use inner joins on Order, naturally excluding identifier-less + runs. +- Only a handful of shared DB methods need NULL guards. + +A v5 instance would only serve v5 API endpoints -- no straddling both APIs. + +### Clean Break for v5 DB Layer + +Rather than retrofitting the v4 database schema, the v5 database is a clean +redesign. Migration from v4 to v5 is handled by a separate offline tool. This +means: +- No migration constraints on the v5 schema design. +- No dual-path code in the DB layer. +- No schema format version gymnastics. +- v4 code stays frozen in the codebase for production instances. +- v5 code is clean and purpose-built. + +### Proposed v5 Data Model + +``` +Identifier: + id Integer PK + identifier String, unique, not null + ordinal Integer, nullable, unique + tag String, nullable + (metadata columns from order_fields in schema) + +Run: + id Integer PK + identifier_id Integer FK, nullable -- orderless runs just work + machine_id Integer FK + start_time DateTime + end_time DateTime + +Machine, Test, Sample -- cleaned up versions of today's tables +``` + +### Proposed Schema Format + +```yaml +name: nts + +metrics: +- name: compile_time + type: Real + ... + +run_fields: +- name: start_time +- name: end_time + +order_fields: # optional metadata on identifiers +- name: author +- name: commit_message + type: text + +machine_fields: +- name: hardware +- name: os +``` + +The identifier is a built-in concept (a single string column), not +schema-defined. `order_fields` provides optional typed metadata columns on +the Identifier table. + +### Storing Large Strings + +For metadata fields like commit messages, two options were discussed: + +- **SQLAlchemy `Text` column**: Unlimited length on SQLite/PostgreSQL. Specified + via `type: text` in the schema. +- **`Binary` column with JSON encoding**: The existing LNT pattern + (`parameters_data` on Machine/Run). Good for unstructured data. + +Schema-defined `order_fields` would use `Text` for large strings. A generic +`parameters_data` blob could also be added for unstructured metadata. + +### Population of Identifier Metadata + +Identifier metadata (commit info, ordinals) is populated **only via the API** +(e.g., `PATCH /api/v5/{ts}/identifiers/{id}`), not by run submissions. Run +submissions only provide the identifier string. An external workflow (CI hook, +cron job, etc.) handles the rest. + +### Deployment Model + +- **Production instances**: Run v4 API with v4 DB layer (unchanged). +- **Experimental instances**: Run migration tool once, then run v5 API with + v5 DB layer. +- A v5 instance serves only v5 endpoints. + +## Open Questions + +1. How should backward compatibility work for existing test suites that submit + numeric git distances? Options: auto-assign ordinal for numeric identifiers, + require external process for everyone, or make it configurable per suite. +2. Exact schema format details: does `order: true` still exist on a run field + to link submissions to identifiers, or is the identifier always a separate + top-level field in the submission JSON? +3. How does the `display: true` mechanism work in the UI -- is it needed at + all if the identifier itself (e.g., SHA) is already human-meaningful? +4. Migration tool design: how to map v4 Orders (with linked-list positions) to + v5 Identifiers (with ordinals). diff --git a/docs/developer_guide.rst b/docs/developer_guide.rst index a4e944ea6..83e8275ee 100644 --- a/docs/developer_guide.rst +++ b/docs/developer_guide.rst @@ -19,8 +19,8 @@ virtual environment and installing the development dependencies:: pip install ".[dev]" This will install the current version of the package, along with the dependencies -required for development (``lit``, ``filecheck``, etc). Note that ``curl``, ``jq`` -and ``docker`` are also required for running the tests. +required for development (``lit``, ``filecheck``, etc). Note that ``curl``, ``jq``, +``docker`` and ``npm`` (Node.js) are also required for running the tests. Running LNT's Regression Tests ------------------------------ diff --git a/docs/v5-implementation-guide.md b/docs/v5-implementation-guide.md new file mode 100644 index 000000000..a9f801143 --- /dev/null +++ b/docs/v5-implementation-guide.md @@ -0,0 +1,252 @@ +# v5 Implementation Guide + +This document covers what a developer (or AI agent) needs to know about the v5 +codebase that isn't obvious from the design docs or the code itself. + +**Relationship to other documentation:** + +- **Design docs** (`docs/design/`): The authoritative spec for what v5 does and + why. Start there for data model, API contracts, and UI behavior. +- **This guide**: Cross-cutting conventions, patterns, and gotchas -- the glue + between the spec and the code. +- **The code**: The implementation. Read it directly -- this guide does not + duplicate what the code already says. +- **`docs/v5-todo.md`**: Active work item tracker for remaining tasks. +- **`/llms.txt`**: AI-oriented endpoint describing the API for programmatic + consumers. +- **Swagger UI** (`/api/v5/openapi/swagger-ui`): Interactive API reference + with request/response schemas. + + +## 1. Where Things Live + +| Layer | Location | Description | +|-------|----------|-------------| +| Database | `lnt/server/db/v5/` | Schema parser, dynamic model factory, CRUD interface (3 files) | +| API | `lnt/server/api/v5/` | flask-smorest endpoints, marshmallow schemas, middleware, auth | +| Frontend | `lnt/server/ui/v5/frontend/src/` | Vanilla TypeScript SPA (Vite build) | +| Flask routes | `lnt/server/ui/v5/views.py` | SPA shell serving (suite-scoped + suite-agnostic) | +| SPA template | `lnt/server/ui/v5/templates/v5_app.html` | Standalone HTML shell for the SPA | +| Tests (API) | `tests/server/api/v5/` | lit + pytest against Postgres | +| Tests (DB) | `tests/server/db/v5/` | lit + pytest against Postgres | +| Tests (UI) | `lnt/server/ui/v5/frontend/src/__tests__/` | vitest + jsdom | +| Design docs | `docs/design/` | Authoritative spec (DB, API, UI) | + + +## 2. v4/v5 Coexistence + +v4 and v5 coexist in the same codebase, controlled by `db_version` in +`lnt.cfg`: + +- **`'0.4'`** (default): v4 Flask/Jinja2 views + Flask-RESTful API only. + No v5 code is registered. +- **`'5.0'`**: v5-only mode. All v4 views are skipped. Only the v5 frontend + blueprint and v5 API (flask-smorest) are registered. + +Key coexistence rules: + +- v4 and v5 DB layers are fully independent -- `lnt/server/db/v5/` has zero + imports from v4 DB code. +- v5 error handlers are registered AFTER app-level handlers. They delegate + to the previous handler for non-v5 routes (though in practice non-v5 routes + don't exist in v5-only mode). + + +## 3. Database Layer Patterns + +See design docs D1-D5 (data model) and D6-D14 (operations) for the full +database specification. The following are implementation patterns not covered +there: + +**Dynamic model factory.** The model factory in `models.py` generates +SQLAlchemy classes dynamically using `type()`. This is how per-suite tables +with schema-defined columns are created at runtime. Built-in columns on +the Commit table (`id`, `commit`, `ordinal`, `tag`) are defined statically; +dynamic `commit_fields` columns are added from the schema. + +**`_UNSET` sentinel.** Update methods (e.g., `update_regression()`) need to +distinguish "caller didn't pass this argument" from "caller explicitly passed +`None` to clear the field." A class-level `_UNSET = object()` sentinel is +used as the default. If the argument `is not _UNSET`, it's applied (including +`None` to clear). If it `is _UNSET`, the field is left unchanged. + +**Datetime handling.** All DB datetimes are timezone-aware UTC +(`DateTime(timezone=True)`, i.e., `TIMESTAMP WITH TIME ZONE`). API responses +include a `Z` suffix via `format_utc()`. `parse_datetime()` in the API +helpers returns aware UTC (bare strings without timezone info are assumed +UTC). + + +## 4. API Layer Patterns + +See design docs R1-R10 (infrastructure) and the endpoints spec for the full +API specification. The following are implementation patterns not covered there: + +**Bootstrap token.** The token configured via `api_auth_token` in `lnt.cfg` +acts as an admin-scoped key, allowing existing deployments to use the v5 API +immediately without creating API keys first. If a Bearer token is provided +but invalid/revoked, the API aborts with 401 -- it never silently downgrades +to unauthenticated access. + +**Cursor implementation.** The cursor is a base64-encoded last-seen primary +key. `cursor_paginate()` fetches `limit + 1` rows to detect whether a next +page exists. The `previous` field is always `null` (forward-only in v1); +the envelope shape is forward-compatible with adding backward pagination +later. + +**Nullable PATCH fields.** For PATCH endpoints where a field can be cleared +to `null` (e.g., `bug`, `notes`, `commit` on regressions; `ordinal` and +`tag` on commits), three layers must +agree: (1) the marshmallow schema field needs `allow_none=True` so +marshmallow doesn't strip `null` values before the endpoint sees them, +(2) the endpoint uses `'key' in body` (not `body.get('key')`) to distinguish +absent from present, and (3) the DB layer uses the `_UNSET` sentinel +(section 3) to distinguish "not provided" from "set to None." Breaking any +one of these layers causes `null` values to be silently ignored. + +**Machine/test delete and RegressionIndicators.** `RegressionIndicator` has +`machine_id` and `test_id` FKs with no `ondelete=CASCADE` (intentional -- +regressions are triage artifacts that should not vanish when underlying +entities change). The machine and test delete handlers must manually delete +indicators referencing the entity before deleting it, or the FK constraint +will block the delete. + +**Middleware.** The v5 middleware intercepts all `/api/v5/` requests and: + +1. Opens a DB session (stored in `g.db_session`). +2. Calls `db.ensure_fresh()` to detect schema changes from other workers. +3. Resolves the test suite from the URL path (if applicable). +4. Adds CORS headers. +5. Logs access in Apache combined format. + +Note: `ensure_fresh` is a per-request contract for **all** v5 code paths, not +just the API. The SPA shell views also call it via `_setup_testsuite()` (see +Frontend Patterns below). + +**Unknown parameter rejection.** Every endpoint calls +`reject_unknown_params(allowed_set)` to return 400 on unrecognized query +parameters. This catches typos and prevents silent filter failures. + + +## 5. Frontend Patterns + +See design docs for architecture, page specs, and UI behavior. The following +are implementation patterns not covered there: + +**SPA shell.** The template `v5_app.html` is a standalone HTML page -- it +does NOT extend the v4 `layout.html`. This avoids inheriting Bootstrap 2, +jQuery, and v4 layout artifacts. + +The `_setup_testsuite()` helper in `lnt/server/ui/v5/__init__.py` is the +shared entry point for all `/v5/` routes. It calls `_make_db_session()` and +then `db.ensure_fresh()` (for v5 databases) so the server-side +`data-testsuites` attribute is always current, even when another worker has +created or deleted a suite since this worker last checked. + +Gotcha: `data-testsuites` uses `| tojson | forceescape` in the Jinja +template. `forceescape` is required because Flask's `tojson` returns a +`Markup` object (marked HTML-safe), making the `| e` filter a no-op. Without +`forceescape`, the JSON double-quotes break the HTML attribute. + +**Internal links.** All internal navigation uses the `spaLink()` utility, +which sets a real `href` (so Cmd+Click opens in a new tab) and intercepts +plain clicks for SPA navigation (no full page reload). + +**URL state.** Settings changes (filters, sort, aggregation) use +`replaceState` (not `pushState`), so the browser Back button navigates +between pages, not between individual setting changes within a page. + +**Component catalog.** Reusable components shared across pages: +`data-table`, `pagination`, `combobox` (generic base), `commit-combobox`, +`machine-combobox`, `regression-combobox`, `metric-selector`, +`commit-search`, `sparkline-card`, `time-series-chart`, `delete-confirm`. + +**Text filter utility.** All client-side text filters use the shared +`matchesFilter(text, filter)` function from `utils.ts` for matching, and +`updateFilterValidation(input)` for visual feedback. These implement the `re:` +regex convention described in the +architecture design doc. The regex is compiled once and cached per filter +string to avoid per-item recompilation on large lists. The +`updateFilterValidation(input)` helper manages all visual feedback on +filter inputs: the `.filter-invalid` red halo for invalid regex, and an +inline "regex" badge (``) that appears when +the input starts with `re:`. For plain filter inputs (not inside a combobox +wrapper), `updateFilterValidation` lazily wraps the input in a +`
` to provide the `position: relative` +context needed for badge positioning. + +**Display:none fast path.** Pages with large tables (Compare, Graph) separate +row creation from filter application. On data load or sort change, a full +rebuild creates all rows. On filter-only changes, a lightweight function +iterates stored row references and toggles visibility via `display:none` — no +DOM creation or destruction. + +**RAF-batch expensive renders.** When a user action updates both a table and +a chart, the table updates synchronously (display:none toggles are fast) and +the chart render is deferred to `requestAnimationFrame`. A generation counter +ensures stale callbacks are discarded. Cancel the pending RAF in the page's +`unmount()` to avoid stale renders. + + +## 6. Testing + +**Python tests** use a lit + pytest hybrid pattern. Each test file has lit +`RUN` lines at the top that: + +1. Create a temporary Postgres instance (via `with_postgres.sh`). +2. Create a temporary v5 LNT instance (via `with_temporary_instance.py`). +3. Run the Python test file with the instance path as an argument. + +To run a single test: +```bash +tox -e py3 -- path/to/test.py +``` + +Do not use `lit` or `pytest` directly -- `tox` sets up the environment +correctly. Only one test path can be passed at a time. + +Test helpers live in `tests/server/api/v5/v5_test_helpers.py` and provide: +`create_app()`, `create_client()`, `admin_headers()`, data creation +functions, and `collect_all_pages()` for cursor-paginated endpoints. + +**Frontend tests** use vitest with jsdom: +```bash +cd lnt/server/ui/v5/frontend && npm test +``` + +**Full suite:** +```bash +tox +``` + +This runs all environments (Python tests, frontend tests, type checking). + + +## 7. Build and Packaging + +**Frontend build.** `npm run build` in `lnt/server/ui/v5/frontend/` outputs +to `lnt/server/ui/v5/static/v5/` (IIFE format with source maps). Built +assets are committed to the repo. + +**Python packaging.** `pyproject.toml` uses setuptools-scm for versioning. +`MANIFEST.in` includes the v5 static files, and `pyproject.toml` declares +package data for `lnt.server.ui.v5`. If the frontend build output path +changes, both `MANIFEST.in` and `pyproject.toml` package-data globs must +be updated to match, or static assets will be silently excluded from the +installed package. + +**Docker.** `docker/compose.yaml` defines a Postgres backend and nginx +reverse proxy. The `lnt.dockerfile` handles DB initialization and startup. + + +## 8. Remaining Work + +- **Migration tool** (`lnt admin migrate-to-v5`): Not started. See design + doc D12 in `docs/design/db/operations.md` for the specification. +- **Profiles**: The v5 Profile model stores profile binary data as Postgres + BYTEA (one profile per run+test pair). The profile binary format parser + lives in `lnt/server/db/v5/profile.py`. API endpoints use profile UUIDs + for data access. See design docs D13 in `docs/design/db/operations.md` + and the Profiles section in `docs/design/api/endpoints.md`. + UI spec: `docs/design/ui/profiles.md`. +- **Open work items**: See `docs/v5-todo.md` for the active tracker. diff --git a/docs/v5-todo.md b/docs/v5-todo.md new file mode 100644 index 000000000..a67db747b --- /dev/null +++ b/docs/v5-todo.md @@ -0,0 +1,333 @@ +# V5 — Work Items + +## API Design & Consistency + +### Samples + +- [ ] Check whether `GET /runs/{uuid}/samples` should accept a metric + filter parameter to reduce data transfer. +- [ ] Check whether filtering `GET /runs/{uuid}/samples` by test name + would eliminate the need for the `/runs/{uuid}/tests/{name}/samples` endpoint. +- [ ] Check whether `/runs/{uuid}/samples` should become a top-level + `/samples?run=UUID` endpoint. +- [ ] Understand whether the `/query` API can be folded into a top-level + `/samples` endpoint for time-series data extraction. +- [ ] Check whether `before`/`after` should be renamed to + `submitted_before`/`submitted_after`. Audit all time-filter parameters across + the API for consistency (including `before_time`/`after_time` on `/trends`). + +### Machines + +- [ ] Document supported sort orders on `/machines/{name}/runs`. +- [ ] Understand whether the machines list endpoint should use cursor pagination + instead of offset pagination. +- [ ] Understand whether both `GET /machines/{name}/runs` and + `GET /runs?machine=x` are needed, or if the `/runs` endpoint suffices. +- [ ] Add ordinal-based sorting to the machines endpoint. The Graph page + currently passes an invalid sort parameter that is silently ignored; it should + error instead. + +### Commits & Orders + +- [ ] Allow including the ordinal (and commit_fields) in run submissions, and + make it a hard error if any submitted commit_field (or ordinal) clashes with + existing values on the commit. Currently commit_fields use first-write-wins + silently, and ordinals can only be set via PATCH. + +### Regressions + +- [ ] Accept an optional client-provided UUID on `POST /regressions`, matching + the pattern already implemented for runs. Useful for stable UUIDs across + mirroring and re-submissions. + +### Tests + +- [ ] Accept multiple `machine=` values on `GET /tests` (OR semantics). The + Regression Detail "Add Indicators" panel calls `getTests()` once per selected + machine then unions results client-side (`regression-detail.ts:754`). + Multi-value machine support would collapse these into a single request. + +### Test Suites + +- [ ] Allow modifying test suite schemas after creation. Add a PATCH endpoint + for test suite schemas that supports adding, removing, and renaming metrics, + commit_fields, and machine_fields. Build a UI page to surface this + functionality so users can manage test suites without writing any code. + +### Runs + +- [ ] Add a `?samples=true` option to `GET /runs/{uuid}` that includes all + samples in the response, producing a v5-submittable JSON payload. This + enables round-tripping: `GET /runs/{uuid}?samples=true` returns data that + can be re-submitted via `POST /runs`. + +### General + +- [ ] Add count endpoints (or a count mode) for commits, runs, machines, etc. +- [ ] Enforce that all schema-defined fields (metrics, commit_fields, and + machine_fields) have an explicit type. Machine fields currently have no `type` + attribute and are always `String(256)`. Once typed, validate submitted values + against declared types at all CRUD endpoints (run submission, PATCH commit, + PATCH machine, etc.) — e.g. reject a string for an `integer` commit_field. +- [ ] Audit sort orders across all API endpoints for consistency. For example, + should `/machines/{name}/runs` allow sorting on commit order? +- [ ] Understand whether the regression detection tool would benefit from a + richer time-series endpoint. + +## UI — Compare Page + +- [x] Show a clickable link to the regression detail page after creating a + regression (not just the UUID). +- [x] Clear the title input after successfully creating a regression. +- [ ] Replace the `
` "Add to Regression" panel with a sticky floating + button (bottom-right) that expands into a panel on click. +- [ ] Understand whether the Compare page should allow inputting an arbitrary + local run. +- [ ] Fix: chart zoom filter leaks — tests outside the visible x-axis range + after zooming still appear in the comparison table. +- [ ] Fix: the "Add to Regression" panel does not take chart zoom into account + — indicators are built from all visible table rows regardless of the zoom + range, so zooming in on a subset of tests still adds all of them. +- [ ] Add a "View on Graph" link per test row that navigates to the Graph page + pre-populated with the test, machines from both sides, and the current metric. +- [x] Fix: "Add to Existing Regression" search is not a proper dropdown — + selecting a regression from the candidate list does not collapse the list. +- [ ] Add noise filtering based on Mann-Whitney U-test as an alternative to the + existing Welch's t-test filter. Users should be able to toggle between the two + methods. +- [ ] Allow plotting error bars on the log2 ratio chart. Investigate what kind + of error bars are most appropriate (standard deviation, confidence intervals, + min/max range, etc.) and how to surface confidence levels for tests with + multiple samples. +- [ ] Reclassify the "Delta % below" noise knob as an "Unchanged threshold". + Tests with `|delta %| < threshold` should get status `unchanged` (too small to + care about) rather than `noise` (not statistically real). This separates + relevance filtering (unchanged threshold) from confidence filtering (p-value, + absolute floor). Update the design doc, the noise classification logic in + `comparison.ts`, the summary bar counts, the chart noise band, and URL state + parameter names accordingly. + +## UI — Graph Page + +- [ ] Fix: regression annotations are not showing. The code exists + (`buildRegressionOverlays`, `fetchAndApplyRegressionAnnotations`) but + something prevents them from appearing. +- [ ] Fix: when adding new traces after selecting a baseline, the baselines for + the newly-added traces are not added to the graph. +- [ ] Fix: baselines cannot display commits that lack an ordinal. Since ordinals + are optional in the v5 data model, baselines must handle commits identified + only by their commit string. +- [ ] Reconsider select-all checkbox behavior: when some tests are selected, + clicking the top checkbox currently selects everything. Consider toggling to + "unselect all" first, then "select all" on a second click. +- [ ] Ensure `display: short sha` has an easy way to be populated. Without it, + the default Graph x-axis display is poor. +- [ ] Allow clicking inside the tooltip to enable clickable links (e.g. to the + commit detail page). +- [ ] Allow plotting the geomean. Clicking a dashboard sparkline should navigate + to a geomean graph. +- [ ] Allow toggling between revisions and dates on the x-axis. Note: commits + may not always map to dates. + +## UI — Graph / Compare Shared + +- [ ] Experiment with placing the search bar just above the table rows instead + of its current position. + +## UI — General + +- [ ] Fix: `updateShadowToolbar()` in Compare page is only called from within + the `if (hasToken)` block, so users without an auth token get a broken + shadow toolbar that never updates after renders. +- [ ] Fix: `machine-combobox.ts` silently swallows fetch errors — the dropdown + stays stuck on "Loading machines..." forever if the API call fails. Adopt + the `fetchError` pattern from `regression-combobox.ts`. +- [ ] Fix: typing `re:` in any text input with regex support causes the input to + lose focus. The bug affects all pages that use the `re:` regex toggle + (Compare, Graph, Test Suites). +- [ ] Fix: copy-to-clipboard does not work on the Admin page when creating a new + API key. +- [ ] Fix: Runs tab search on Test Suites page uses exact machine name match + (`?machine=`), so partial input returns empty results. Consider switching to + a machine combobox. +- [ ] Machine detail page: allow searching the run history by run UUID or by + commit. +- [ ] Consider infinite scrolling for test suites sub-tab lists with filters + applying to all results, not just the current page. + +## Cleanup & Tech Debt + +- [ ] Remove the `/v5` prefix from all URL paths. UI pages should be served at + `/{testsuite}/...` (not `/v5/{testsuite}/...`), suite-agnostic pages at `/` + (not `/v5/`), and the API at `/api/{testsuite}/...` (not + `/api/v5/{testsuite}/...`). Add a `/api/version` endpoint to query the API + version. This affects Flask routes, the API blueprint prefix, the SPA router, + the nav component, static asset paths, and all tests that reference `/v5/` + paths. The `urlBase` reverse-proxy prefix support should be preserved. +- [ ] Undo changes made to the v4 layer (e.g. new migrations). +- [x] Reorganize `combobox.ts`, `machine-combobox.ts`, and + `regression-combobox.ts` — remnants of the Compare page being standalone. + All three combobox components (machine-combobox, commit-search, + regression-combobox) share ~50-60 lines of identical boilerplate (ARIA, + keyboard nav, blur/outside-click handling) and are candidates for + unification into a single generic combobox base. `combobox.ts` already has + a factored-out `setupComboboxKeyboard` helper that is not reused by the + standalone combobox components — it should be exported and shared. + +## Use Cases + +- [ ] Look into Parquet integration for regression analysis. +- [ ] Investigate the production of a daily report. +- [ ] Look into providing an easier way to compare a run with the previous run. + +## Performance + +### P0 — Critical + +- [ ] **Add pagination/limit to `POST /trends`** (impact: prevents unbounded + queries). The trends endpoint returns ALL matching data points with no limit. + The `EXP(AVG(LN(metric)))` geomean scans the entire Sample table — O(N) on + 100M+ rows. Without pagination, responses can contain tens of thousands of + items and take 30+ seconds. + +- [ ] **Gate `dump_response()` validation on debug mode** (impact: halves + marshmallow serialization cost). `dump_response()` in `helpers.py` runs both + `schema.dump()` and `schema.validate()` on every serialized item. The validate + call re-parses the just-dumped output as a safety net. For the query endpoint + at limit=10,000, this adds ~1–2 seconds of pure Python overhead. The validate + step should be skipped in production. + +### P1 — High + +- [ ] **Remove `ordered = True` from BaseSchema.Meta** (impact: eliminates + `OrderedDict` overhead across all schemas). All schemas inherit + `ordered = True`, forcing marshmallow to use `OrderedDict` instead of plain + `dict`. Since Python 3.7+ dicts maintain insertion order, this is unnecessary + and adds 2–3x overhead on dict construction in tight serialization loops. + +- [ ] **Add Cache-Control headers to immutable endpoints** (impact: eliminates + repeat fetches entirely). No v5 API endpoint (except `/llms.txt`) sets + Cache-Control headers. Immutable resources — run detail, test + suite schemas — should have `Cache-Control: public, max-age=86400, immutable`. + Slowly-changing resources (machines, commits) should have short-lived caching + (60–300s). + +- [ ] **Fix N+1 `run.commit_obj` in MachineRuns endpoint** (impact: eliminates + up to 500 lazy-load queries per page). `GET /machines/{name}/runs` + (`machines.py:275`) accesses `run.commit_obj.commit` in serialization without + eager loading. Each run in the page triggers a separate SELECT. Fix: add + `.options(joinedload(ts.Run.commit_obj))` to the query. + +- [ ] **Replace regression list indicator subqueryload with SQL COUNT(DISTINCT)** + (impact: avoids loading thousands of indicator objects). The regression list + endpoint (`regressions.py:199`) uses `subqueryload(ts.Regression.indicators)` + to load ALL indicator rows for every regression on the page, then iterates them + in Python just to compute `machine_count` and `test_count`. A single SQL + aggregation query would be dramatically more efficient. + +- [ ] **Cache schema version check with TTL** (impact: eliminates 1 DB + round-trip per request). `ensure_fresh()` (`middleware.py:60`) queries + `v5_schema_version` on every single request. Schema changes are extremely rare + (suite creation/deletion). A time-based cache (e.g., 5–10 seconds) would skip + the query on most requests. + +- [ ] **Use INSERT ON CONFLICT DO NOTHING for batch indicator addition** (impact: + avoids O(existing_count) Python-side dedup). `add_regression_indicators_batch` + (`__init__.py:1162`) loads ALL existing indicators for a regression into Python + to build a dedup set before inserting new ones. PostgreSQL's + `INSERT ... ON CONFLICT (regression_id, machine_id, test_id, metric) DO NOTHING` + eliminates this entirely. + +### P2 — Medium + +- [ ] **Cache API key lookups in-process with short TTL** (impact: eliminates + 1 DB round-trip per authenticated request). Every authenticated request hashes + the bearer token and queries the `api_key` table. An in-process LRU cache + keyed on `key_hash` with a short TTL (e.g., 60 seconds) would avoid the + query on most requests. Caveat: revoked keys remain valid for up to TTL + seconds. + +- [ ] **Replace ORM `session.delete()` with bulk SQL DELETE for machines/runs** + (impact: prevents potential OOM on large cascades). `delete_machine` + (`__init__.py:628`) uses `session.delete(machine)` which can cascade through + 10K runs × 7,500 samples = 75M rows. While `passive_deletes=True` should + prevent loading, it is fragile. A direct + `session.query(ts.Machine).filter(...).delete()` or raw SQL DELETE is safer + and faster. + +- [ ] **Use EXISTS subquery instead of JOIN+DISTINCT for regression indicator + filters** (impact: more efficient semi-join for large indicator tables). The + regression list endpoint (`regressions.py:239`) uses JOIN + DISTINCT when + filtering by machine/test/metric. An EXISTS subquery avoids producing large + intermediate result sets and is better optimized by PostgreSQL. + +- [ ] **Use `schema.dump(many=True)` for batch serialization** (impact: ~5–10x + faster serialization for large result sets). List endpoints and the query + endpoint call `dump_response()` per-item in a loop. Marshmallow's + `dump(many=True)` amortizes schema introspection and field setup across + all items. + +- [ ] **Compute ETag from response body bytes, not re-serialization** (impact: + eliminates double JSON serialization on detail endpoints). `compute_etag()` + (`etag.py:13`) calls `json.dumps(data, sort_keys=True)` to hash the response, + but `jsonify()` also serializes the same data. Computing the ETag from + `response.get_data()` instead eliminates one full JSON serialization. + +- [ ] **Investigate database size and performance of data submissions**. Using + lnt-scrape reveals very slow submission times. Profile and identify + bottlenecks. + +### P3 — Low / Polish + +- [ ] **Eliminate double existence check in `POST /commits`**: The endpoint + (`commits.py:193`) calls `ts.get_commit()` to check for existence, then + `ts.get_or_create_commit()` does its own existence check. Remove the redundant + first check. + +- [ ] **Eliminate redundant flush calls in PATCH commits path**: `update_commit` + (`__init__.py:437`) flushes after every call. When both ordinal and + commit_fields are updated, there are 3 flushes per request. Defer flushing + to the endpoint level. + +- [ ] **Add max batch size to `POST /commits/resolve` schema**: The + `CommitResolveRequestSchema` has no max length validation on the `commits` + list. A client can send 21K+ values in one request. Add + `validate=Length(min=1, max=5000)`. + +- [ ] **Remove dead `getFieldChanges()` from frontend `api.ts`**: The function + (`api.ts:295`) is exported but never imported or called anywhere in the + frontend source. Dead code. + +- [ ] **Add `?search=` filter to regressions endpoint**: Both the regression + list page and the Compare page "Add to Existing Regression" panel do + client-side title search limited to the first page of results (25 or 50 + items). Regressions beyond that page are invisible to the search. A + server-side `?search=` parameter (substring match on title, consistent with + machines/commits/tests) would fix this data completeness bug. + +- [ ] **Only set full CORS headers on OPTIONS preflight responses**: Currently + all 5 CORS headers are set on every response (`middleware.py:91`). Only + `Access-Control-Allow-Origin` and `Access-Control-Expose-Headers` are needed + on non-preflight responses. Saves ~200 bytes of header per response. + +- [ ] **Accept multiple `machine=` values on `GET /commits`**: The Graph page + fetches commit scaffolds per-machine in parallel then unions client-side + (`graph-data-cache.ts:55`). Multi-value machine support would collapse N + paginated fetches into one. + +- [ ] **Use EXISTS instead of JOIN+DISTINCT for machine/metric filters on + `GET /tests`**: The tests endpoint (`tests.py:63`) joins Test→Sample→Run + and applies DISTINCT. An EXISTS subquery (matching the pattern in + `commits.py:163`) would avoid the large intermediate result set. + +## Profiles + +- [ ] **CFG view**: Control-flow graph renderer (D3-based, ISA-specific). + Deferred to a future phase. +- [x] **Replace N+1 profile-existence check in commit picker**: The Profiles + page commit dropdown calls `GET /runs/{uuid}/profiles` for every run on the + selected machine to filter commits without profiles. Replace with a + server-side mechanism (e.g. `has_profiles` flag on run list responses or a + filtered commits endpoint). The Profiles page also fetches all commits then + filters client-side by machine — it should use the existing `?machine=` + filter on `GET /commits` instead. diff --git a/lnt/lnttool/create.py b/lnt/lnttool/create.py index e397087c8..eeb576a30 100644 --- a/lnt/lnttool/create.py +++ b/lnt/lnttool/create.py @@ -38,7 +38,7 @@ # The list of available databases, and their properties. At a minimum, there # should be a 'default' entry for the default database. databases = { - 'default' : { 'path' : %(default_db)r }, + 'default' : { %(db_version_line)s'path' : %(default_db)r }, } # The LNT email configuration. @@ -90,9 +90,12 @@ help="authentication token for the REST API") @click.option("--show-sql", is_flag=True, help="show SQL statements executed during construction") +@click.option("--db-version", default="0.4", type=click.Choice(["0.4", "5.0"]), + show_default=True, + help="database version (5.0 requires PostgreSQL)") def action_create(instance_path, name, config, wsgi, tmp_dir, db_dir, profile_dir, default_db, secret_key, url, api_auth_token, - show_sql): + show_sql, db_version): """create an LLVM nightly test installation \b @@ -133,6 +136,14 @@ def action_create(instance_path, name, config, wsgi, tmp_dir, db_dir, else: api_auth_token_line = "# api_auth_token = 'secret'" + if db_version == '5.0': + db_version_line = "'db_version': '5.0', " + if lnt.server.db.util.path_has_no_database_type(db_dir): + print("warning: v5 requires PostgreSQL. The default SQLite " + "path will not work. Use --db-dir with a PostgreSQL URI.") + else: + db_version_line = "" + os.mkdir(instance_path) os.mkdir(tmp_path) os.mkdir(schemas_path) @@ -156,7 +167,11 @@ def action_create(instance_path, name, config, wsgi, tmp_dir, db_dir, os.chmod(wsgi_path, 0o755) # Execute an upgrade on the database to initialize the schema. - lnt.server.db.migrate.update_path(db_path) + if db_version == '5.0': + from lnt.server.db.v5 import initialize_v5_database + initialize_v5_database(db_path) + else: + lnt.server.db.migrate.update_path(db_path) print('created LNT configuration in %r' % basepath) print(' configuration file: %s' % cfg_path) diff --git a/lnt/server/api/__init__.py b/lnt/server/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/lnt/server/api/v5/__init__.py b/lnt/server/api/v5/__init__.py new file mode 100644 index 000000000..e0ac36adb --- /dev/null +++ b/lnt/server/api/v5/__init__.py @@ -0,0 +1,38 @@ +"""LNT v5 REST API factory. + +Creates and configures the flask-smorest Api instance, registers middleware, +and hooks up all endpoint blueprints. +""" + +from flask_smorest import Api as SmorestApi + + +def create_v5_api(app): + """Create and register the v5 REST API on the given Flask app. + + Returns the flask-smorest Api instance. + """ + app.config.update({ + "API_TITLE": "LNT API", + "API_VERSION": "v5", + "OPENAPI_VERSION": "3.0.3", + "OPENAPI_URL_PREFIX": "/api/v5/openapi", + "OPENAPI_JSON_PATH": "openapi.json", + "OPENAPI_SWAGGER_UI_PATH": "/swagger-ui", + "OPENAPI_SWAGGER_UI_URL": "https://cdn.jsdelivr.net/npm/swagger-ui-dist/", + }) + smorest_api = SmorestApi(app) + + from .middleware import register_middleware + register_middleware(app) + + from .errors import register_error_handlers + register_error_handlers(smorest_api, app) + + from .endpoints import register_all_endpoints + register_all_endpoints(smorest_api) + + from .endpoints.agents import llms_txt_bp + app.register_blueprint(llms_txt_bp) + + return smorest_api diff --git a/lnt/server/api/v5/auth.py b/lnt/server/api/v5/auth.py new file mode 100644 index 000000000..2871b61a6 --- /dev/null +++ b/lnt/server/api/v5/auth.py @@ -0,0 +1,151 @@ +"""v5 API authentication: Bearer token validation and scope decorators. + +Scope hierarchy (linear, each level includes all below): + read (0) < submit (1) < triage (2) < manage (3) < admin (4) +""" + +import hashlib +import hmac +import functools + +from flask import current_app, g, request +import sqlalchemy.exc + +from lnt.server.db.v5.models import APIKey, utcnow + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_LAST_USED_UPDATE_INTERVAL = 3600 # seconds; update at most once per hour + + +# --------------------------------------------------------------------------- +# Scope hierarchy +# --------------------------------------------------------------------------- + +SCOPE_LEVELS = { + 'read': 0, + 'submit': 1, + 'triage': 2, + 'manage': 3, + 'admin': 4, +} + + +def _get_scope_level(scope_name): + """Return the integer level for a scope name.""" + return SCOPE_LEVELS.get(scope_name, -1) + + +def _hash_token(token): + """SHA-256 hash of a raw token string.""" + return hashlib.sha256(token.encode('utf-8')).hexdigest() + + +def _resolve_bearer_token(): + """Extract and validate the Bearer token from the Authorization header. + + Returns a tuple of (scope_name, api_key_or_None). If no token is + provided (no Authorization header, or non-Bearer scheme), returns + (None, None) so the caller can allow unauthenticated reads. + + If a Bearer token IS provided but is invalid or revoked, aborts + with 401 immediately — an explicitly-presented credential that + fails validation must never be silently downgraded to + unauthenticated access. + """ + auth_header = request.headers.get('Authorization', '') + if not auth_header.startswith('Bearer '): + return None, None + + token = auth_header[len('Bearer '):] + if not token: + from flask import abort + abort(401) + + # Check bootstrap token from lnt.cfg first + legacy_token = getattr(current_app.old_config, 'api_auth_token', None) + if legacy_token and hmac.compare_digest(token, legacy_token): + return 'admin', None + + # Look up hashed token in the APIKey table + session = getattr(g, 'db_session', None) + if session is None: + from flask import abort + abort(401) + + key_hash = _hash_token(token) + try: + api_key = session.query(APIKey).filter( + APIKey.key_hash == key_hash, + APIKey.is_active.is_(True), + ).first() + except (sqlalchemy.exc.OperationalError, sqlalchemy.exc.ProgrammingError): + # Table may not exist yet (pre-migration) + return None, None + + if api_key is None: + from flask import abort + abort(401) + + # Update last_used_at (best effort, throttled to avoid write-on-read) + now = utcnow() + try: + last = api_key.last_used_at + if last is None or (now - last).total_seconds() >= _LAST_USED_UPDATE_INTERVAL: + api_key.last_used_at = now + except Exception: + pass + + return api_key.scope, api_key + + +def get_current_auth(): + """Return (scope_name, api_key_or_None) for the current request. + + Caches the result on ``g`` for the duration of the request. + """ + if hasattr(g, '_v5_auth'): + return g._v5_auth + result = _resolve_bearer_token() + g._v5_auth = result + return result + + +def require_scope(scope_name): + """Decorator that enforces the given scope on a view function. + + If the endpoint requires only ``read`` scope and the deployment + has not set ``require_auth_for_reads`` in lnt.cfg, unauthenticated + requests are allowed (matching v4 behaviour). + """ + required_level = _get_scope_level(scope_name) + + def decorator(fn): + @functools.wraps(fn) + def wrapper(*args, **kwargs): + granted_scope, _api_key = get_current_auth() + + if granted_scope is None: + # No token provided + if required_level <= SCOPE_LEVELS['read']: + # Allow unauthenticated reads unless the deployment + # requires authentication for reads. + require_auth = getattr( + current_app.old_config, + 'require_auth_for_reads', False) + if not require_auth: + return fn(*args, **kwargs) + from flask import abort + abort(401) + + granted_level = _get_scope_level(granted_scope) + if granted_level < required_level: + from flask import abort + abort(403) + + return fn(*args, **kwargs) + return wrapper + return decorator diff --git a/lnt/server/api/v5/endpoints/__init__.py b/lnt/server/api/v5/endpoints/__init__.py new file mode 100644 index 000000000..a94b318f9 --- /dev/null +++ b/lnt/server/api/v5/endpoints/__init__.py @@ -0,0 +1,53 @@ +"""Endpoint registration for the v5 API. + +``register_all_endpoints()`` imports and registers all endpoint blueprints. +Missing modules are handled gracefully (with a warning) so the app works +during incremental development. +""" + +import logging + +logger = logging.getLogger(__name__) + +# All endpoint modules that should be registered. Each entry is the +# sub-module name relative to ``lnt.server.api.v5.endpoints``. +_ENDPOINT_MODULES = [ + 'discovery', + 'test_suites', + 'commits', + 'machines', + 'runs', + 'tests', + 'samples', + 'profiles', + 'query', + 'regressions', + 'trends', + 'admin', +] + + +def register_all_endpoints(smorest_api): + """Import and register every endpoint blueprint with the Api instance. + + Endpoint modules must expose a ``blp`` attribute which is a + ``flask_smorest.Blueprint`` instance. + """ + import importlib + + for module_name in _ENDPOINT_MODULES: + fqn = 'lnt.server.api.v5.endpoints.%s' % module_name + try: + mod = importlib.import_module(fqn) + blp = getattr(mod, 'blp', None) + if blp is not None: + smorest_api.register_blueprint(blp) + else: + logger.debug("Module %s has no 'blp' attribute, skipping.", + fqn) + except ImportError: + logger.debug("Endpoint module %s not yet implemented, skipping.", + fqn) + except Exception: + logger.warning("Failed to register endpoint module %s:", + fqn, exc_info=True) diff --git a/lnt/server/api/v5/endpoints/admin.py b/lnt/server/api/v5/endpoints/admin.py new file mode 100644 index 000000000..cbdfd7e8b --- /dev/null +++ b/lnt/server/api/v5/endpoints/admin.py @@ -0,0 +1,131 @@ +"""Admin endpoints: API key management. + +GET /api/v5/admin/api-keys -- List all API keys (admin scope) +POST /api/v5/admin/api-keys -- Create a new API key (admin scope) +DELETE /api/v5/admin/api-keys/{prefix} -- Revoke a key by prefix (admin scope) + +Admin endpoints live OUTSIDE the {testsuite} namespace. The middleware opens +a DB session for auth validation but does NOT resolve a test suite. +""" + +import secrets + +from flask import g +from flask.views import MethodView +from flask_smorest import Blueprint + +from lnt.server.db.v5.models import utcnow +from ..auth import APIKey, require_scope, _hash_token +from ..errors import reject_unknown_params +from ..helpers import dump_response, format_utc +from ..schemas.admin import ( + APIKeyCreateRequestSchema, + APIKeyCreateResponseSchema, + APIKeyItemSchema, + APIKeyListResponseSchema, +) + +_api_key_schema = APIKeyItemSchema() + +blp = Blueprint( + 'Admin', + __name__, + url_prefix='/api/v5/admin', + description='Create, list, and revoke API keys (scopes: read, submit, triage, manage, admin)', +) + + +@blp.route('/api-keys') +class APIKeyCollection(MethodView): + """List and create API keys.""" + + @require_scope('admin') + @blp.response(200, APIKeyListResponseSchema) + def get(self): + """List all API keys. + + Returns metadata for every key (prefix, name, scope, timestamps, + and active status). The raw token is never included. + """ + reject_unknown_params(set()) + session = g.db_session + keys = session.query(APIKey).order_by(APIKey.id).all() + + items = [] + for k in keys: + items.append(dump_response(_api_key_schema, { + 'prefix': k.key_prefix, + 'name': k.name, + 'scope': k.scope, + 'created_at': format_utc(k.created_at), + 'last_used_at': format_utc(k.last_used_at), + 'is_active': k.is_active, + })) + + return {'items': items} + + @require_scope('admin') + @blp.arguments(APIKeyCreateRequestSchema) + @blp.response(201, APIKeyCreateResponseSchema) + def post(self, payload): + """Create a new API key. + + Returns the raw token exactly once. Store it securely -- it + cannot be retrieved again. + """ + name = payload['name'] + scope = payload['scope'] + + # Generate a cryptographically random token + raw_token = secrets.token_hex(32) # 64-char hex string + prefix = raw_token[:8] + key_hash = _hash_token(raw_token) + + api_key = APIKey( + name=name, + key_prefix=prefix, + key_hash=key_hash, + scope=scope, + created_at=utcnow(), + is_active=True, + ) + + session = g.db_session + session.add(api_key) + session.flush() + + return { + 'key': raw_token, + 'prefix': prefix, + 'scope': scope, + } + + +@blp.route('/api-keys/') +class APIKeyByPrefix(MethodView): + """Revoke a single API key identified by its prefix.""" + + @require_scope('admin') + @blp.response(204) + def delete(self, prefix): + """Revoke an API key by its prefix. + + The key is deactivated rather than deleted, preserving the + audit trail. + """ + session = g.db_session + api_key = session.query(APIKey).filter( + APIKey.key_prefix == prefix, + ).first() + + if api_key is None: + from ..errors import abort_with_error + abort_with_error( + 404, + "API key with prefix '%s' not found" % prefix, + ) + + api_key.is_active = False + session.flush() + + return '' diff --git a/lnt/server/api/v5/endpoints/agents.py b/lnt/server/api/v5/endpoints/agents.py new file mode 100644 index 000000000..ca060b730 --- /dev/null +++ b/lnt/server/api/v5/endpoints/agents.py @@ -0,0 +1,155 @@ +"""llms.txt endpoint: GET /llms.txt + +Serves a plain-text orientation document for AI agents, following the +llms.txt convention (analogous to robots.txt). Describes what LNT is, +its domain concepts, API structure, and common workflows. + +Registered as a plain Flask blueprint (not flask-smorest) so it does not +appear in the OpenAPI spec. +""" + +from flask import Blueprint, make_response +import hashlib + +llms_txt_bp = Blueprint('llms_txt', __name__) + +LLMS_TEXT = """\ +# LNT — LLVM Nightly Test Infrastructure + +LNT is a performance testing infrastructure designed for tracking software +performance over time. It collects benchmark results from test runs, detects +regressions, and provides tools for performance analysis. Originally built +for the LLVM compiler project, it can be used for any software project. + +## Key Concepts + +- **Test Suite**: A schema defining what metrics to collect (e.g., "nts" for + the LLVM nightly test suite). Each suite has its own set of machines, commits, + runs, and tests. All data queries are scoped to a specific test suite. + +- **Machine**: A build/test environment identified by name (e.g., + "clang-x86_64-linux"). Machines have key-value info fields describing + their configuration. + +- **Commit**: A named point that groups runs (e.g., a Git SHA, version number, + or ad-hoc label). Each commit has an optional integer ordinal that places it + in a total order for time-series analysis, and an optional human-readable tag + (e.g., "release-18.1") for labeling. Commits may have metadata fields + (e.g., author, commit_message) defined by the test suite schema. + +- **Run**: A single test execution on a machine at a specific commit. Contains + samples (individual test results) with metric values. Identified by UUID. + +- **Test**: A named benchmark or test case (e.g., "SingleSource/Benchmarks/ + Dhrystone/dry"). Tests are created implicitly when runs are submitted. + +- **Sample**: A single data point: one test's metric values from one run. + Each sample records values for the metrics defined by the test suite schema + (e.g., execution_time, compile_time, code_size). + +- **Regression**: A tracked performance change, grouping one or more indicators + (machine, test, metric triples). Has a state (detected, active, fixed, + etc.), optional title, bug link, notes, and suspected introduction commit. + +## REST API (v5) + +Base URL: /api/v5/ +Authentication: Bearer token in Authorization header. Reads are unauthenticated +by default. Write operations require tokens with appropriate scopes +(submit, triage, manage, admin). + +### Discovery + + GET /api/v5/ List test suites and API links + +### Per-Suite Endpoints (replace {ts} with suite name, e.g., "nts") + + GET /api/v5/{ts}/machines List machines + GET /api/v5/{ts}/machines/{name} Machine detail + GET /api/v5/{ts}/commits List commits (?search=, ?machine=, ?sort=ordinal, ?has_profiles=) + GET /api/v5/{ts}/commits/{value} Commit detail (with prev/next) + POST /api/v5/{ts}/commits/resolve Batch resolve commit strings to summaries + GET /api/v5/{ts}/runs List runs (?machine=, ?commit=, ?has_profiles=) + POST /api/v5/{ts}/runs Submit a run + GET /api/v5/{ts}/runs/{uuid} Run detail + GET /api/v5/{ts}/runs/{uuid}/samples Samples for a run + GET /api/v5/{ts}/runs/{uuid}/profiles List profiles for a run + GET /api/v5/{ts}/profiles/{uuid} Profile metadata + counters + GET /api/v5/{ts}/profiles/{uuid}/functions Function list + GET /api/v5/{ts}/profiles/{uuid}/functions/{fn} Function disassembly + GET /api/v5/{ts}/tests List tests (?search=, ?machine=, ?metric=) + POST /api/v5/{ts}/query Query time-series data + POST /api/v5/{ts}/trends Aggregated trend data (geomean) + GET /api/v5/{ts}/regressions List regressions + POST /api/v5/{ts}/regressions Create regression + +### Global Endpoints + + GET /api/v5/test-suites List all test suites + GET /api/v5/test-suites/{name} Suite detail (schema + metrics) + POST /api/v5/test-suites Create test suite (admin) + GET /api/v5/admin/api-keys List API keys (admin) + POST /api/v5/admin/api-keys Create API key (admin) + DELETE /api/v5/admin/api-keys/{prefix} Revoke API key (admin) + +Full endpoint list including all PATCH/DELETE operations: + /api/v5/openapi/swagger-ui + +### Pagination + +List endpoints return cursor-paginated responses: + { "items": [...], "cursor": { "next": "...", "previous": null } } +Pass cursor= to get the next page. Use limit= to control page size (max 10000, default 25). + +### Common Workflows + +1. Discover available data: GET /api/v5/ to list test suites, then + GET /api/v5/{ts}/machines to see what machines exist. + +2. Query performance history: POST /api/v5/{ts}/query with + { "metric": "execution_time", "machine": "machine-name", + "test": ["test/name"] } to get time-series data points. + Optional filters: after_commit, before_commit, after_time, before_time. + Optional sort: "test", "commit", "submitted_at" (prefix with - for desc). + +3. Submit a run: POST /api/v5/{ts}/runs with a JSON body: + { "format_version": "5", + "machine": { "name": "machine-name" }, + "commit": "abc123", + "tests": [ + { "name": "test/name", "execution_time": 1.23, "compile_time": 0.45 }, + { "name": "test/name", "execution_time": [1.0, 2.0] } + ] } + Metric values can be scalars or arrays (arrays create multiple samples). + An optional "uuid" field provides a client-chosen UUID for the run + (8-4-4-4-12 hex format, normalized to lowercase; 409 if already exists). + If omitted, the server generates a UUID v4. + Requires a token with "submit" scope. + +4. Check for regressions: GET /api/v5/{ts}/regressions?state=detected + to find new regressions. PATCH to update state, title, bug link, notes, + or commit. Add indicators via POST /regressions/{uuid}/indicators. + +5. Inspect a specific commit: GET /api/v5/{ts}/commits/{value} returns + the commit detail with previous/next navigation links. Use + PATCH /api/v5/{ts}/commits/{value} to assign an ordinal for time-series + ordering. + +6. Aggregated trends: POST /api/v5/{ts}/trends with + { "metric": "execution_time", "machine": ["m1", "m2"], "last_n": 500 } + to get geomean-aggregated performance per (machine, commit) pair for the + most recent N commits by ordinal. +""" + +_ETAG = hashlib.md5(LLMS_TEXT.encode()).hexdigest() + + +@llms_txt_bp.route('/llms.txt') +def llms_txt(): + """Serve the LNT orientation document for AI agents.""" + resp = make_response(LLMS_TEXT) + resp.mimetype = 'text/plain' + resp.charset = 'utf-8' + resp.headers['Cache-Control'] = 'public, max-age=86400' + resp.headers['ETag'] = _ETAG + return resp diff --git a/lnt/server/api/v5/endpoints/commits.py b/lnt/server/api/v5/endpoints/commits.py new file mode 100644 index 000000000..e933ef40a --- /dev/null +++ b/lnt/server/api/v5/endpoints/commits.py @@ -0,0 +1,373 @@ +"""Commit endpoints for the v5 API. + +GET /api/v5/{ts}/commits -- List commits (cursor-paginated) +POST /api/v5/{ts}/commits -- Create commit +GET /api/v5/{ts}/commits/{value} -- Commit detail (includes prev/next) +PATCH /api/v5/{ts}/commits/{value} -- Update commit (ordinal, fields) +DELETE /api/v5/{ts}/commits/{value} -- Delete commit (cascade) +POST /api/v5/{ts}/commits/resolve -- Batch resolve commit strings to summaries +""" + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy import or_ + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..etag import add_etag_to_response +from ..helpers import dump_response, escape_like, lookup_machine +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.commits import ( + CommitCreateSchema, + CommitDetailQuerySchema, + CommitDetailSchema, + CommitListQuerySchema, + CommitResolveRequestSchema, + CommitResolveResponseSchema, + CommitSummarySchema, + CommitUpdateSchema, + PaginatedCommitResponseSchema, +) + +_commit_summary_schema = CommitSummarySchema() +_commit_detail_schema = CommitDetailSchema() + +blp = Blueprint( + 'Commits', + __name__, + url_prefix='/api/v5/', + description='List, create, and inspect commits with previous/next navigation', +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _extract_commit_fields(data, ts, skip=('commit', 'ordinal')): + """Extract commit_field values from a request payload. + + Returns a dict of {field_name: value} for fields defined in the + schema, skipping any keys in *skip*. + """ + names = {cf.name for cf in ts.schema.commit_fields} + return {k: v for k, v in data.items() + if k not in skip and k in names} + + +def _serialize_commit_fields(commit_obj, ts): + """Return a dict of {field_name: value} for commit_fields.""" + result = {} + for cf in ts.schema.commit_fields: + val = getattr(commit_obj, cf.name, None) + if val is not None: + result[cf.name] = str(val) + return result + + +def _serialize_commit_summary(commit_obj, ts): + """Serialize a commit for list responses.""" + return dump_response(_commit_summary_schema, { + 'commit': commit_obj.commit, + 'ordinal': commit_obj.ordinal, + 'tag': commit_obj.tag, + 'fields': _serialize_commit_fields(commit_obj, ts), + }) + + +def _serialize_commit_neighbor(commit_obj, testsuite): + """Serialize a previous/next commit reference, or None.""" + if commit_obj is None: + return None + return { + 'commit': commit_obj.commit, + 'ordinal': commit_obj.ordinal, + 'tag': commit_obj.tag, + 'link': '/api/v5/%s/commits/%s' % (testsuite, commit_obj.commit), + } + + +def _get_neighbors(session, ts, commit_obj): + """Find previous/next commits by ordinal. + + Returns (previous, next) where each is a Commit or None. + """ + if commit_obj.ordinal is None: + return None, None + + prev_commit = session.query(ts.Commit).filter( + ts.Commit.ordinal.isnot(None), + ts.Commit.ordinal < commit_obj.ordinal, + ).order_by(ts.Commit.ordinal.desc()).first() + + next_commit = session.query(ts.Commit).filter( + ts.Commit.ordinal.isnot(None), + ts.Commit.ordinal > commit_obj.ordinal, + ).order_by(ts.Commit.ordinal.asc()).first() + + return prev_commit, next_commit + + +def _serialize_commit_detail(commit_obj, testsuite, ts, session): + """Serialize a commit for detail responses, including prev/next.""" + prev_commit, next_commit = _get_neighbors(session, ts, commit_obj) + return dump_response(_commit_detail_schema, { + 'commit': commit_obj.commit, + 'ordinal': commit_obj.ordinal, + 'tag': commit_obj.tag, + 'fields': _serialize_commit_fields(commit_obj, ts), + 'previous_commit': _serialize_commit_neighbor( + prev_commit, testsuite), + 'next_commit': _serialize_commit_neighbor( + next_commit, testsuite), + }) + + +# --------------------------------------------------------------------------- +# Endpoints +# --------------------------------------------------------------------------- + +@blp.route('/commits') +class CommitList(MethodView): + """List and create commits.""" + + @require_scope('read') + @blp.arguments(CommitListQuerySchema, location="query") + @blp.response(200, PaginatedCommitResponseSchema) + def get(self, query_args, testsuite): + """List commits (cursor-paginated).""" + reject_unknown_params( + {'cursor', 'limit', 'search', 'machine', 'sort', 'has_profiles'}) + ts = g.ts + session = g.db_session + + query = session.query(ts.Commit) + + search = query_args.get('search') + if search: + escaped = escape_like(search) + pattern = '%' + escaped + '%' + conditions = [ts.Commit.commit.ilike(pattern, escape='\\')] + conditions.append( + ts.Commit.tag.ilike(pattern, escape='\\')) + for cf in ts.schema.searchable_commit_fields: + col = getattr(ts.Commit, cf.name) + conditions.append( + col.ilike(pattern, escape='\\')) + query = query.filter(or_(*conditions)) + + # -- Machine and has_profiles filters -- + machine_name = query_args.get('machine') + has_profiles = query_args.get('has_profiles') + + if machine_name: + machine = lookup_machine(session, ts, machine_name) + + # Build the run-existence subquery, optionally scoped to a machine. + run_sq = session.query(ts.Run).filter( + ts.Run.commit_id == ts.Commit.id) + if machine_name: + run_sq = run_sq.filter(ts.Run.machine_id == machine.id) + + if has_profiles is not None: + # Profile-existence subquery: runs that have profile data. + profile_sq = ( + session.query(ts.Run.id) + .join(ts.Profile, ts.Profile.run_id == ts.Run.id) + .filter(ts.Run.commit_id == ts.Commit.id) + ) + if machine_name: + profile_sq = profile_sq.filter( + ts.Run.machine_id == machine.id) + + if has_profiles is True: + query = query.filter(profile_sq.exists()) + else: + # has_profiles=false: no profiled run on this commit. + # When machine= is also present, additionally require + # that the commit has at least one run on that machine + # (so the filter means "has runs on machine X but none + # of them have profiles"). + if machine_name: + query = query.filter(run_sq.exists()) + query = query.filter(~profile_sq.exists()) + elif machine_name: + query = query.filter(run_sq.exists()) + + sort_param = query_args.get('sort') + if sort_param == 'ordinal': + query = query.filter(ts.Commit.ordinal.isnot(None)) + cursor_col = ts.Commit.ordinal + else: + cursor_col = ts.Commit.id + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, cursor_col, cursor_str, limit) + + serialized = [_serialize_commit_summary(c, ts) for c in items] + return jsonify(make_paginated_response(serialized, next_cursor)) + + @require_scope('submit') + @blp.arguments(CommitCreateSchema) + @blp.response(201, CommitDetailSchema) + def post(self, body, testsuite): + """Create a commit explicitly.""" + ts = g.ts + session = g.db_session + + commit_str = body['commit'] + + # Check if commit already exists. + existing = ts.get_commit(session, commit=commit_str) + if existing is not None: + abort_with_error(409, "Commit '%s' already exists" % commit_str) + + metadata = _extract_commit_fields(body, ts) + commit_obj = ts.get_or_create_commit(session, commit_str, **metadata) + + # Set ordinal if provided. + ordinal = body.get('ordinal') + if ordinal is not None: + ts.update_commit(session, commit_obj, ordinal=ordinal) + + session.flush() + + result = _serialize_commit_detail(commit_obj, testsuite, ts, session) + resp = jsonify(result) + resp.status_code = 201 + return resp + + +@blp.route('/commits/') +class CommitDetail(MethodView): + """Commit detail, update, and delete.""" + + @require_scope('read') + @blp.arguments(CommitDetailQuerySchema, location="query") + @blp.response(200, CommitDetailSchema) + def get(self, query_args, testsuite, commit_value): + """Get commit detail by commit string. + + The response includes previous_commit and next_commit references + (based on ordinal ordering). + """ + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + + commit_obj = ts.get_commit(session, commit=commit_value) + if commit_obj is None: + abort_with_error(404, "Commit '%s' not found" % commit_value) + + data = _serialize_commit_detail(commit_obj, testsuite, ts, session) + return add_etag_to_response(jsonify(data), data) + + @require_scope('manage') + @blp.arguments(CommitUpdateSchema) + @blp.response(200, CommitDetailSchema) + def patch(self, body, testsuite, commit_value): + """Update commit ordinal, tag, and/or commit_fields.""" + ts = g.ts + session = g.db_session + + commit_obj = ts.get_commit(session, commit=commit_value) + if commit_obj is None: + abort_with_error(404, "Commit '%s' not found" % commit_value) + + # Update ordinal if provided. + if 'ordinal' in body: + ordinal_val = body['ordinal'] + if ordinal_val is None: + ts.update_commit(session, commit_obj, clear_ordinal=True) + else: + ts.update_commit(session, commit_obj, ordinal=ordinal_val) + + # Update tag if provided. + if 'tag' in body: + tag_val = body['tag'] + if tag_val is None: + ts.update_commit(session, commit_obj, clear_tag=True) + else: + ts.update_commit(session, commit_obj, tag=tag_val) + + # Update commit_fields. + field_updates = _extract_commit_fields(body, ts, skip=('ordinal', 'tag')) + if field_updates: + ts.update_commit(session, commit_obj, **field_updates) + + try: + session.flush() + except Exception as exc: + session.rollback() + abort_with_error( + 409, "Failed to update commit: %s" % exc) + + return jsonify( + _serialize_commit_detail(commit_obj, testsuite, ts, session)) + + @require_scope('manage') + @blp.response(204) + def delete(self, testsuite, commit_value): + """Delete a commit and cascade to its runs/samples. + + Returns 409 if regressions reference this commit. + """ + ts = g.ts + session = g.db_session + + commit_obj = ts.get_commit(session, commit=commit_value) + if commit_obj is None: + abort_with_error(404, "Commit '%s' not found" % commit_value) + + try: + ts.delete_commit(session, commit_obj.id) + except ValueError as exc: + abort_with_error(409, str(exc)) + + session.flush() + return '', 204 + + +@blp.route('/commits/resolve') +class CommitResolve(MethodView): + """Batch resolve commit strings to summaries with field values.""" + + @require_scope('read') + @blp.arguments(CommitResolveRequestSchema, location="json") + @blp.response(200, CommitResolveResponseSchema) + def post(self, body, testsuite): + """Resolve a list of commit strings to their summaries. + + Returns each found commit's ordinal and field values in a dict + keyed by commit string. Commit strings not found in the database + are returned in a separate ``not_found`` list. + + Duplicate commit values in the request are deduplicated; each + appears at most once in the response. + """ + ts = g.ts + session = g.db_session + + requested = body['commits'] + unique_values = list(dict.fromkeys(requested)) + + commit_objs = ts.get_commits_by_values(session, unique_values) + found_map = {obj.commit: obj for obj in commit_objs} + + # Build response preserving request order (deduplicated). + results = {} + not_found = [] + for val in unique_values: + obj = found_map.get(val) + if obj is not None: + results[val] = _serialize_commit_summary(obj, ts) + else: + not_found.append(val) + + return jsonify({'results': results, 'not_found': not_found}) diff --git a/lnt/server/api/v5/endpoints/discovery.py b/lnt/server/api/v5/endpoints/discovery.py new file mode 100644 index 000000000..3c277d07c --- /dev/null +++ b/lnt/server/api/v5/endpoints/discovery.py @@ -0,0 +1,52 @@ +"""Discovery endpoint: GET /api/v5/ + +Returns a list of available test suites with links to their resources. +No authentication required. +""" + +from flask import g +from flask.views import MethodView +from flask_smorest import Blueprint + +from ..errors import reject_unknown_params +from ..schemas.common import DiscoveryResponseSchema +from .test_suites import _suite_links + +blp = Blueprint( + 'Discovery', + __name__, + url_prefix='/api/v5', + description='Discover available test suites and their resource URLs', +) + + +@blp.route('/') +class Discovery(MethodView): + """Discover available test suites.""" + + @blp.response(200, DiscoveryResponseSchema) + def get(self): + """List all available test suites with links. + + No authentication required. + """ + reject_unknown_params(set()) + db = getattr(g, 'db', None) + if db is None: + return {'test_suites': []} + + suites = [] + for name in sorted(db.testsuite.keys()): + suites.append({ + 'name': name, + 'links': _suite_links(name), + }) + + return { + 'test_suites': suites, + 'links': { + 'openapi': '/api/v5/openapi/openapi.json', + 'swagger_ui': '/api/v5/openapi/swagger-ui', + 'test_suites': '/api/v5/test-suites', + }, + } diff --git a/lnt/server/api/v5/endpoints/machines.py b/lnt/server/api/v5/endpoints/machines.py new file mode 100644 index 000000000..6af898045 --- /dev/null +++ b/lnt/server/api/v5/endpoints/machines.py @@ -0,0 +1,279 @@ +"""Machine endpoints for the v5 API. + +GET /api/v5/{ts}/machines -- List machines +POST /api/v5/{ts}/machines -- Create machine +GET /api/v5/{ts}/machines/{machine_name} -- Machine detail +PATCH /api/v5/{ts}/machines/{machine_name} -- Update machine +DELETE /api/v5/{ts}/machines/{machine_name} -- Delete machine +GET /api/v5/{ts}/machines/{machine_name}/runs -- List runs for machine +""" + +from flask import g, jsonify, make_response +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy import or_ + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..etag import add_etag_to_response +from ..helpers import ( + dump_response, escape_like, format_utc, lookup_machine, + parse_datetime, +) +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.machines import ( + MachineCreateSchema, + MachineListQuerySchema, + MachineResponseSchema, + MachineRunResponseSchema, + MachineRunsQuerySchema, + MachineUpdateSchema, + PaginatedMachineResponseSchema, + PaginatedMachineRunResponseSchema, +) + +_machine_schema = MachineResponseSchema() +_machine_run_schema = MachineRunResponseSchema() + +blp = Blueprint( + 'Machines', + __name__, + url_prefix='/api/v5/', + description='List, create, update, and delete machines, and list their runs', +) + + +def _split_machine_info(info, ts): + """Split an info dict into (schema_fields, parameters). + + Keys matching schema-defined machine_fields go into schema_fields; + everything else goes into parameters. + """ + field_names = ts._machine_field_names + schema_fields = {} + params = {} + for key, value in info.items(): + if key in field_names: + schema_fields[key] = value + else: + params[key] = value + return schema_fields, params + + +def _serialize_machine(machine, ts): + """Serialize a Machine model instance for the API response.""" + info = {} + for mf in ts.schema.machine_fields: + val = getattr(machine, mf.name, None) + if val is not None: + info[mf.name] = str(val) + params = machine.parameters + if params: + for k, v in params.items(): + info[k] = str(v) + return dump_response(_machine_schema, { + 'name': machine.name, + 'info': info, + }) + + +@blp.route('/machines') +class MachineList(MethodView): + """List and create machines.""" + + @require_scope('read') + @blp.arguments(MachineListQuerySchema, location="query") + @blp.response(200, PaginatedMachineResponseSchema) + def get(self, query_args, testsuite): + """List machines (offset-paginated, filterable).""" + reject_unknown_params({'search', 'limit', 'offset'}) + ts = g.ts + session = g.db_session + + query = session.query(ts.Machine) + + search = query_args.get('search') + if search: + escaped = escape_like(search) + pattern = '%' + escaped + '%' + conditions = [ + ts.Machine.name.ilike(pattern, escape='\\')] + for mf in ts.schema.searchable_machine_fields: + col = getattr(ts.Machine, mf.name) + conditions.append( + col.ilike(pattern, escape='\\')) + query = query.filter(or_(*conditions)) + + query = query.order_by(ts.Machine.name.asc()) + + total = query.count() + + limit = query_args['limit'] + limit = max(1, min(limit, 10000)) + + offset = query_args['offset'] + offset = max(0, offset) + + machines = query.offset(offset).limit(limit).all() + + items = [_serialize_machine(m, ts) for m in machines] + return jsonify(make_paginated_response(items, None, total=total)) + + @require_scope('manage') + @blp.arguments(MachineCreateSchema) + @blp.response(201, MachineResponseSchema) + def post(self, body, testsuite): + """Create a new machine.""" + ts = g.ts + session = g.db_session + + name = body['name'].strip() + + existing = ts.get_machine(session, name=name) + if existing: + abort_with_error( + 409, "A machine named '%s' already exists" % name) + + info = body.get('info') or {} + schema_fields, params = _split_machine_info(info, ts) + + machine = ts.get_or_create_machine( + session, name, + parameters=params if params else None, + **schema_fields) + session.flush() + + resp = jsonify(_serialize_machine(machine, ts)) + resp.status_code = 201 + return resp + + +@blp.route('/machines/') +class MachineDetail(MethodView): + """Machine detail, update, and delete.""" + + @require_scope('read') + @blp.response(200, MachineResponseSchema) + def get(self, testsuite, machine_name): + """Get machine detail by name.""" + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + machine = lookup_machine(session, ts, machine_name) + data = _serialize_machine(machine, ts) + return add_etag_to_response(jsonify(data), data) + + @require_scope('manage') + @blp.arguments(MachineUpdateSchema) + @blp.response(200, MachineResponseSchema) + def patch(self, body, testsuite, machine_name): + """Update machine name and/or info. + + On rename, returns a Location header with the new URL. + """ + ts = g.ts + session = g.db_session + machine = lookup_machine(session, ts, machine_name) + + new_name = body.get('name') + renamed = False + + if new_name is not None: + new_name = new_name.strip() + if new_name != machine.name: + existing = ts.get_machine(session, name=new_name) + if existing: + abort_with_error( + 409, + "A machine named '%s' already exists" % new_name) + ts.update_machine(session, machine, name=new_name) + renamed = True + + new_info = body.get('info') + if new_info is not None and isinstance(new_info, dict): + schema_updates, params = _split_machine_info(new_info, ts) + ts.update_machine(session, machine, + parameters=params, **schema_updates) + + session.flush() + + result = _serialize_machine(machine, ts) + resp = jsonify(result) + + if renamed: + new_url = '/api/v5/%s/machines/%s' % ( + testsuite, machine.name) + resp.headers['Location'] = new_url + + return resp + + @require_scope('manage') + @blp.response(204) + def delete(self, testsuite, machine_name): + """Delete machine and all its associated runs and data.""" + ts = g.ts + session = g.db_session + machine = lookup_machine(session, ts, machine_name) + + # RegressionIndicator.machine_id has no CASCADE, so delete them + # before the machine. The Regression itself remains (it may have + # other indicators on different machines). + session.query(ts.RegressionIndicator).filter( + ts.RegressionIndicator.machine_id == machine.id + ).delete(synchronize_session='fetch') + + ts.delete_machine(session, machine.id) + session.flush() + + return make_response('', 204) + + +@blp.route('/machines//runs') +class MachineRuns(MethodView): + """List runs for a specific machine.""" + + @require_scope('read') + @blp.arguments(MachineRunsQuerySchema, location="query") + @blp.response(200, PaginatedMachineRunResponseSchema) + def get(self, query_args, testsuite, machine_name): + """List runs for a machine (cursor-paginated).""" + reject_unknown_params({'after', 'before', 'sort', 'cursor', 'limit'}) + ts = g.ts + session = g.db_session + machine = lookup_machine(session, ts, machine_name) + + query = session.query(ts.Run).filter( + ts.Run.machine_id == machine.id + ) + + after_str = query_args.get('after') + if after_str: + after_dt = parse_datetime(after_str) + if after_dt is None: + abort_with_error(400, "Invalid 'after' datetime format") + query = query.filter(ts.Run.submitted_at > after_dt) + + before_str = query_args.get('before') + if before_str: + before_dt = parse_datetime(before_str) + if before_dt is None: + abort_with_error(400, "Invalid 'before' datetime format") + query = query.filter(ts.Run.submitted_at < before_dt) + + sort = query_args.get('sort') + descending = (sort == '-submitted_at') + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, ts.Run.id, cursor_str, limit, descending=descending) + + serialized = [dump_response(_machine_run_schema, { + 'uuid': run.uuid, + 'commit': run.commit_obj.commit if run.commit_obj else None, + 'submitted_at': format_utc(run.submitted_at), + }) for run in items] + return jsonify(make_paginated_response(serialized, next_cursor)) diff --git a/lnt/server/api/v5/endpoints/profiles.py b/lnt/server/api/v5/endpoints/profiles.py new file mode 100644 index 000000000..61a9e2e35 --- /dev/null +++ b/lnt/server/api/v5/endpoints/profiles.py @@ -0,0 +1,184 @@ +"""Profile endpoints for the v5 API. + +GET /api/v5/{ts}/runs/{run_uuid}/profiles + -- List profiles for a run +GET /api/v5/{ts}/profiles/{profile_uuid} + -- Profile metadata + top-level counters +GET /api/v5/{ts}/profiles/{profile_uuid}/functions + -- List functions with counters +GET /api/v5/{ts}/profiles/{profile_uuid}/functions/{fn_name} + -- Disassembly + per-instruction counters +""" + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..helpers import dump_response, lookup_profile, lookup_run_by_uuid +from ..schemas.profiles import ( + FunctionDetailSchema, + FunctionListResponseSchema, + ProfileListItemSchema, + ProfileMetadataSchema, +) +from lnt.server.db.v5.profile import ProfileData, ProfileParseError + +blp = Blueprint( + 'Profiles', + __name__, + url_prefix='/api/v5/', + description='Inspect hardware performance counter profiles', +) + +# Pre-instantiated schemas for dump_response validation. +_profile_list_item_schema = ProfileListItemSchema() +_profile_metadata_schema = ProfileMetadataSchema() +_function_list_schema = FunctionListResponseSchema() +_function_detail_schema = FunctionDetailSchema() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _deserialize_profile(profile): + """Deserialize a Profile's binary data. Aborts with 500 on error.""" + try: + return ProfileData.deserialize(profile.data) + except ProfileParseError as e: + abort_with_error( + 500, + "Failed to parse profile data: %s" % str(e)) + + +# --------------------------------------------------------------------------- +# Profile listing (per run) +# --------------------------------------------------------------------------- + +@blp.route('/runs//profiles') +class ProfileList(MethodView): + """List profiles attached to a run.""" + + @require_scope('read') + @blp.response(200, ProfileListItemSchema(many=True)) + def get(self, testsuite, run_uuid): + """List profiles for a run. + + Returns an array of {test, uuid} objects for all profiles + attached to the given run. + """ + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + run = lookup_run_by_uuid(session, ts, run_uuid) + + profiles = ts.get_profiles_for_run(session, run) + items = [ + dump_response(_profile_list_item_schema, + {'test': test_name, 'uuid': uuid}) + for uuid, test_name in profiles + ] + return jsonify(items) + + +# --------------------------------------------------------------------------- +# Profile data (by UUID) +# --------------------------------------------------------------------------- + +@blp.route('/profiles/') +class ProfileMetadata(MethodView): + """Profile metadata and top-level counters.""" + + @require_scope('read') + @blp.response(200, ProfileMetadataSchema) + def get(self, testsuite, profile_uuid): + """Get profile metadata and top-level counters.""" + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + profile = lookup_profile(session, ts, profile_uuid, load_data=True) + p = _deserialize_profile(profile) + + return jsonify(dump_response(_profile_metadata_schema, { + 'uuid': profile.uuid, + 'test': profile.test.name, + 'run_uuid': profile.run.uuid, + 'counters': p.get_top_level_counters(), + 'disassembly_format': p.get_disassembly_format(), + })) + + +@blp.route('/profiles//functions') +class ProfileFunctions(MethodView): + """List functions in a profile with their counters.""" + + @require_scope('read') + @blp.response(200, FunctionListResponseSchema) + def get(self, testsuite, profile_uuid): + """List functions with counters. + + Returns all functions sorted by total counter value descending + (hottest first). + """ + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + profile = lookup_profile(session, ts, profile_uuid, load_data=True) + p = _deserialize_profile(profile) + + functions_dict = p.get_functions() + functions = [] + for fn_name, fn_info in functions_dict.items(): + functions.append({ + 'name': fn_name, + 'counters': fn_info.counters, + 'length': fn_info.length, + }) + + # Sort by total counter value descending (hottest first) + def _total_counters(fn): + return sum(fn['counters'].values()) + functions.sort(key=_total_counters, reverse=True) + + return jsonify(dump_response(_function_list_schema, + {'functions': functions})) + + +@blp.route('/profiles//functions/') +class ProfileFunctionDetail(MethodView): + """Disassembly and per-instruction counters for a function.""" + + @require_scope('read') + @blp.response(200, FunctionDetailSchema) + def get(self, testsuite, profile_uuid, fn_name): + """Get disassembly and per-instruction counters for a function.""" + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + profile = lookup_profile(session, ts, profile_uuid, load_data=True) + p = _deserialize_profile(profile) + + functions_dict = p.get_functions() + if fn_name not in functions_dict: + abort_with_error( + 404, + "Function '%s' not found in profile" % fn_name) + + fn_info = functions_dict[fn_name] + instructions = [ + { + 'address': insn.address, + 'counters': insn.counters, + 'text': insn.text, + } + for insn in p.get_code_for_function(fn_name) + ] + + return jsonify(dump_response(_function_detail_schema, { + 'name': fn_name, + 'counters': fn_info.counters, + 'disassembly_format': p.get_disassembly_format(), + 'instructions': instructions, + })) diff --git a/lnt/server/api/v5/endpoints/query.py b/lnt/server/api/v5/endpoints/query.py new file mode 100644 index 000000000..001f27360 --- /dev/null +++ b/lnt/server/api/v5/endpoints/query.py @@ -0,0 +1,443 @@ +"""Query endpoint for the v5 API. + +POST /api/v5/{ts}/query + Body (JSON): {metric, machine, test, commit, after_commit, before_commit, + after_time, before_time, sort, limit, cursor} + +Returns cursor-paginated data points. The metric field is required; +all other fields are optional. The test field accepts a list of names +for disjunction queries. +""" + +import base64 +import json + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy import and_, or_ + +from ..auth import require_scope +from ..errors import abort_with_error +from ..helpers import ( + dump_response, + format_utc, + lookup_commit, + lookup_machine, + parse_datetime, + validate_metric_name, +) +from ..pagination import make_paginated_response +from ..schemas.query import ( + QueryDataPointSchema, QueryEndpointQuerySchema, QueryResponseSchema, +) + +_query_point_schema = QueryDataPointSchema() + +blp = Blueprint( + 'Query', + __name__, + url_prefix='/api/v5/', + description='Query time-series performance data across machines, tests, and metrics', +) + +_DEFAULT_LIMIT = 100 +_MAX_LIMIT = 10000 + +_ALLOWED_SORT_FIELDS = {'test', 'commit', 'submitted_at'} + + +def _parse_sort(sort_str): + """Parse a comma-separated sort string into (field_name, ascending) pairs. + + Examples: + "test,commit" -> [("test", True), ("commit", True), ("id", True)] + "-submitted_at,test" -> [("submitted_at", False), ("test", True), ("id", True)] + + An internal ``id`` tiebreaker is always appended to guarantee deterministic + cursor pagination. When *sort_str* is empty the result is ``[("id", True)]`` + -- an arbitrary but stable ordering that excludes no data. + + Returns a list of (field_name, ascending) tuples, or None on error. + """ + if not sort_str: + return [('id', True)] + + result = [] + seen = set() + for part in sort_str.split(','): + part = part.strip() + if not part: + continue + if part.startswith('-'): + ascending = False + field_name = part[1:] + else: + ascending = True + field_name = part + if field_name not in _ALLOWED_SORT_FIELDS: + return None + if field_name in seen: + continue + seen.add(field_name) + result.append((field_name, ascending)) + + if not result: + return None + + if 'id' not in seen: + result.append(('id', True)) + + return result + + +def _resolve_sort_column(ts, field_name): + """Map a sort field name to its SQLAlchemy column.""" + if field_name == 'test': + return ts.Test.name + elif field_name == 'commit': + return ts.Commit.ordinal + elif field_name == 'submitted_at': + return ts.Run.submitted_at + elif field_name == 'id': + return ts.Sample.id + raise ValueError("Unknown sort field: %s" % field_name) + + +def _encode_cursor(values): + """Encode a list of cursor values into an opaque string. + + Values are JSON-encoded then base64-wrapped to safely handle + values containing any characters (colons, unicode, etc.). + """ + payload = json.dumps(values, separators=(',', ':')) + return base64.urlsafe_b64encode(payload.encode('utf-8')).decode('ascii') + + +def _decode_cursor(cursor_str, num_fields): + """Decode a cursor string back into a list of values. + + Returns None if the cursor is malformed. + """ + if not cursor_str: + return None + try: + decoded = base64.urlsafe_b64decode( + cursor_str.encode('ascii')).decode('utf-8') + parts = json.loads(decoded) + if not isinstance(parts, list) or len(parts) != num_fields: + return None + return parts + except (ValueError, TypeError, UnicodeDecodeError, json.JSONDecodeError): + return None + + +def _build_cursor_filter(ts, sort_spec, cursor_values): + """Build a SQLAlchemy filter expression for cursor-based pagination. + + For mixed ASC/DESC sort orders, expands to an OR chain: + (col1 > v1) + OR (col1 = v1 AND col2 < v2) -- if col2 is DESC + OR (col1 = v1 AND col2 = v2 AND col3 > v3) + ... + """ + conditions = [] + for i in range(len(sort_spec)): + field_name, ascending = sort_spec[i] + col = _resolve_sort_column(ts, field_name) + cursor_val = _coerce_cursor_value(field_name, cursor_values[i]) + + # All prefix columns must be equal + prefix_conditions = [] + for j in range(i): + pf_name, _ = sort_spec[j] + pf_col = _resolve_sort_column(ts, pf_name) + pf_val = _coerce_cursor_value(pf_name, cursor_values[j]) + prefix_conditions.append(pf_col == pf_val) + + # The i-th column uses > (ASC) or < (DESC) + if ascending: + cmp = col > cursor_val + else: + cmp = col < cursor_val + + if prefix_conditions: + conditions.append(and_(*prefix_conditions, cmp)) + else: + conditions.append(cmp) + + return or_(*conditions) + + +def _coerce_cursor_value(field_name, value): + """Coerce a cursor value to the appropriate Python type. + + With JSON-encoded cursors, values are already the right type + in most cases. This handles edge cases and type enforcement. + Raises ValueError if the value cannot be coerced. + """ + if field_name == 'commit': + return int(value) + elif field_name == 'test': + return str(value) if value is not None else '' + elif field_name == 'submitted_at': + return value # None or string, both valid + elif field_name == 'id': + return int(value) + return value + + +def _extract_cursor_values(sort_spec, row_data): + """Extract cursor values from a result row for encoding. + + row_data is a dict with keys: test_name, ordinal, submitted_at, sample_id. + """ + values = [] + for field_name, _ in sort_spec: + if field_name == 'test': + values.append(row_data['test_name']) + elif field_name == 'commit': + values.append(row_data['ordinal']) + elif field_name == 'submitted_at': + values.append(row_data['submitted_at']) + elif field_name == 'id': + values.append(row_data['sample_id']) + return values + + +def _build_query(session, ts, metric_col, metric_name, machine, test_ids, + sort_spec, cursor_values, commit, after_commit, + before_commit, after_time, before_time, limit): + """Build and execute the time-series query. + + Returns (items, has_next, last_row_data) where items is a list of + serialized dicts, has_next indicates more results, and last_row_data + is a dict of raw values from the last row (for cursor construction). + """ + q = ( + session.query( + metric_col.label('metric_value'), + ts.Commit.commit, + ts.Commit.ordinal, + ts.Commit.tag, + ts.Run.uuid, + ts.Run.submitted_at, + ts.Test.name.label('test_name'), + ts.Machine.name.label('machine_name'), + ts.Sample.id.label('sample_id'), + ) + .select_from(ts.Sample) + .join(ts.Run, ts.Sample.run_id == ts.Run.id) + .join(ts.Commit, ts.Run.commit_id == ts.Commit.id) + .join(ts.Test, ts.Sample.test_id == ts.Test.id) + .join(ts.Machine, ts.Run.machine_id == ts.Machine.id) + .filter(metric_col.isnot(None)) + ) + + if machine is not None: + q = q.filter(ts.Run.machine_id == machine.id) + if test_ids is not None: + if len(test_ids) == 1: + q = q.filter(ts.Sample.test_id == test_ids[0]) + else: + q = q.filter(ts.Sample.test_id.in_(test_ids)) + + # Exact commit filter — by commit identity, not ordinal. + if commit is not None: + q = q.filter(ts.Run.commit_id == commit.id) + + # Commit range filters — by ordinal. + if after_commit is not None: + q = q.filter(ts.Commit.ordinal > after_commit.ordinal) + if before_commit is not None: + q = q.filter(ts.Commit.ordinal < before_commit.ordinal) + + # When sorting by commit (ordinal), exclude NULL ordinals. + sort_fields = {fn for fn, _ in sort_spec} + if 'commit' in sort_fields: + q = q.filter(ts.Commit.ordinal.isnot(None)) + + # Time range filters. + if after_time is not None: + q = q.filter(ts.Run.submitted_at > after_time) + if before_time is not None: + q = q.filter(ts.Run.submitted_at < before_time) + + # Cursor filter. + if cursor_values is not None: + try: + q = q.filter(_build_cursor_filter(ts, sort_spec, cursor_values)) + except (ValueError, TypeError): + abort_with_error(400, "Invalid pagination cursor") + + # Ordering. + for field_name, ascending in sort_spec: + col = _resolve_sort_column(ts, field_name) + q = q.order_by(col.asc() if ascending else col.desc()) + + # Fetch limit + 1 to detect next page. + rows = q.limit(limit + 1).all() + + has_next = len(rows) > limit + rows = rows[:limit] + + items = [] + for row in rows: + items.append(dump_response(_query_point_schema, { + 'test': row.test_name, + 'machine': row.machine_name, + 'metric': metric_name, + 'value': row.metric_value, + 'commit': row.commit, + 'ordinal': row.ordinal, + 'tag': row.tag, + 'run_uuid': row.uuid, + 'submitted_at': format_utc(row.submitted_at), + })) + + last_row_data = None + if rows: + r = rows[-1] + last_row_data = { + 'test_name': r.test_name, + 'ordinal': r.ordinal, + 'submitted_at': r.submitted_at, + 'sample_id': r.sample_id, + } + + return items, has_next, last_row_data + + +@blp.route('/query') +class QueryView(MethodView): + """Query data points.""" + + @require_scope('read') + @blp.arguments(QueryEndpointQuerySchema, location="json") + @blp.response(200, QueryResponseSchema) + def post(self, query_args, testsuite): + """Query data points. + + Returns cursor-paginated data points. The metric field is + required; all other fields are optional -- omit any to get + data across all values of that dimension. + """ + ts = g.ts + session = g.db_session + + # ------------------------------------------------------------------ + # Parse filter parameters + # ------------------------------------------------------------------ + machine_name = query_args.get('machine') + test_names = query_args.get('test') + field_name = query_args['metric'] + + machine = None + if machine_name: + machine = lookup_machine(session, ts, machine_name) + + # Silently skip unknown test names — return no data for them + # rather than 404-ing the entire request. + test_ids = None + if test_names: + test_ids = [] + for tn in test_names: + test = ts.get_test(session, name=tn) + if test is not None: + test_ids.append(test.id) + if not test_ids: + return jsonify(make_paginated_response([], None)) + + validate_metric_name(ts, field_name) + metric_col = getattr(ts.Sample, field_name) + + # ------------------------------------------------------------------ + # Parse sort parameter + # ------------------------------------------------------------------ + sort_str = query_args.get('sort') + sort_spec = _parse_sort(sort_str) + if sort_spec is None: + abort_with_error( + 400, "Invalid sort parameter. Allowed fields: %s. " + "Use - prefix for descending." + % ', '.join(sorted(_ALLOWED_SORT_FIELDS))) + + # ------------------------------------------------------------------ + # Parse range filters + # ------------------------------------------------------------------ + commit_str = query_args.get('commit') + after_commit_str = query_args.get('after_commit') + before_commit_str = query_args.get('before_commit') + after_time_str = query_args.get('after_time') + before_time_str = query_args.get('before_time') + + if commit_str and (after_commit_str or before_commit_str): + abort_with_error( + 400, + "The 'commit' parameter cannot be combined with " + "'after_commit' or 'before_commit'") + + commit = None + if commit_str: + commit = lookup_commit(session, ts, commit_str) + + after_commit = None + if after_commit_str: + after_commit = lookup_commit(session, ts, after_commit_str) + if after_commit.ordinal is None: + abort_with_error( + 400, "Commit '%s' has no ordinal; cannot use as " + "range boundary" % after_commit_str) + + before_commit = None + if before_commit_str: + before_commit = lookup_commit(session, ts, before_commit_str) + if before_commit.ordinal is None: + abort_with_error( + 400, "Commit '%s' has no ordinal; cannot use as " + "range boundary" % before_commit_str) + + after_time = None + if after_time_str: + after_time = parse_datetime(after_time_str) + if after_time is None: + abort_with_error( + 400, "Invalid after_time format, expected ISO 8601") + + before_time = None + if before_time_str: + before_time = parse_datetime(before_time_str) + if before_time is None: + abort_with_error( + 400, "Invalid before_time format, expected ISO 8601") + + # ------------------------------------------------------------------ + # Parse pagination parameters + # ------------------------------------------------------------------ + limit = query_args['limit'] + limit = max(1, min(limit, _MAX_LIMIT)) + + cursor_str = query_args.get('cursor') + cursor_values = None + if cursor_str: + cursor_values = _decode_cursor(cursor_str, len(sort_spec)) + if cursor_values is None: + abort_with_error(400, "Invalid pagination cursor") + + # ------------------------------------------------------------------ + # Execute query + # ------------------------------------------------------------------ + items, has_next, last_row_data = _build_query( + session, ts, metric_col, field_name, machine, test_ids, + sort_spec, cursor_values, commit, after_commit, before_commit, + after_time, before_time, limit) + + # ------------------------------------------------------------------ + # Build cursor and response + # ------------------------------------------------------------------ + next_cursor = None + if has_next and last_row_data: + cursor_vals = _extract_cursor_values(sort_spec, last_row_data) + next_cursor = _encode_cursor(cursor_vals) + + return jsonify(make_paginated_response(items, next_cursor)) diff --git a/lnt/server/api/v5/endpoints/regressions.py b/lnt/server/api/v5/endpoints/regressions.py new file mode 100644 index 000000000..4fcc0a834 --- /dev/null +++ b/lnt/server/api/v5/endpoints/regressions.py @@ -0,0 +1,425 @@ +"""Regression endpoints for the v5 API. + +GET /api/v5/{ts}/regressions -- List +POST /api/v5/{ts}/regressions -- Create +GET /api/v5/{ts}/regressions/{uuid} -- Detail +PATCH /api/v5/{ts}/regressions/{uuid} -- Update +DELETE /api/v5/{ts}/regressions/{uuid} -- Delete +POST /api/v5/{ts}/regressions/{uuid}/indicators -- Add indicators (batch) +DELETE /api/v5/{ts}/regressions/{uuid}/indicators -- Remove indicators (batch) +""" + +from flask import g, jsonify, make_response +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy.orm import joinedload, subqueryload + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..helpers import ( + dump_response, + lookup_commit, + lookup_machine, + lookup_regression, + lookup_test, + validate_metric_name, +) +from ..etag import add_etag_to_response +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.regressions import ( + IndicatorAddSchema, + IndicatorRemoveSchema, + IndicatorResponseSchema, + PaginatedRegressionListSchema, + RegressionCreateSchema, + RegressionDetailSchema, + RegressionListItemSchema, + RegressionListQuerySchema, + RegressionUpdateSchema, + STATE_TO_DB, + state_to_api, + state_to_db, +) + +_indicator_schema = IndicatorResponseSchema() +_regression_list_schema = RegressionListItemSchema() +_regression_detail_schema = RegressionDetailSchema() + +blp = Blueprint( + 'Regressions', + __name__, + url_prefix='/api/v5/', + description='Triage performance regressions: create, update, delete, and manage indicators', +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _serialize_indicator(ri): + """Serialize a RegressionIndicator into the API response dict.""" + return dump_response(_indicator_schema, { + 'uuid': ri.uuid, + 'machine': ri.machine.name if ri.machine else None, + 'test': ri.test.name if ri.test else None, + 'metric': ri.metric, + }) + + +def _serialize_regression_base(regression): + """Shared fields for both list and detail regression responses.""" + return { + 'uuid': regression.uuid, + 'title': regression.title, + 'bug': regression.bug, + 'state': state_to_api(regression.state), + 'commit': (regression.commit_obj.commit + if regression.commit_obj else None), + } + + +def _serialize_regression_list(regression): + """Serialize a Regression for the list endpoint. + + Requires the regression to have indicators eagerly loaded (or + accessible) for computing machine_count and test_count. + """ + machines = set() + tests = set() + for ri in regression.indicators: + machines.add(ri.machine_id) + tests.add(ri.test_id) + + result = _serialize_regression_base(regression) + result['machine_count'] = len(machines) + result['test_count'] = len(tests) + return dump_response(_regression_list_schema, result) + + +def _serialize_regression_detail(regression): + """Serialize a Regression for the detail endpoint (with indicators).""" + result = _serialize_regression_base(regression) + result['notes'] = regression.notes + result['indicators'] = [ + _serialize_indicator(ri) for ri in regression.indicators + ] + return dump_response(_regression_detail_schema, result) + + +def _validate_state(state_str): + """Validate and convert a state string to its DB integer. + + Aborts with 400 if invalid. + """ + db_state = state_to_db(state_str) + if db_state is None: + abort_with_error( + 400, + "Invalid state '%s'. Valid states: %s" + % (state_str, ', '.join(sorted(STATE_TO_DB.keys())))) + return db_state + + +def _resolve_indicators(session, ts, indicator_dicts): + """Resolve indicator input dicts to DB-ready dicts. + + Each input dict has {machine, test, metric} (names). This function + looks up each entity and returns a list of dicts with + {machine_id, test_id, metric}. + + Aborts with 404 if any machine or test is not found, 400 if metric is + unknown. + """ + resolved = [] + machine_cache = {} + test_cache = {} + for ind in indicator_dicts: + m_name = ind['machine'] + if m_name not in machine_cache: + machine_cache[m_name] = lookup_machine(session, ts, m_name) + t_name = ind['test'] + if t_name not in test_cache: + test_cache[t_name] = lookup_test(session, ts, t_name) + validate_metric_name(ts, ind['metric']) + resolved.append({ + 'machine_id': machine_cache[m_name].id, + 'test_id': test_cache[t_name].id, + 'metric': ind['metric'], + }) + return resolved + + +def _eager_load_regression(session, ts, regression_uuid): + """Look up a regression by UUID with eager-loaded relationships. + + Loads indicators with their machine and test relationships for + serialization. Aborts with 404 if not found. + """ + reg = ( + session.query(ts.Regression) + .populate_existing() + .options( + joinedload(ts.Regression.commit_obj), + subqueryload(ts.Regression.indicators) + .joinedload(ts.RegressionIndicator.machine), + subqueryload(ts.Regression.indicators) + .joinedload(ts.RegressionIndicator.test), + ) + .filter(ts.Regression.uuid == regression_uuid) + .first() + ) + if reg is None: + abort_with_error(404, "Regression '%s' not found" % regression_uuid) + return reg + + +# --------------------------------------------------------------------------- +# Regression List / Create +# --------------------------------------------------------------------------- + +@blp.route('/regressions') +class RegressionList(MethodView): + """List and create regressions.""" + + @require_scope('read') + @blp.arguments(RegressionListQuerySchema, location="query") + @blp.response(200, PaginatedRegressionListSchema) + def get(self, query_args, testsuite): + """List regressions (cursor-paginated, filterable).""" + reject_unknown_params( + {'state', 'machine', 'test', 'metric', 'commit', + 'has_commit', 'cursor', 'limit'}) + ts = g.ts + session = g.db_session + + query = session.query(ts.Regression).options( + joinedload(ts.Regression.commit_obj), + subqueryload(ts.Regression.indicators), + ) + + # -- State filter -- + state_values = query_args['state'] + if state_values: + db_states = [_validate_state(sv) for sv in state_values] + query = query.filter(ts.Regression.state.in_(db_states)) + + # -- Commit filters -- + commit_value = query_args.get('commit') + if commit_value: + commit_obj = lookup_commit(session, ts, commit_value) + query = query.filter( + ts.Regression.commit_id == commit_obj.id) + + has_commit = query_args.get('has_commit') + if has_commit is True: + query = query.filter( + ts.Regression.commit_id.isnot(None)) + elif has_commit is False: + query = query.filter( + ts.Regression.commit_id.is_(None)) + + # -- Machine / test / metric filters (via indicator JOIN) -- + machine_name = query_args.get('machine') + test_name = query_args.get('test') + metric_name = query_args.get('metric') + + machine = None + if machine_name: + machine = lookup_machine(session, ts, machine_name) + test = None + if test_name: + test = lookup_test(session, ts, test_name) + if metric_name: + validate_metric_name(ts, metric_name) + + if machine or test or metric_name: + query = query.join( + ts.RegressionIndicator, + ts.RegressionIndicator.regression_id == ts.Regression.id + ) + + if machine: + query = query.filter( + ts.RegressionIndicator.machine_id == machine.id) + if test: + query = query.filter( + ts.RegressionIndicator.test_id == test.id) + if metric_name: + query = query.filter( + ts.RegressionIndicator.metric == metric_name) + + if machine or test or metric_name: + query = query.distinct() + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, ts.Regression.id, cursor_str, limit) + + serialized = [_serialize_regression_list(r) for r in items] + return jsonify(make_paginated_response(serialized, next_cursor)) + + @require_scope('triage') + @blp.arguments(RegressionCreateSchema) + @blp.response(201, RegressionDetailSchema) + def post(self, body, testsuite): + """Create a new regression.""" + ts = g.ts + session = g.db_session + + state_str = body.get('state') or 'detected' + db_state = _validate_state(state_str) + + # Resolve commit by value (optional) + commit_obj = None + commit_value = body.get('commit') + if commit_value: + commit_obj = lookup_commit(session, ts, commit_value) + + # Resolve indicators (optional) + indicator_dicts = body.get('indicators') or [] + resolved = _resolve_indicators(session, ts, indicator_dicts) + + title = body.get('title') or None + bug = body.get('bug') + notes = body.get('notes') + + regression = ts.create_regression( + session, title, resolved, + bug=bug, notes=notes, commit=commit_obj, state=db_state) + + # Reload with eager-loaded relationships for serialization + regression = _eager_load_regression( + session, ts, regression.uuid) + + result = _serialize_regression_detail(regression) + resp = jsonify(result) + resp.status_code = 201 + return resp + + +# --------------------------------------------------------------------------- +# Regression Detail / Update / Delete +# --------------------------------------------------------------------------- + +@blp.route('/regressions/') +class RegressionDetail(MethodView): + """Regression detail, update, and delete.""" + + @require_scope('read') + @blp.response(200, RegressionDetailSchema) + def get(self, testsuite, regression_uuid): + """Get regression detail with embedded indicators.""" + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + regression = _eager_load_regression(session, ts, regression_uuid) + data = _serialize_regression_detail(regression) + return add_etag_to_response(jsonify(data), data) + + @require_scope('triage') + @blp.arguments(RegressionUpdateSchema) + @blp.response(200, RegressionDetailSchema) + def patch(self, body, testsuite, regression_uuid): + """Update regression title, bug, notes, state, and/or commit.""" + ts = g.ts + session = g.db_session + regression = lookup_regression(session, ts, regression_uuid) + + # Fields present in body are updated; None values clear the field. + # Fields absent from body are left unchanged. + kwargs = {} + + if 'title' in body: + kwargs['title'] = body['title'] + + if 'bug' in body: + kwargs['bug'] = body['bug'] + + if 'notes' in body: + kwargs['notes'] = body['notes'] + + if 'state' in body: + kwargs['state'] = _validate_state(body['state']) + + if 'commit' in body: + commit_value = body['commit'] + if commit_value is None: + kwargs['commit'] = None # clear + else: + kwargs['commit'] = lookup_commit(session, ts, commit_value) + + ts.update_regression(session, regression, **kwargs) + + # Reload for serialization (relationships may have changed) + regression = _eager_load_regression(session, ts, regression_uuid) + return jsonify(_serialize_regression_detail(regression)) + + @require_scope('triage') + @blp.response(204) + def delete(self, testsuite, regression_uuid): + """Delete a regression and its indicators.""" + ts = g.ts + session = g.db_session + regression = lookup_regression(session, ts, regression_uuid) + ts.delete_regression(session, regression.id) + return make_response('', 204) + + +# --------------------------------------------------------------------------- +# Indicators +# --------------------------------------------------------------------------- + +@blp.route('/regressions//indicators') +class RegressionIndicators(MethodView): + """Add and remove indicators for a regression (batch operations).""" + + @require_scope('triage') + @blp.arguments(IndicatorAddSchema) + @blp.response(200, RegressionDetailSchema) + def post(self, body, testsuite, regression_uuid): + """Add indicators to a regression (batch). + + Duplicates (same regression+machine+test+metric) are silently + ignored. + """ + ts = g.ts + session = g.db_session + regression = lookup_regression(session, ts, regression_uuid) + + indicator_dicts = body['indicators'] + resolved = _resolve_indicators(session, ts, indicator_dicts) + + ts.add_regression_indicators_batch(session, regression, resolved) + + # Reload and return full detail + regression = _eager_load_regression( + session, ts, regression_uuid) + return jsonify(_serialize_regression_detail(regression)) + + @require_scope('triage') + @blp.arguments(IndicatorRemoveSchema) + @blp.response(200, RegressionDetailSchema) + def delete(self, body, testsuite, regression_uuid): + """Remove indicators from a regression (batch, by UUID). + + Unknown UUIDs are silently ignored. + """ + ts = g.ts + session = g.db_session + regression = lookup_regression(session, ts, regression_uuid) + + session.query(ts.RegressionIndicator).filter( + ts.RegressionIndicator.regression_id == regression.id, + ts.RegressionIndicator.uuid.in_(body['indicator_uuids']), + ).delete(synchronize_session='fetch') + session.flush() + + # Reload and return full detail + regression = _eager_load_regression( + session, ts, regression_uuid) + return jsonify(_serialize_regression_detail(regression)) diff --git a/lnt/server/api/v5/endpoints/runs.py b/lnt/server/api/v5/endpoints/runs.py new file mode 100644 index 000000000..19896c251 --- /dev/null +++ b/lnt/server/api/v5/endpoints/runs.py @@ -0,0 +1,226 @@ +"""Run endpoints for the v5 API. + +GET /api/v5/{ts}/runs -- List runs (cursor-paginated) +POST /api/v5/{ts}/runs -- Submit run (v5 format) +GET /api/v5/{ts}/runs/{uuid} -- Run detail +DELETE /api/v5/{ts}/runs/{uuid} -- Delete run +""" + +from flask import g, jsonify, make_response +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy.exc import IntegrityError +from sqlalchemy.orm import joinedload + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..etag import add_etag_to_response +from ..helpers import dump_response, lookup_run_by_uuid, parse_datetime, serialize_run +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.runs import ( + PaginatedRunResponseSchema, + RunListQuerySchema, + RunResponseSchema, + RunSubmitBodySchema, + RunSubmitQuerySchema, + RunSubmitResponseSchema, +) + +_run_submit_schema = RunSubmitResponseSchema() + +blp = Blueprint( + 'Runs', + __name__, + url_prefix='/api/v5/', + description='Submit, list, inspect, and delete test runs', +) + + +@blp.route('/runs') +class RunList(MethodView): + """List runs and submit new runs.""" + + @require_scope('read') + @blp.arguments(RunListQuerySchema, location="query") + @blp.response(200, PaginatedRunResponseSchema) + def get(self, query_args, testsuite): + """List runs (cursor-paginated, filterable).""" + reject_unknown_params( + {'machine', 'commit', 'after', 'before', 'sort', + 'cursor', 'limit', 'has_profiles'}) + ts = g.ts + session = g.db_session + + query = session.query(ts.Run).options( + joinedload(ts.Run.machine), + joinedload(ts.Run.commit_obj), + ) + + # Filter by machine name + machine_name = query_args.get('machine') + if machine_name: + machine = ts.get_machine(session, name=machine_name) + if machine is None: + return jsonify(make_paginated_response([], None)) + query = query.filter(ts.Run.machine_id == machine.id) + + # Filter by commit string + commit_value = query_args.get('commit') + if commit_value: + commit_obj = ts.get_commit(session, commit=commit_value) + if commit_obj is None: + return jsonify(make_paginated_response([], None)) + query = query.filter(ts.Run.commit_id == commit_obj.id) + + # Filter by after/before datetime + after_str = query_args.get('after') + if after_str: + after_dt = parse_datetime(after_str) + if after_dt is None: + abort_with_error(400, "Invalid 'after' datetime format") + query = query.filter(ts.Run.submitted_at > after_dt) + + before_str = query_args.get('before') + if before_str: + before_dt = parse_datetime(before_str) + if before_dt is None: + abort_with_error(400, "Invalid 'before' datetime format") + query = query.filter(ts.Run.submitted_at < before_dt) + + # Filter by profile existence. + has_profiles = query_args.get('has_profiles') + if has_profiles is True: + query = query.filter( + session.query(ts.Profile.id) + .filter(ts.Profile.run_id == ts.Run.id) + .exists() + ) + elif has_profiles is False: + query = query.filter( + ~session.query(ts.Profile.id) + .filter(ts.Profile.run_id == ts.Run.id) + .exists() + ) + + # Sort: default is ascending by ID (insertion order). + sort = query_args.get('sort') + descending = (sort == '-submitted_at') + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, ts.Run.id, cursor_str, limit, descending=descending) + + serialized = [serialize_run(run, ts) for run in items] + return jsonify(make_paginated_response(serialized, next_cursor)) + + @require_scope('submit') + @blp.arguments(RunSubmitQuerySchema, location="query") + @blp.arguments(RunSubmitBodySchema) + @blp.response(201, RunSubmitResponseSchema) + def post(self, query_args, body, testsuite): + """Submit a new run. + + Accepts the v5 JSON report format (format_version '5'). + Regression detection is external; create regressions + separately via POST /regressions. + """ + reject_unknown_params({'on_machine_conflict'}) + ts = g.ts + session = g.db_session + + version = body.get('format_version') + if version is None: + abort_with_error(400, "v5 API requires format_version '5', " + "but it is missing") + if version != '5': + abort_with_error(400, "v5 API requires format_version '5', " + "got %r" % (version,)) + + # Normalize client-provided UUID to lowercase (UUIDs are + # case-insensitive per RFC 9562; we store canonical lowercase). + client_uuid = body.get('uuid') + if client_uuid is not None: + body['uuid'] = client_uuid.lower() + + # When a client UUID is provided, wrap import_run in a savepoint + # so that an IntegrityError from the UUID unique constraint does + # not invalidate prior work in the transaction. When no client + # UUID is present, skip the savepoint to avoid the overhead. + if client_uuid is not None: + try: + with session.begin_nested(): + run = ts.import_run( + session, body, + machine_strategy=query_args['on_machine_conflict']) + except IntegrityError: + abort_with_error( + 409, + "A run with UUID '%s' already exists" % body['uuid']) + except ValueError as exc: + abort_with_error(400, str(exc)) + else: + try: + run = ts.import_run( + session, body, + machine_strategy=query_args['on_machine_conflict']) + except ValueError as exc: + abort_with_error(400, str(exc)) + + session.flush() + + run_uuid = run.uuid + result_url = '/api/v5/%s/runs/%s' % (testsuite, run_uuid) + + response = jsonify(dump_response(_run_submit_schema, { + 'success': True, + 'run_uuid': run_uuid, + 'result_url': result_url, + })) + response.status_code = 201 + response.headers['Location'] = result_url + return response + + +@blp.route('/runs/') +class RunDetail(MethodView): + """Run detail and deletion.""" + + @require_scope('read') + @blp.response(200, RunResponseSchema) + def get(self, testsuite, run_uuid): + """Get run detail by UUID.""" + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + + # Normalize UUID to lowercase (centralized in get_run() for + # standard lookups; this inline query needs explicit normalization + # because it bypasses get_run() for joinedload support). + run = session.query(ts.Run).options( + joinedload(ts.Run.machine), + joinedload(ts.Run.commit_obj), + ).filter(ts.Run.uuid == run_uuid.lower()).first() + + if run is None: + abort_with_error(404, "Run '%s' not found" % run_uuid) + + data = serialize_run(run, ts) + return add_etag_to_response(jsonify(data), data) + + @require_scope('manage') + @blp.response(204) + def delete(self, testsuite, run_uuid): + """Delete a run and all associated samples.""" + ts = g.ts + session = g.db_session + + run = lookup_run_by_uuid(session, ts, run_uuid) + + ts.delete_run(session, run.id) + session.flush() + + return make_response('', 204) diff --git a/lnt/server/api/v5/endpoints/samples.py b/lnt/server/api/v5/endpoints/samples.py new file mode 100644 index 000000000..1eae1b068 --- /dev/null +++ b/lnt/server/api/v5/endpoints/samples.py @@ -0,0 +1,111 @@ +"""Sample endpoints for the v5 API. + +GET /api/v5/{ts}/runs/{uuid}/samples -- All samples for a run +GET /api/v5/{ts}/runs/{uuid}/tests/{test_name}/samples -- Samples for a test +""" + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint +from sqlalchemy.orm import joinedload + +from ..auth import require_scope +from ..errors import reject_unknown_params +from ..helpers import dump_response, lookup_run_by_uuid, lookup_test +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.samples import ( + PaginatedSampleResponseSchema, + RunSamplesQuerySchema, + SampleListResponseSchema, + SampleResponseSchema, +) + +_sample_schema = SampleResponseSchema() + +blp = Blueprint( + 'Samples', + __name__, + url_prefix='/api/v5/', + description='List metric measurements collected for each test in a run', +) + + +def _serialize_sample(sample, ts): + """Serialize a Sample model instance for the API response. + + Returns a dict with test name and a metrics dict containing all + non-null metric values. + """ + metrics = {} + for metric in ts.schema.metrics: + value = getattr(sample, metric.name, None) + if value is not None: + metrics[metric.name] = value + + return dump_response(_sample_schema, { + 'test': sample.test.name, + 'metrics': metrics, + }) + + +@blp.route('/runs//samples') +class RunSamples(MethodView): + """List all samples for a run.""" + + @require_scope('read') + @blp.arguments(RunSamplesQuerySchema, location="query") + @blp.response(200, PaginatedSampleResponseSchema) + def get(self, query_args, testsuite, run_uuid): + """List samples for a run (cursor-paginated).""" + reject_unknown_params({'cursor', 'limit'}) + ts = g.ts + session = g.db_session + run = lookup_run_by_uuid(session, ts, run_uuid) + + query = session.query(ts.Sample).options( + joinedload(ts.Sample.test) + ).filter( + ts.Sample.run_id == run.id + ) + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, ts.Sample.id, cursor_str, limit) + + serialized = [_serialize_sample(s, ts) for s in items] + return jsonify(make_paginated_response(serialized, next_cursor)) + + +@blp.route('/runs//tests//samples') +class RunTestSamples(MethodView): + """List samples for a specific test in a run.""" + + @require_scope('read') + @blp.response(200, SampleListResponseSchema) + def get(self, testsuite, run_uuid, test_name): + """Get samples for a specific test in a run. + + Returns a list because a run may have multiple samples for the + same test. + """ + reject_unknown_params(set()) + ts = g.ts + session = g.db_session + run = lookup_run_by_uuid(session, ts, run_uuid) + + # Look up the test by name + test = lookup_test(session, ts, test_name) + + samples = session.query(ts.Sample).options( + joinedload(ts.Sample.test) + ).filter( + ts.Sample.run_id == run.id, + ts.Sample.test_id == test.id, + ).all() + + serialized = [_serialize_sample(s, ts) for s in samples] + return jsonify({'items': serialized}) diff --git a/lnt/server/api/v5/endpoints/test_suites.py b/lnt/server/api/v5/endpoints/test_suites.py new file mode 100644 index 000000000..a91f7126a --- /dev/null +++ b/lnt/server/api/v5/endpoints/test_suites.py @@ -0,0 +1,168 @@ +"""Test suite management endpoints for the v5 API. + +GET /api/v5/test-suites -- List all test suites +POST /api/v5/test-suites -- Create a test suite +GET /api/v5/test-suites/ -- Get suite details +DELETE /api/v5/test-suites/ -- Delete a test suite +""" + +from flask import after_this_request, g +from flask.views import MethodView +from flask_smorest import Blueprint + +from lnt.server.db.v5 import V5DB +from lnt.server.db.v5.schema import SchemaError, parse_schema + +from ..auth import require_scope +from ..errors import abort_with_error, reject_unknown_params +from ..helpers import dump_response +from ..schemas.test_suites import ( + TestSuiteCreateQuerySchema, + TestSuiteCreateRequestSchema, + TestSuiteDeleteQuerySchema, + TestSuiteDetailQuerySchema, + TestSuiteDetailResponseSchema, + TestSuiteListQuerySchema, + TestSuiteListResponseSchema, +) + +_suite_detail_schema = TestSuiteDetailResponseSchema() + +blp = Blueprint( + 'Test Suites', + __name__, + url_prefix='/api/v5/test-suites', + description='List, create, and delete test suites and their field definitions', +) + + +def _suite_links(name): + """Build the standard per-suite links dict.""" + prefix = '/api/v5/%s' % name + return { + 'machines': prefix + '/machines', + 'commits': prefix + '/commits', + 'runs': prefix + '/runs', + 'tests': prefix + '/tests', + 'regressions': prefix + '/regressions', + 'query': prefix + '/query', + } + + +def _suite_detail(db, name): + """Build a detail dict for a suite.""" + tsdb = db.testsuite[name] + return dump_response(_suite_detail_schema, { + 'name': name, + 'schema': V5DB._schema_to_dict(tsdb.schema), + 'links': _suite_links(name), + }) + + +@blp.route('/') +class TestSuiteCollection(MethodView): + """List and create test suites.""" + + @blp.arguments(TestSuiteListQuerySchema, location='query') + @blp.response(200, TestSuiteListResponseSchema) + def get(self, query_args): + """List all test suites.""" + reject_unknown_params(set()) + db = getattr(g, 'db', None) + if db is None: + return {'items': []} + + items = [] + for name in sorted(db.testsuite.keys()): + items.append(_suite_detail(db, name)) + return {'items': items} + + @require_scope('manage') + @blp.arguments(TestSuiteCreateQuerySchema, location='query') + @blp.arguments(TestSuiteCreateRequestSchema) + @blp.response(201, TestSuiteDetailResponseSchema) + def post(self, query_args, payload): + """Create a new test suite.""" + reject_unknown_params(set()) + db = g.db + session = g.db_session + name = payload['name'] + + # Check the in-memory cache first. + if name in db.testsuite: + abort_with_error(409, "Test suite '%s' already exists" % name) + + # Parse the payload into a v5 TestSuiteSchema. + try: + schema = parse_schema(payload) + except SchemaError as exc: + abort_with_error(400, str(exc)) + + # Create the suite (tables, schema row, version bump). + try: + db.create_suite(session, schema) + session.commit() + except ValueError as exc: + session.rollback() + abort_with_error(409, str(exc)) + except Exception as exc: + session.rollback() + abort_with_error(400, + "Failed to create test suite '%s': %s" + % (name, exc)) + + @after_this_request + def add_location_header(response): + response.headers['Location'] = '/api/v5/test-suites/%s' % name + return response + + return _suite_detail(db, name) + + +@blp.route('/') +class TestSuiteDetail(MethodView): + """Get or delete a test suite.""" + + @blp.arguments(TestSuiteDetailQuerySchema, location='query') + @blp.response(200, TestSuiteDetailResponseSchema) + def get(self, query_args, suite_name): + """Get a test suite's field definitions and resource links.""" + reject_unknown_params(set()) + db = getattr(g, 'db', None) + if db is None or suite_name not in db.testsuite: + abort_with_error(404, "Test suite '%s' not found" % suite_name) + return _suite_detail(db, suite_name) + + @require_scope('manage') + @blp.arguments(TestSuiteDeleteQuerySchema, location='query') + @blp.response(204) + def delete(self, query_args, suite_name): + """Delete a test suite and all its data (irreversible). + + Requires ?confirm=true to proceed. + """ + reject_unknown_params({'confirm'}) + + confirm = query_args.get('confirm') + if confirm != 'true': + abort_with_error( + 400, + "Deleting a test suite drops all its tables and data. " + "Pass ?confirm=true to proceed.") + + db = g.db + session = g.db_session + + if suite_name not in db.testsuite: + abort_with_error(404, "Test suite '%s' not found" % suite_name) + + try: + db.delete_suite(session, suite_name) + session.commit() + except Exception as exc: + session.rollback() + abort_with_error( + 500, + "Failed to delete test suite '%s': %s" % (suite_name, exc)) + + return '', 204 diff --git a/lnt/server/api/v5/endpoints/tests.py b/lnt/server/api/v5/endpoints/tests.py new file mode 100644 index 000000000..89a93981f --- /dev/null +++ b/lnt/server/api/v5/endpoints/tests.py @@ -0,0 +1,88 @@ +"""Test entity endpoints for the v5 API. + +GET /api/v5/{ts}/tests -- List tests (cursor-paginated, filterable) +""" + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint + +from ..auth import require_scope +from ..errors import reject_unknown_params +from ..helpers import ( + dump_response, escape_like, lookup_machine, + validate_metric_name, +) +from ..pagination import ( + cursor_paginate, + make_paginated_response, +) +from ..schemas.tests import ( + PaginatedTestResponseSchema, + TestListQuerySchema, + TestResponseSchema, +) + +_test_schema = TestResponseSchema() + +blp = Blueprint( + 'Tests', + __name__, + url_prefix='/api/v5/', + description='List and inspect test definitions', +) + + +def _serialize_test(test): + """Serialize a Test model instance for the API response.""" + return dump_response(_test_schema, {'name': test.name}) + + +@blp.route('/tests') +class TestList(MethodView): + """List tests.""" + + @require_scope('read') + @blp.arguments(TestListQuerySchema, location="query") + @blp.response(200, PaginatedTestResponseSchema) + def get(self, query_args, testsuite): + """List tests (cursor-paginated, filterable).""" + reject_unknown_params({ + 'search', 'machine', 'metric', + 'cursor', 'limit', + }) + ts = g.ts + session = g.db_session + + query = session.query(ts.Test) + + # Machine / metric filters require joining through Sample + # (and additionally through Run when machine= is specified). + machine_name = query_args.get('machine') + metric_name = query_args.get('metric') + if machine_name or metric_name: + query = query.join(ts.Sample, ts.Sample.test_id == ts.Test.id) + if machine_name: + machine = lookup_machine(session, ts, machine_name) + query = query.join(ts.Run).filter( + ts.Run.machine_id == machine.id) + if metric_name: + validate_metric_name(ts, metric_name) + metric_col = getattr(ts.Sample, metric_name) + query = query.filter(metric_col.isnot(None)) + if machine_name or metric_name: + query = query.distinct() + + search = query_args.get('search') + if search: + escaped = escape_like(search) + query = query.filter( + ts.Test.name.ilike('%' + escaped + '%', escape='\\')) + + cursor_str = query_args.get('cursor') + limit = query_args['limit'] + items, next_cursor = cursor_paginate( + query, ts.Test.id, cursor_str, limit) + + serialized = [_serialize_test(t) for t in items] + return jsonify(make_paginated_response(serialized, next_cursor)) diff --git a/lnt/server/api/v5/endpoints/trends.py b/lnt/server/api/v5/endpoints/trends.py new file mode 100644 index 000000000..3f1a85783 --- /dev/null +++ b/lnt/server/api/v5/endpoints/trends.py @@ -0,0 +1,79 @@ +"""Trends endpoint for the v5 API. + +POST /api/v5/{ts}/trends + Body (JSON): {metric, machine, last_n} + +Returns server-side geomean-aggregated trend data for the Dashboard. +The metric field is required; all other fields are optional. +""" + +from flask import g, jsonify +from flask.views import MethodView +from flask_smorest import Blueprint + +from ..auth import require_scope +from ..errors import abort_with_error +from ..helpers import dump_response, format_utc, get_metric_def, lookup_machine +from ..schemas.trends import TrendsItemSchema, TrendsQuerySchema, TrendsResponseSchema + +_trends_item_schema = TrendsItemSchema() + +blp = Blueprint( + 'Trends', + __name__, + url_prefix='/api/v5/', + description='Geomean-aggregated trend data for the Dashboard', +) + + +@blp.route('/trends') +class TrendsView(MethodView): + """Geomean-aggregated trend data.""" + + @require_scope('read') + @blp.arguments(TrendsQuerySchema, location="json") + @blp.response(200, TrendsResponseSchema) + def post(self, query_args, testsuite): + """Query geomean-aggregated trend data. + + Returns trend data points grouped by (machine, commit) with + the geometric mean of all positive sample values within each + group. Only commits with a non-null ordinal are included. + """ + ts = g.ts + session = g.db_session + + metric = query_args['metric'] + machine_names = query_args.get('machine', []) + + metric_def = get_metric_def(ts, metric) + if metric_def.type != 'real': + abort_with_error( + 400, "Metric '%s' has type '%s'; trends requires a " + "'real' type metric" % (metric, metric_def.type)) + + machine_ids = [] + for name in machine_names: + machine = lookup_machine(session, ts, name) + machine_ids.append(machine.id) + + last_n = query_args.get('last_n') + + results = ts.query_trends( + session, metric, + machine_ids=machine_ids or None, + last_n=last_n, + ) + + items = [] + for r in results: + items.append(dump_response(_trends_item_schema, { + 'machine': r['machine_name'], + 'commit': r['commit'], + 'ordinal': r['ordinal'], + 'tag': r['tag'], + 'value': r['value'], + 'submitted_at': format_utc(r['submitted_at']), + })) + + return jsonify({'metric': metric, 'items': items}) diff --git a/lnt/server/api/v5/errors.py b/lnt/server/api/v5/errors.py new file mode 100644 index 000000000..0f83cf887 --- /dev/null +++ b/lnt/server/api/v5/errors.py @@ -0,0 +1,216 @@ +"""Standardized error handling scoped to the v5 API. + +Produces responses in the format: + {"error": {"code": "not_found", "message": "Machine 'foo' not found"}} + +Error handlers are registered on the flask-smorest Api / Flask app but only +apply to v5 API paths to avoid breaking v4 error format. + +IMPORTANT: ``register_error_handlers`` must be called AFTER any app-level +error handlers (e.g. the 404/500 handlers in ``app.py``) so that we can +save and delegate to them for non-v5 routes. +""" + +import logging + +from flask import jsonify, request +from werkzeug.exceptions import HTTPException + +logger = logging.getLogger(__name__) + + +# Map HTTP status codes to error code strings +STATUS_CODE_MAP = { + 400: 'validation_error', + 401: 'unauthorized', + 403: 'forbidden', + 404: 'not_found', + 405: 'method_not_allowed', + 409: 'conflict', + 415: 'unsupported_media_type', + 422: 'validation_error', + 429: 'rate_limited', + 500: 'internal_error', +} + + +class V5ApiError(Exception): + """Custom exception for v5 API errors. + + Raised by ``abort_with_error`` and caught by a Flask error handler + registered in ``register_error_handlers``. This replaces the previous + ``flask.abort(response)`` pattern which relied on Flask's undocumented + behaviour of passing through Response objects wrapped in an HTTPException + with ``code=None``. + """ + + def __init__(self, status_code, error_code, message): + super().__init__(message) + self.status_code = status_code + self.error_code = error_code + self.message = message + + +def _make_error_response(code, message, status_code): + """Build a standardized error JSON response.""" + resp = jsonify({ + 'error': { + 'code': code, + 'message': message, + } + }) + resp.status_code = status_code + return resp + + +def _get_previous_handler(app, code): + """Return the previously registered error handler for *code*, or None. + + Flask stores app-level handlers in + ``app.error_handler_spec[None][code]``. We look up the handler for + the given integer status code (or ``None`` for exception-class-keyed + handlers). + """ + spec = app.error_handler_spec.get(None, {}) + handlers = spec.get(code, {}) + if not handlers: + return None + # The dict is keyed by exception class; return the first (there is + # usually only one per code). + for handler in handlers.values(): + return handler + return None + + +def _non_v5_fallback(exc, previous_handler): + """Handle *exc* on a non-v5 route. + + If a *previous_handler* (the one that was registered before v5's) + exists, delegate to it so that app-level error formatting (e.g. + the content-negotiating 404/500 handlers in ``app.py``) is + preserved. Otherwise fall back to werkzeug's default HTML + response — which is exactly what Flask itself does when no error + handler is registered. + """ + if previous_handler is not None: + return previous_handler(exc) + return exc.get_response() + + +def register_error_handlers(smorest_api, app): + """Register error handlers scoped to v5 API paths. + + We register on the Flask app itself, but the handlers check + ``request.path`` so they only transform responses for ``/api/v5/`` + routes. For non-v5 routes the previously registered handler (if + any) is called, preserving v4 / Flask-RESTful error formatting. + + This function MUST be called after any app-level error handlers + (404, 500, etc.) have been registered, so that the saved + ``previous_handler`` references are valid. + """ + + @app.errorhandler(V5ApiError) + def handle_v5_api_error(exc): + return _make_error_response(exc.error_code, exc.message, + exc.status_code) + + # -- HTTPException (generic) ------------------------------------------ + prev_http = _get_previous_handler(app, None) + + @app.errorhandler(HTTPException) + def handle_http_exception(exc): + if not request.path.startswith('/api/v5/'): + if prev_http is not None: + return prev_http(exc) + return exc.get_response() + + status_code = exc.code or 500 + error_code = STATUS_CODE_MAP.get(status_code, 'error') + message = exc.description or str(exc) + + return _make_error_response(error_code, message, status_code) + + # -- 422 Unprocessable Entity (webargs validation) -------------------- + prev_422 = _get_previous_handler(app, 422) + + @app.errorhandler(422) + def handle_unprocessable(exc): + if not request.path.startswith('/api/v5/'): + return _non_v5_fallback(exc, prev_422) + + # flask-smorest/webargs validation errors include details + messages = getattr(exc, 'data', {}).get('messages', {}) + if messages: + detail = str(messages) + else: + detail = getattr(exc, 'description', str(exc)) + + return _make_error_response('validation_error', detail, 422) + + # -- Per-status-code handlers ----------------------------------------- + # Register explicit handlers for common status codes so that they take + # priority over the app-level 404/500 handlers registered in app.py. + # Flask dispatches to the most-specific (by status code) handler, so a + # generic HTTPException handler alone is not enough. + for _code in (400, 401, 403, 404, 405, 409, 500): + def _make_handler(code, previous_handler): + def _handler(exc): + if not request.path.startswith('/api/v5/'): + return _non_v5_fallback(exc, previous_handler) + error_code = STATUS_CODE_MAP.get(code, 'error') + message = getattr(exc, 'description', str(exc)) + return _make_error_response(error_code, message, code) + _handler.__name__ = 'handle_v5_%d' % code + return _handler + _prev = _get_previous_handler(app, _code) + app.errorhandler(_code)(_make_handler(_code, _prev)) + + # -- Generic Exception handler ---------------------------------------- + @app.errorhandler(Exception) + def handle_generic_exception(exc): + # V5ApiError is handled by its own dedicated handler above. + if isinstance(exc, V5ApiError): + return handle_v5_api_error(exc) + + if not request.path.startswith('/api/v5/'): + if isinstance(exc, HTTPException): + # Delegate to the per-code or generic HTTP handler so that + # app-level formatting is preserved. + return handle_http_exception(exc) + raise exc + + # If it is an HTTPException, delegate to the HTTP handler + if isinstance(exc, HTTPException): + return handle_http_exception(exc) + + logger.exception("Unhandled exception in v5 API") + return _make_error_response('internal_error', + 'An unexpected error occurred.', 500) + + +def abort_with_error(status_code, message): + """Abort the current request with a standardized v5 error response. + + Raises a :class:`V5ApiError` which is caught by the handler registered + in :func:`register_error_handlers` and converted into a JSON response. + """ + error_code = STATUS_CODE_MAP.get(status_code, 'error') + raise V5ApiError(status_code, error_code, message) + + +def reject_unknown_params(valid_params): + """Reject any query parameters not in *valid_params*. + + Call at the top of every GET/POST handler that reads ``request.args`` + so that typos like ``serch=foo`` instead of ``search=foo`` + are caught early (400) rather than silently returning unfiltered data. + """ + unknown = set(request.args.keys()) - valid_params + if unknown: + abort_with_error( + 400, + "Unknown query parameter(s): %s. " + "Valid parameters are: %s" + % (', '.join(sorted(unknown)), + ', '.join(sorted(valid_params)))) diff --git a/lnt/server/api/v5/etag.py b/lnt/server/api/v5/etag.py new file mode 100644 index 000000000..c870e52d5 --- /dev/null +++ b/lnt/server/api/v5/etag.py @@ -0,0 +1,66 @@ +"""ETag computation and conditional request support for the v5 API. + +Uses weak ETags (``W/""``) computed from the JSON-serialized response +body. Checks ``If-None-Match`` and returns 304 when appropriate. +""" + +import hashlib +import json + +from flask import request + + +def compute_etag(data): + """Compute a weak ETag from a Python object (dict/list). + + The object is serialized to compact JSON and hashed with MD5. + """ + serialized = json.dumps(data, sort_keys=True, separators=(',', ':')) + md5 = hashlib.md5(serialized.encode('utf-8')).hexdigest() + return 'W/"%s"' % md5 + + +def check_etag(etag_value): + """Check ``If-None-Match`` against the given ETag. + + Returns a 304 Not Modified response if the client already has the + current version, otherwise returns None. + """ + if_none_match = request.headers.get('If-None-Match', '') + if not if_none_match: + return None + + # Parse comma-separated ETags per RFC 7232 + client_etags = [e.strip() for e in if_none_match.split(',')] + # Normalise: strip W/ prefix for comparison + + def normalise(e): + if e.startswith('W/'): + return e[2:] + return e + + normalised_server = normalise(etag_value) + for client_etag in client_etags: + if client_etag == '*' or normalise(client_etag) == normalised_server: + from flask import Response + resp = Response(status=304) + resp.headers['ETag'] = etag_value + return resp + + return None + + +def add_etag_to_response(response, data): + """Compute an ETag from *data* and set it on *response*. + + Also checks ``If-None-Match``. If the client already has the + current version, returns a 304 response instead. + """ + etag_value = compute_etag(data) + + not_modified = check_etag(etag_value) + if not_modified is not None: + return not_modified + + response.headers['ETag'] = etag_value + return response diff --git a/lnt/server/api/v5/helpers.py b/lnt/server/api/v5/helpers.py new file mode 100644 index 000000000..4fd25fab0 --- /dev/null +++ b/lnt/server/api/v5/helpers.py @@ -0,0 +1,183 @@ +"""Shared helper functions for v5 API endpoints.""" + +import datetime + +import marshmallow as ma + +from .errors import abort_with_error +from .schemas.runs import RunResponseSchema + +_run_schema = RunResponseSchema() + + +def parse_datetime(value): + """Parse an ISO datetime string. Returns a timezone-aware UTC datetime + or None. + + Two differences from ``datetime.fromisoformat``: + + 1. Accepts ``Z`` as a timezone suffix (mapped to ``+00:00``). + 2. Always returns a **timezone-aware UTC** datetime. Bare datetime + strings (no timezone suffix) are assumed to be UTC. + """ + if not value: + return None + try: + dt = datetime.datetime.fromisoformat(value.replace('Z', '+00:00')) + if dt.tzinfo is not None: + dt = dt.astimezone(datetime.timezone.utc) + else: + # Bare datetime assumed UTC + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt + except (ValueError, TypeError): + return None + + +def escape_like(pattern): + """Escape SQL LIKE wildcards in user-supplied patterns.""" + return pattern.replace('\\', '\\\\').replace('%', r'\%').replace('_', r'\_') + + +def validate_metric_name(ts, field_name): + """Validate that *field_name* is a known metric for this test suite. + + Aborts with 400 if the metric is not found. Returns *field_name* + unchanged on success. + """ + if field_name not in ts._metric_names: + abort_with_error(400, "Unknown metric name '%s'" % field_name) + return field_name + + +def get_metric_def(ts, metric_name): + """Validate *metric_name* and return its schema Metric definition. + + Aborts with 400 if the metric is not found. + """ + validate_metric_name(ts, metric_name) + for m in ts.schema.metrics: + if m.name == metric_name: + return m + # Unreachable if validate_metric_name passed + abort_with_error(400, "Unknown metric name '%s'" % metric_name) + + +# --------------------------------------------------------------------------- +# Entity lookup helpers (abort with 404 if not found) +# --------------------------------------------------------------------------- + +def lookup_machine(session, ts, machine_name): + """Look up a machine by name. Aborts with 404 if not found.""" + machine = ts.get_machine(session, name=machine_name) + if machine is None: + abort_with_error(404, "Machine '%s' not found" % machine_name) + return machine + + +def lookup_run_by_uuid(session, ts, run_uuid): + """Look up a Run by UUID. Aborts with 404 if not found.""" + run = ts.get_run(session, uuid=run_uuid) + if run is None: + abort_with_error(404, "Run '%s' not found" % run_uuid) + return run + + +def lookup_commit(session, ts, commit_id): + """Look up a Commit by its identity string (e.g. git SHA). + + Aborts with 404 if not found. + """ + commit_obj = ts.get_commit(session, commit=commit_id) + if commit_obj is None: + abort_with_error(404, "Commit '%s' not found" % commit_id) + return commit_obj + + +def lookup_test(session, ts, test_name): + """Look up a Test by name. Aborts with 404 if not found.""" + test = ts.get_test(session, name=test_name) + if test is None: + abort_with_error(404, "Test '%s' not found" % test_name) + return test + + +def lookup_regression(session, ts, regression_uuid): + """Look up a Regression by UUID. Aborts with 404 if not found.""" + regression = ts.get_regression(session, uuid=regression_uuid) + if regression is None: + abort_with_error(404, "Regression '%s' not found" % regression_uuid) + return regression + + +def lookup_profile(session, ts, profile_uuid, *, load_data=False): + """Look up a Profile by UUID. Aborts with 404 if not found. + + When *load_data* is True, eagerly loads the deferred ``data`` column + and joins the ``test`` and ``run`` relations. + """ + profile = ts.get_profile(session, uuid=profile_uuid, load_data=load_data) + if profile is None: + abort_with_error(404, "Profile '%s' not found" % profile_uuid) + return profile + + +# --------------------------------------------------------------------------- +# Response validation +# --------------------------------------------------------------------------- + +def dump_response(schema, data): + """Validate and serialize *data* through a marshmallow schema. + + Catches serializer-schema drift at dev time: + + - Extra keys in *data* not declared in the schema raise ValueError + (dump() would silently discard them). + - Missing required fields are caught by validate(). + """ + schema_fields = set(schema.dump_fields.keys()) + extra = set(data.keys()) - schema_fields + if extra: + raise ValueError( + f"Serializer produced keys not in " + f"{type(schema).__name__}: {extra}") + result = schema.dump(data) + errors = schema.validate(result) + if errors: + raise ma.ValidationError(errors) + return result + + +# --------------------------------------------------------------------------- +# Serialization helpers +# --------------------------------------------------------------------------- + +def format_utc(dt): + """Format a UTC datetime as an ISO 8601 string with Z suffix. + + Returns None if *dt* is None. Naive datetimes are assumed to be + UTC and tagged accordingly. + """ + if dt is None: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=datetime.timezone.utc) + return dt.astimezone(datetime.timezone.utc).isoformat().replace('+00:00', 'Z') + + +def serialize_run(run, ts): + """Serialize a Run model instance for API responses. + + Returns a validated dict with uuid, machine, commit, submitted_at, + and run_parameters. + """ + machine_name = run.machine.name if run.machine else None + + data = { + 'uuid': run.uuid, + 'machine': machine_name, + 'commit': run.commit_obj.commit if run.commit_obj else None, + 'submitted_at': format_utc(run.submitted_at), + 'run_parameters': dict(run.run_parameters) if run.run_parameters else {}, + } + return dump_response(_run_schema, data) diff --git a/lnt/server/api/v5/middleware.py b/lnt/server/api/v5/middleware.py new file mode 100644 index 000000000..ff0c50b7c --- /dev/null +++ b/lnt/server/api/v5/middleware.py @@ -0,0 +1,148 @@ +"""v5 API middleware: testsuite resolution, DB session lifecycle, CORS, +and access logging.""" + +import logging +import sys + +from flask import current_app, g, request + +from lnt.server.db.v5 import V5DB, utcnow +from lnt.server.api.v5.errors import _make_error_response + +access_logger = logging.getLogger('lnt.server.api.v5.access') + + +def register_middleware(app): + """Register before_request and after_request hooks for /api/v5/ paths.""" + + # Configure access logger (once, even if called multiple times in tests). + if not access_logger.handlers: + handler = logging.StreamHandler(sys.stderr) + handler.setLevel(logging.INFO) + handler.setFormatter(logging.Formatter('%(message)s')) + access_logger.addHandler(handler) + access_logger.setLevel(logging.INFO) + access_logger.propagate = False + + @app.before_request + def v5_before_request(): + """Open a DB session for v5 API requests and resolve the testsuite.""" + if not request.path.startswith('/api/v5/'): + return + + # Skip DB setup for OpenAPI spec paths + if request.path.startswith('/api/v5/openapi'): + return + + # Always open a DB session for v5 paths (needed for auth, discovery, etc.) + db = current_app.instance.get_database("default") + if db is None: + return _make_error_response( + 'configuration_error', + "No default database configured.", + 500, + ) + if not isinstance(db, V5DB): + return _make_error_response( + 'configuration_error', + "The v5 API requires a v5 database " + "(set db_version to '5.0' in lnt.cfg).", + 500, + ) + g.db = db + g.db_name = "default" + g.db_session = db.make_session() + + # Ensure the in-memory schema cache is up-to-date. In a + # multi-worker deployment another worker may have created or + # deleted a test suite; this single-row integer check detects + # that and reloads the cache before any endpoint runs. + db.ensure_fresh(g.db_session) + + # Resolve testsuite from view_args if the URL contains one. + view_args = request.view_args or {} + testsuite = view_args.get('testsuite') + if testsuite: + ts = db.get_suite(testsuite) + if ts is None: + return _make_error_response( + 'not_found', + "Test suite '%s' not found" % testsuite, + 404, + ) + g.ts = ts + + @app.teardown_request + def v5_teardown_request(exc): + """Close the DB session after the request.""" + session = g.pop('db_session', None) + if session is not None: + try: + if exc is not None: + session.rollback() + else: + session.commit() + except Exception: + session.rollback() + finally: + session.close() + + @app.after_request + def v5_cors_headers(response): + """Add CORS headers to v5 API responses.""" + if not request.path.startswith('/api/v5/'): + return response + + response.headers['Access-Control-Allow-Origin'] = '*' + response.headers['Access-Control-Allow-Methods'] = \ + 'GET, POST, PATCH, DELETE, OPTIONS' + response.headers['Access-Control-Allow-Headers'] = \ + 'Authorization, Content-Type, If-None-Match' + response.headers['Access-Control-Expose-Headers'] = 'ETag, Location' + response.headers['Access-Control-Max-Age'] = '86400' + + return response + + @app.after_request + def v5_access_log(response): + """Emit an Apache combined-format access log line for v5 requests.""" + if not request.path.startswith('/api/v5/'): + return response + + # Determine authenticated user from cached auth context. + scope, api_key = getattr(g, '_v5_auth', (None, None)) + if api_key is not None: + user = api_key.name + elif scope is not None: + user = 'bootstrap' + else: + user = '-' + + now = utcnow() + timestamp = now.strftime('%d/%b/%Y:%H:%M:%S +0000') + + content_length = response.content_length + size = str(content_length) if content_length is not None else '-' + + referer = request.headers.get('Referer', '-') + user_agent = request.headers.get('User-Agent', '-') + + path = request.full_path + if path.endswith('?'): + path = path[:-1] + + line = '%s - %s [%s] "%s %s %s" %d %s "%s" "%s"' % ( + request.remote_addr or '-', + user, + timestamp, + request.method, + path, + request.environ.get('SERVER_PROTOCOL', 'HTTP/1.1'), + response.status_code, + size, + referer, + user_agent, + ) + access_logger.info(line) + + return response diff --git a/lnt/server/api/v5/pagination.py b/lnt/server/api/v5/pagination.py new file mode 100644 index 000000000..7023e6805 --- /dev/null +++ b/lnt/server/api/v5/pagination.py @@ -0,0 +1,112 @@ +"""Cursor-based and offset pagination utilities for the v5 API. + +Cursor-based pagination encodes the last-seen primary key as base64. +Forward-only in v1 (``previous`` is always null). + +Response envelope: + {"items": [...], "cursor": {"next": "...", "previous": null}} +""" + +import base64 + + +def encode_cursor(value): + """Encode an integer ID into a base64 cursor string.""" + return base64.urlsafe_b64encode(str(value).encode('utf-8')).decode('ascii') + + +def decode_cursor(cursor_str): + """Decode a base64 cursor string back into an integer ID. + + Returns None if the cursor is malformed. + """ + if not cursor_str: + return None + try: + decoded = base64.urlsafe_b64decode(cursor_str.encode('ascii')) + return int(decoded.decode('utf-8')) + except (ValueError, TypeError, UnicodeDecodeError): + return None + + +def cursor_paginate(query, id_column, cursor_str=None, limit=25, + descending=False): + """Apply cursor-based pagination to a SQLAlchemy query. + + Parameters + ---------- + query : sqlalchemy.orm.Query + The base query to paginate. Callers should **not** apply their + own ``.order_by()`` for the paginated column -- this function + handles ordering. + id_column : sqlalchemy.Column + The column used for ordering and cursor position (usually `Model.id`). + cursor_str : str or None + The cursor from the previous response (``cursor.next``). + limit : int + Maximum number of items to return. + descending : bool + When *True*, order by ``id_column DESC`` and page forward with + ``id_column < last_id``. Default is ascending order. + + Returns + ------- + (items, next_cursor) : (list, str or None) + The page of results and the cursor for the next page (or None if + there are no more results). + """ + limit = min(max(limit, 1), 10000) + + if cursor_str: + last_id = decode_cursor(cursor_str) + if last_id is None: + from flask import abort + abort(400, description="Invalid pagination cursor") + if descending: + query = query.filter(id_column < last_id) + else: + query = query.filter(id_column > last_id) + + if descending: + query = query.order_by(id_column.desc()) + else: + query = query.order_by(id_column.asc()) + + # Fetch one extra to detect if there is a next page. + items = query.limit(limit + 1).all() + + if len(items) > limit: + items = items[:limit] + next_cursor = encode_cursor(getattr(items[-1], id_column.key)) + else: + next_cursor = None + + return items, next_cursor + + +def make_paginated_response(items, next_cursor, total=None): + """Build the standard paginated response envelope. + + Parameters + ---------- + items : list + The serialized items for this page. + next_cursor : str or None + Cursor string for the next page. + total : int or None + Total count (included for offset-based pagination). + + Returns + ------- + dict + """ + result = { + 'items': items, + 'cursor': { + 'next': next_cursor, + 'previous': None, # Forward-only in v1 + }, + } + if total is not None: + result['total'] = total + return result diff --git a/lnt/server/api/v5/schemas/__init__.py b/lnt/server/api/v5/schemas/__init__.py new file mode 100644 index 000000000..82049afe1 --- /dev/null +++ b/lnt/server/api/v5/schemas/__init__.py @@ -0,0 +1,19 @@ +"""Base schema utilities for the v5 API. + +Provides a common base class and helpers for building marshmallow schemas +that work with flask-smorest and LNT's dynamic test-suite models. +""" + +import marshmallow as ma + + +class BaseSchema(ma.Schema): + """Common base for all v5 API schemas. + + Configured to: + - Raise on unknown fields by default + - Use ``ordered`` output for consistent JSON + """ + + class Meta: + ordered = True diff --git a/lnt/server/api/v5/schemas/admin.py b/lnt/server/api/v5/schemas/admin.py new file mode 100644 index 000000000..623400d1b --- /dev/null +++ b/lnt/server/api/v5/schemas/admin.py @@ -0,0 +1,101 @@ +"""Marshmallow schemas for the Admin / API Key endpoints. + +Schemas: +- APIKeyCreateRequestSchema -- POST request body (name, scope) +- APIKeyCreateResponseSchema -- POST response (raw key shown once, prefix, scope) +- APIKeyItemSchema -- Item in list response (prefix, name, scope, etc.) +- APIKeyListResponseSchema -- GET list response +""" + +import marshmallow as ma + +from . import BaseSchema +from ..auth import SCOPE_LEVELS + + +def _validate_scope(value): + """Validate that the scope string is one of the known scope names.""" + if value not in SCOPE_LEVELS: + raise ma.ValidationError( + "Invalid scope '%s'. Must be one of: %s" + % (value, ', '.join(sorted(SCOPE_LEVELS.keys(), + key=lambda s: SCOPE_LEVELS[s]))) + ) + + +class APIKeyCreateRequestSchema(BaseSchema): + """Request body for POST /api/v5/admin/api-keys.""" + + name = ma.fields.String( + required=True, + metadata={'description': 'Human-readable name for the API key'}, + ) + scope = ma.fields.String( + required=True, + validate=_validate_scope, + metadata={ + 'description': 'Scope level: read, submit, triage, manage, admin', + }, + ) + + +class APIKeyCreateResponseSchema(BaseSchema): + """Response for POST /api/v5/admin/api-keys. + + The raw ``key`` value is shown exactly once and is never stored in + plaintext. + """ + + key = ma.fields.String( + required=True, + metadata={'description': 'Raw API key token (shown once)'}, + ) + prefix = ma.fields.String( + required=True, + metadata={'description': 'First 8 characters of the token (used as identifier)'}, + ) + scope = ma.fields.String( + required=True, + metadata={'description': 'Granted scope level'}, + ) + + +class APIKeyItemSchema(BaseSchema): + """Schema for a single API key in the list response. + + Never includes the key hash or the raw token. + """ + + prefix = ma.fields.String( + required=True, + metadata={'description': 'First 8 characters of the token (identifier)'}, + ) + name = ma.fields.String( + required=True, + metadata={'description': 'Human-readable key name'}, + ) + scope = ma.fields.String( + required=True, + metadata={'description': 'Granted scope level'}, + ) + created_at = ma.fields.String( + required=True, + metadata={'description': 'When the key was created (ISO 8601 UTC)'}, + ) + last_used_at = ma.fields.String( + allow_none=True, + metadata={'description': 'When the key was last used (ISO 8601 UTC)'}, + ) + is_active = ma.fields.Boolean( + required=True, + metadata={'description': 'Whether the key is active (not revoked)'}, + ) + + +class APIKeyListResponseSchema(BaseSchema): + """Response for GET /api/v5/admin/api-keys.""" + + items = ma.fields.List( + ma.fields.Nested(APIKeyItemSchema), + required=True, + ) diff --git a/lnt/server/api/v5/schemas/commits.py b/lnt/server/api/v5/schemas/commits.py new file mode 100644 index 000000000..6f00e387a --- /dev/null +++ b/lnt/server/api/v5/schemas/commits.py @@ -0,0 +1,200 @@ +"""Marshmallow schemas for commit request/response in the v5 API. + +Commits replace the v4 "orders" concept. Each commit has a unique +commit string, an optional integer ordinal for sorting, and dynamic +commit_fields defined in the test suite schema. +""" + +import marshmallow as ma + +from . import BaseSchema +from .common import BaseQuerySchema, CursorPaginationQuerySchema, PaginatedResponseSchema + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class CommitSummarySchema(BaseSchema): + """A single commit in a list response.""" + commit = ma.fields.String( + required=True, + metadata={'description': 'Unique commit identifier (e.g. git SHA)'}, + ) + ordinal = ma.fields.Integer( + allow_none=True, + metadata={'description': 'Optional integer for total ordering'}, + ) + tag = ma.fields.String( + allow_none=True, + metadata={'description': 'Optional human-readable tag (e.g. release-18)'}, + ) + fields = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(allow_none=True), + metadata={ + 'description': 'Commit field values defined by the test suite schema', + 'example': {'llvm_project_revision': 'abc123'}, + }, + ) + + +class CommitNeighborSchema(BaseSchema): + """Reference to a previous or next commit.""" + commit = ma.fields.String( + metadata={'description': 'Commit identifier'}, + ) + ordinal = ma.fields.Integer( + allow_none=True, + metadata={'description': 'Ordinal value'}, + ) + tag = ma.fields.String( + allow_none=True, + metadata={'description': 'Tag value'}, + ) + link = ma.fields.String( + allow_none=True, + metadata={'description': 'URL to fetch the referenced commit'}, + ) + + +class CommitDetailSchema(BaseSchema): + """Full commit detail including previous/next neighbors.""" + commit = ma.fields.String( + required=True, + metadata={'description': 'Unique commit identifier (e.g. git SHA)'}, + ) + ordinal = ma.fields.Integer( + allow_none=True, + metadata={'description': 'Optional integer for total ordering'}, + ) + tag = ma.fields.String( + allow_none=True, + metadata={'description': 'Optional human-readable tag (e.g. release-18)'}, + ) + fields = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(allow_none=True), + metadata={'description': 'Commit field values defined by the test suite schema'}, + ) + previous_commit = ma.fields.Nested( + CommitNeighborSchema, allow_none=True, + metadata={'description': 'Previous commit by ordinal'}, + ) + next_commit = ma.fields.Nested( + CommitNeighborSchema, allow_none=True, + metadata={'description': 'Next commit by ordinal'}, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedCommitResponseSchema(PaginatedResponseSchema): + """Paginated list of commits.""" + items = ma.fields.List(ma.fields.Nested(CommitSummarySchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class CommitListQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /commits.""" + search = ma.fields.String( + load_default=None, + metadata={'description': 'Search commits by substring across commit ' + 'string, tag, and searchable commit fields ' + '(case-insensitive)'}, + ) + machine = ma.fields.String( + load_default=None, + metadata={'description': 'Filter to commits with runs on this machine'}, + ) + sort = ma.fields.String( + load_default=None, + validate=ma.validate.OneOf(['ordinal']), + metadata={'description': "Sort order. Use 'ordinal' to sort by ordinal " + "(excludes commits without ordinals)"}, + ) + has_profiles = ma.fields.Boolean( + load_default=None, + metadata={'description': 'Filter: true = only commits with profile ' + 'data, false = only commits without. Combinable with ' + 'machine= to scope to a specific machine.'}, + ) + + +class CommitDetailQuerySchema(BaseQuerySchema): + """Query parameters for GET /commits/{value}.""" + pass + + +# --------------------------------------------------------------------------- +# Request body schemas +# --------------------------------------------------------------------------- + +class CommitCreateSchema(BaseSchema): + """Request body for POST /commits.""" + class Meta: + unknown = ma.INCLUDE + + commit = ma.fields.String( + required=True, + metadata={'description': 'Unique commit identifier (e.g. git SHA)'}, + ) + ordinal = ma.fields.Integer( + load_default=None, + allow_none=True, + metadata={'description': 'Optional integer for total ordering'}, + ) + + +class CommitUpdateSchema(BaseSchema): + """Request body for PATCH /commits/{value}.""" + class Meta: + unknown = ma.INCLUDE + + ordinal = ma.fields.Integer( + load_default=None, + allow_none=True, + metadata={'description': 'Integer for total ordering, or null to clear'}, + ) + tag = ma.fields.String( + allow_none=True, + validate=ma.validate.Length(max=256), + metadata={'description': 'Human-readable tag, or null to clear'}, + ) + + +# --------------------------------------------------------------------------- +# Batch resolve schemas +# --------------------------------------------------------------------------- + +class CommitResolveRequestSchema(BaseSchema): + """Request body for POST /commits/resolve.""" + commits = ma.fields.List( + ma.fields.String(), + required=True, + validate=ma.validate.Length(min=1), + metadata={ + 'description': 'Commit identity strings to resolve', + 'example': ['abc123', 'def456'], + }, + ) + + +class CommitResolveResponseSchema(BaseSchema): + """Response body for POST /commits/resolve.""" + results = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Nested(CommitSummarySchema), + required=True, + metadata={'description': 'Resolved commits keyed by commit string'}, + ) + not_found = ma.fields.List( + ma.fields.String(), + required=True, + metadata={'description': 'Commit strings not found in the database'}, + ) diff --git a/lnt/server/api/v5/schemas/common.py b/lnt/server/api/v5/schemas/common.py new file mode 100644 index 000000000..6b36f9c7e --- /dev/null +++ b/lnt/server/api/v5/schemas/common.py @@ -0,0 +1,117 @@ +"""Common schemas used across v5 API endpoints: error responses, pagination +envelopes, and field metadata schemas. +""" + +import marshmallow as ma + +from . import BaseSchema + + +# --------------------------------------------------------------------------- +# Error schemas +# --------------------------------------------------------------------------- + +class ErrorDetailSchema(BaseSchema): + code = ma.fields.String(required=True, + metadata={'description': 'Machine-readable error code'}) + message = ma.fields.String(required=True, + metadata={'description': 'Human-readable error description'}) + + +class ErrorResponseSchema(BaseSchema): + error = ma.fields.Nested(ErrorDetailSchema, required=True) + + +# --------------------------------------------------------------------------- +# Pagination schemas +# --------------------------------------------------------------------------- + +class CursorSchema(BaseSchema): + next = ma.fields.String(allow_none=True, load_default=None, + metadata={'description': 'Cursor for the next page'}) + previous = ma.fields.String(allow_none=True, load_default=None, + metadata={'description': 'Reserved for future use (always null)'}) + + +class PaginatedResponseSchema(BaseSchema): + """Base envelope for paginated list responses. + + Subclasses should add an ``items`` field with the appropriate nested + schema. + """ + cursor = ma.fields.Nested(CursorSchema) + total = ma.fields.Integer(allow_none=True, load_default=None, + metadata={'description': 'Total count (for bounded lists)'}) + + +# --------------------------------------------------------------------------- +# Field metadata schemas +# --------------------------------------------------------------------------- + +class TestSuiteLinksSchema(BaseSchema): + """Links to resources within a test suite.""" + machines = ma.fields.String(metadata={'description': 'URL for machines list'}) + commits = ma.fields.String(metadata={'description': 'URL for commits list'}) + runs = ma.fields.String(metadata={'description': 'URL for runs list'}) + tests = ma.fields.String(metadata={'description': 'URL for tests list'}) + regressions = ma.fields.String(metadata={'description': 'URL for regressions list'}) + query = ma.fields.String(metadata={'description': 'URL for time-series query endpoint'}) + + +class TestSuiteDiscoverySchema(BaseSchema): + """A single test suite in the discovery response.""" + name = ma.fields.String(required=True) + links = ma.fields.Nested(TestSuiteLinksSchema, required=True) + + +class DiscoveryLinksSchema(BaseSchema): + """Top-level links in the discovery response.""" + openapi = ma.fields.String(metadata={'description': 'URL for OpenAPI JSON spec'}) + swagger_ui = ma.fields.String(metadata={'description': 'URL for Swagger UI'}) + test_suites = ma.fields.String(metadata={'description': 'URL for test suites list'}) + + +class DiscoveryResponseSchema(BaseSchema): + """Response schema for GET /api/v5/.""" + test_suites = ma.fields.List(ma.fields.Nested(TestSuiteDiscoverySchema)) + links = ma.fields.Nested(DiscoveryLinksSchema) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class BaseQuerySchema(BaseSchema): + """Base class for query parameter schemas. + + Sets ``unknown = EXCLUDE`` so that marshmallow ignores unknown + parameters (they are still rejected by ``reject_unknown_params``). + """ + + class Meta: + ordered = True + unknown = ma.EXCLUDE + + +class CursorPaginationQuerySchema(BaseQuerySchema): + """Query parameters for cursor-paginated endpoints.""" + cursor = ma.fields.String( + load_default=None, + metadata={'description': 'Pagination cursor from a previous response'}, + ) + limit = ma.fields.Integer( + load_default=25, + metadata={'description': 'Maximum number of results per page (default 25, max 10000)'}, + ) + + +class OffsetPaginationQuerySchema(BaseQuerySchema): + """Query parameters for offset-paginated endpoints.""" + limit = ma.fields.Integer( + load_default=25, + metadata={'description': 'Maximum number of results per page (default 25, max 10000)'}, + ) + offset = ma.fields.Integer( + load_default=0, + metadata={'description': 'Number of results to skip (default 0)'}, + ) diff --git a/lnt/server/api/v5/schemas/machines.py b/lnt/server/api/v5/schemas/machines.py new file mode 100644 index 000000000..3d5a80182 --- /dev/null +++ b/lnt/server/api/v5/schemas/machines.py @@ -0,0 +1,125 @@ +"""Marshmallow schemas for machine request/response in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import CursorPaginationQuerySchema, OffsetPaginationQuerySchema, PaginatedResponseSchema + + +# --------------------------------------------------------------------------- +# Request schemas +# --------------------------------------------------------------------------- + +class MachineCreateSchema(BaseSchema): + """Schema for POST /machines request body.""" + name = ma.fields.String( + required=True, + validate=ma.validate.Length(min=1), + metadata={'description': 'Machine name'}, + ) + info = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.String(), + load_default=None, + metadata={ + 'description': 'Optional key-value metadata for the machine', + 'example': {'os': 'linux', 'cpu': 'x86_64'}, + }, + ) + + +class MachineUpdateSchema(BaseSchema): + """Schema for PATCH /machines/{machine_name} request body.""" + name = ma.fields.String( + load_default=None, + validate=ma.validate.Length(min=1), + metadata={'description': 'New machine name (rename)'}, + ) + info = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.String(), + load_default=None, + metadata={ + 'description': 'Updated key-value metadata', + 'example': {'os': 'linux', 'cpu': 'x86_64'}, + }, + ) + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class MachineResponseSchema(BaseSchema): + """Schema for a single machine in responses.""" + name = ma.fields.String( + required=True, + metadata={'description': 'Machine name'}, + ) + info = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.String(), + metadata={ + 'description': 'Machine metadata / parameters', + 'example': {'os': 'linux', 'cpu': 'x86_64'}, + }, + ) + + +class MachineRunResponseSchema(BaseSchema): + """Schema for a run in the machine runs sub-resource.""" + uuid = ma.fields.String( + required=True, + metadata={'description': 'UUID for the run (server-generated or client-provided)'}, + ) + commit = ma.fields.String( + allow_none=True, + metadata={'description': 'Commit string for this run'}, + ) + submitted_at = ma.fields.String( + allow_none=True, + metadata={'description': 'Run submission time (ISO 8601)'}, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedMachineResponseSchema(PaginatedResponseSchema): + """Paginated list of machines.""" + items = ma.fields.List(ma.fields.Nested(MachineResponseSchema)) + + +class PaginatedMachineRunResponseSchema(PaginatedResponseSchema): + """Paginated list of machine runs.""" + items = ma.fields.List(ma.fields.Nested(MachineRunResponseSchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class MachineListQuerySchema(OffsetPaginationQuerySchema): + """Query parameters for GET /machines.""" + search = ma.fields.String( + load_default=None, + metadata={'description': 'Search machines by substring across name ' + 'and searchable machine fields (case-insensitive)'}, + ) + + +class MachineRunsQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /machines/{name}/runs.""" + after = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only runs submitted after this time (exclusive)'}, + ) + before = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only runs submitted before this time (exclusive)'}, + ) + sort = ma.fields.String( + load_default=None, + metadata={'description': 'Sort order. Use -submitted_at for newest first'}, + ) diff --git a/lnt/server/api/v5/schemas/profiles.py b/lnt/server/api/v5/schemas/profiles.py new file mode 100644 index 000000000..36384a3db --- /dev/null +++ b/lnt/server/api/v5/schemas/profiles.py @@ -0,0 +1,118 @@ +"""Marshmallow schemas for profile responses in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema + + +class ProfileListItemSchema(BaseSchema): + """Schema for a single item in the profile listing. + + Returned by GET /runs/{uuid}/profiles. + """ + test = ma.fields.String( + required=True, + metadata={'description': 'Test name'}, + ) + uuid = ma.fields.String( + required=True, + metadata={'description': 'Profile UUID'}, + ) + + +class ProfileMetadataSchema(BaseSchema): + """Schema for profile metadata + top-level counters. + + Returned by GET /profiles/{uuid}. + """ + uuid = ma.fields.String( + required=True, + metadata={'description': 'Profile UUID'}, + ) + test = ma.fields.String( + required=True, + metadata={'description': 'Test name'}, + ) + run_uuid = ma.fields.String( + required=True, + metadata={'description': 'Run UUID'}, + ) + counters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + metadata={ + 'description': 'Top-level counters (absolute values)', + 'example': {'cycles': 1500000, 'branch-misses': 2300}, + }, + ) + disassembly_format = ma.fields.String( + metadata={'description': 'Disassembly format (e.g. llvm-objdump)'}, + ) + + +class FunctionInfoSchema(BaseSchema): + """Schema for a single function in the function list.""" + name = ma.fields.String( + required=True, + metadata={'description': 'Function name'}, + ) + counters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + metadata={ + 'description': 'Counter values as percentages', + 'example': {'cycles': 45.2, 'branch-misses': 12.1}, + }, + ) + length = ma.fields.Integer( + metadata={'description': 'Number of instructions'}, + ) + + +class FunctionListResponseSchema(BaseSchema): + """Schema for GET /profiles/{uuid}/functions.""" + functions = ma.fields.List( + ma.fields.Nested(FunctionInfoSchema), + metadata={'description': 'Functions sorted by hotness (descending)'}, + ) + + +class InstructionSchema(BaseSchema): + """Schema for a single instruction in the disassembly.""" + address = ma.fields.Raw( + metadata={'description': 'Instruction address'}, + ) + counters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + metadata={ + 'description': 'Counter values as percentages', + 'example': {'cycles': 0.8, 'branch-misses': 0.1}, + }, + ) + text = ma.fields.String( + metadata={'description': 'Disassembly text'}, + ) + + +class FunctionDetailSchema(BaseSchema): + """Schema for GET /profiles/{uuid}/functions/{fn_name}.""" + name = ma.fields.String( + required=True, + metadata={'description': 'Function name'}, + ) + counters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + metadata={ + 'description': 'Function-level counter aggregates', + 'example': {'cycles': 45.2, 'branch-misses': 12.1}, + }, + ) + disassembly_format = ma.fields.String( + metadata={'description': 'Disassembly format (e.g. llvm-objdump)'}, + ) + instructions = ma.fields.List( + ma.fields.Nested(InstructionSchema), + metadata={'description': 'Disassembly with per-instruction counters'}, + ) diff --git a/lnt/server/api/v5/schemas/query.py b/lnt/server/api/v5/schemas/query.py new file mode 100644 index 000000000..82a394b6f --- /dev/null +++ b/lnt/server/api/v5/schemas/query.py @@ -0,0 +1,121 @@ +"""Marshmallow schemas for the query endpoint in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import CursorSchema + + +class QueryDataPointSchema(BaseSchema): + """Schema for a single data point in a query response.""" + test = ma.fields.String( + required=True, + metadata={'description': 'Name of the test this data point belongs to'}, + ) + machine = ma.fields.String( + required=True, + metadata={'description': 'Name of the machine this data point belongs to'}, + ) + metric = ma.fields.String( + required=True, + metadata={'description': 'Name of the sample field (metric)'}, + ) + value = ma.fields.Float( + required=True, + metadata={'description': 'The sample value for the field'}, + ) + commit = ma.fields.String( + required=True, + metadata={ + 'description': 'Commit identity string (e.g. revision hash)', + 'example': 'abc123', + }, + ) + ordinal = ma.fields.Integer( + allow_none=True, + metadata={'description': 'Commit ordinal position (may be null)'}, + ) + tag = ma.fields.String( + allow_none=True, + metadata={'description': 'Commit tag (may be null)'}, + ) + run_uuid = ma.fields.String( + required=True, + metadata={'description': 'UUID of the run this data point belongs to'}, + ) + submitted_at = ma.fields.String( + allow_none=True, + metadata={'description': 'Run submission time (ISO 8601)'}, + ) + + +class QueryResponseSchema(BaseSchema): + """Response schema for POST /api/v5/{ts}/query.""" + items = ma.fields.List( + ma.fields.Nested(QueryDataPointSchema), + required=True, + metadata={'description': 'Query data points'}, + ) + cursor = ma.fields.Nested(CursorSchema) + + +# --------------------------------------------------------------------------- +# Request body schema +# --------------------------------------------------------------------------- + +class QueryEndpointQuerySchema(BaseSchema): + """JSON body for POST /query.""" + + class Meta: + ordered = True + unknown = ma.RAISE + + machine = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by machine name'}, + ) + test = ma.fields.List( + ma.fields.String(), + load_default=None, + metadata={ + 'description': 'Filter by test name(s) (disjunction).', + }, + ) + metric = ma.fields.String( + required=True, + metadata={'description': 'Metric name (required)'}, + ) + commit = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by exact commit (mutually exclusive with after_commit/before_commit)'}, + ) + after_commit = ma.fields.String( + load_default=None, + metadata={'description': 'Only return data points after this commit (by ordinal)'}, + ) + before_commit = ma.fields.String( + load_default=None, + metadata={'description': 'Only return data points before this commit (by ordinal)'}, + ) + after_time = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only data points after this time (exclusive)'}, + ) + before_time = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only data points before this time (exclusive)'}, + ) + sort = ma.fields.String( + load_default=None, + metadata={'description': 'Comma-separated sort fields: test, commit, ' + 'submitted_at (prefix with - for descending). ' + 'When omitted, results are in an arbitrary but stable order.'}, + ) + limit = ma.fields.Integer( + load_default=100, + metadata={'description': 'Maximum number of results per page (default 100, max 10000)'}, + ) + cursor = ma.fields.String( + load_default=None, + metadata={'description': 'Pagination cursor from a previous response'}, + ) diff --git a/lnt/server/api/v5/schemas/regressions.py b/lnt/server/api/v5/schemas/regressions.py new file mode 100644 index 000000000..503c099e1 --- /dev/null +++ b/lnt/server/api/v5/schemas/regressions.py @@ -0,0 +1,284 @@ +"""Marshmallow schemas for regression and indicator request/response +in the v5 API. +""" + +import logging +import marshmallow as ma +from webargs import fields as webargs_fields + +logger = logging.getLogger(__name__) + +from . import BaseSchema +from .common import CursorPaginationQuerySchema, PaginatedResponseSchema + + +# --------------------------------------------------------------------------- +# State mapping: API string <-> DB integer (v5 values: 0-4) +# --------------------------------------------------------------------------- + +STATE_TO_DB = { + 'detected': 0, + 'active': 1, + 'not_to_be_fixed': 2, + 'fixed': 3, + 'false_positive': 4, +} + +DB_TO_STATE = {v: k for k, v in STATE_TO_DB.items()} + +VALID_STATES = sorted(STATE_TO_DB.keys()) + + +def state_to_api(db_value): + """Convert a DB integer state to the API string representation. + + Returns ``'unknown_'`` and logs a warning when the value + does not map to any known state, instead of silently falling back + to ``'detected'`` which would mask data corruption. + """ + try: + return DB_TO_STATE[db_value] + except KeyError: + logger.warning("Unknown regression state in DB: %r", db_value) + return f'unknown_{db_value}' + + +def state_to_db(api_string): + """Convert an API string state to the DB integer representation. + + Returns None if the string is not a valid state. + """ + return STATE_TO_DB.get(api_string) + + +# --------------------------------------------------------------------------- +# Indicator schemas +# --------------------------------------------------------------------------- + +class IndicatorResponseSchema(BaseSchema): + """Schema for a single regression indicator.""" + uuid = ma.fields.String( + required=True, + metadata={'description': 'Indicator UUID'}, + ) + machine = ma.fields.String( + required=True, + allow_none=True, + metadata={'description': 'Name of the machine'}, + ) + test = ma.fields.String( + required=True, + allow_none=True, + metadata={'description': 'Name of the test'}, + ) + metric = ma.fields.String( + required=True, + metadata={'description': 'Metric name'}, + ) + + +class IndicatorInputSchema(BaseSchema): + """Schema for a single indicator input ({machine, test, metric}).""" + machine = ma.fields.String( + required=True, + metadata={'description': 'Machine name'}, + ) + test = ma.fields.String( + required=True, + metadata={'description': 'Test name'}, + ) + metric = ma.fields.String( + required=True, + metadata={'description': 'Metric name'}, + ) + + +class IndicatorAddSchema(BaseSchema): + """Schema for POST /regressions/{uuid}/indicators request body.""" + indicators = ma.fields.List( + ma.fields.Nested(IndicatorInputSchema), + required=True, + validate=ma.validate.Length(min=1), + metadata={'description': 'List of {machine, test, metric} indicators to add'}, + ) + + +class IndicatorRemoveSchema(BaseSchema): + """Schema for DELETE /regressions/{uuid}/indicators request body.""" + indicator_uuids = ma.fields.List( + ma.fields.String(), + required=True, + validate=ma.validate.Length(min=1), + metadata={'description': 'UUIDs of indicators to remove'}, + ) + + +# --------------------------------------------------------------------------- +# Regression request schemas +# --------------------------------------------------------------------------- + +class RegressionCreateSchema(BaseSchema): + """Schema for POST /regressions request body.""" + title = ma.fields.String( + load_default=None, + metadata={'description': 'Optional title (auto-generated if omitted)'}, + ) + bug = ma.fields.String( + load_default=None, + metadata={'description': 'Optional bug URL'}, + ) + notes = ma.fields.String( + load_default=None, + metadata={'description': 'Optional investigation notes'}, + ) + state = ma.fields.String( + load_default=None, + validate=ma.validate.OneOf(VALID_STATES), + metadata={'description': 'Optional initial state (default: detected)', + 'enum': VALID_STATES}, + ) + commit = ma.fields.String( + load_default=None, + metadata={'description': 'Optional suspected introduction commit (resolved by value)'}, + ) + indicators = ma.fields.List( + ma.fields.Nested(IndicatorInputSchema), + load_default=[], + metadata={'description': 'Optional list of {machine, test, metric} indicators'}, + ) + + +class RegressionUpdateSchema(BaseSchema): + """Schema for PATCH /regressions/{uuid} request body. + + Fields must NOT have ``load_default`` — the PATCH handler uses + ``'key' in body`` to distinguish absent fields (leave unchanged) from + fields sent as ``null`` (clear the value). + """ + title = ma.fields.String( + metadata={'description': 'New title'}, + ) + bug = ma.fields.String( + allow_none=True, + metadata={'description': 'New bug URL (null to clear)'}, + ) + notes = ma.fields.String( + allow_none=True, + metadata={'description': 'New notes (null to clear)'}, + ) + state = ma.fields.String( + validate=ma.validate.OneOf(VALID_STATES), + metadata={'description': 'New state', 'enum': VALID_STATES}, + ) + commit = ma.fields.String( + allow_none=True, + metadata={'description': 'Suspected introduction commit (null to clear)'}, + ) + + +# --------------------------------------------------------------------------- +# Regression response schemas +# --------------------------------------------------------------------------- + +class RegressionListItemSchema(BaseSchema): + """Schema for a regression in list responses.""" + uuid = ma.fields.String( + required=True, + metadata={'description': 'Regression UUID'}, + ) + title = ma.fields.String( + allow_none=True, + metadata={'description': 'Regression title'}, + ) + bug = ma.fields.String( + allow_none=True, + metadata={'description': 'Bug tracker URL'}, + ) + state = ma.fields.String( + required=True, + metadata={'description': 'Regression state', 'enum': VALID_STATES}, + ) + commit = ma.fields.String( + allow_none=True, + metadata={'description': 'Suspected introduction commit (identity string)'}, + ) + machine_count = ma.fields.Integer( + metadata={'description': 'Number of distinct machines across indicators'}, + ) + test_count = ma.fields.Integer( + metadata={'description': 'Number of distinct tests across indicators'}, + ) + + +class RegressionDetailSchema(BaseSchema): + """Schema for a single regression detail response.""" + uuid = ma.fields.String( + required=True, + metadata={'description': 'Regression UUID'}, + ) + title = ma.fields.String( + allow_none=True, + metadata={'description': 'Regression title'}, + ) + bug = ma.fields.String( + allow_none=True, + metadata={'description': 'Bug tracker URL'}, + ) + notes = ma.fields.String( + allow_none=True, + metadata={'description': 'Investigation notes'}, + ) + state = ma.fields.String( + required=True, + metadata={'description': 'Regression state', 'enum': VALID_STATES}, + ) + commit = ma.fields.String( + allow_none=True, + metadata={'description': 'Suspected introduction commit (identity string)'}, + ) + indicators = ma.fields.List( + ma.fields.Nested(IndicatorResponseSchema), + metadata={'description': 'Embedded list of regression indicators'}, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedRegressionListSchema(PaginatedResponseSchema): + """Paginated list of regressions.""" + items = ma.fields.List(ma.fields.Nested(RegressionListItemSchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class RegressionListQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /regressions.""" + state = webargs_fields.DelimitedList( + ma.fields.String(), + load_default=[], + metadata={'description': 'Filter by state (comma-separated)'}, + ) + machine = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by machine name'}, + ) + test = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by test name'}, + ) + metric = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by metric name'}, + ) + commit = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by commit (regressions whose commit_id matches)'}, + ) + has_commit = ma.fields.Boolean( + load_default=None, + metadata={'description': 'Filter: true = has commit, false = no commit'}, + ) diff --git a/lnt/server/api/v5/schemas/runs.py b/lnt/server/api/v5/schemas/runs.py new file mode 100644 index 000000000..6db7d230c --- /dev/null +++ b/lnt/server/api/v5/schemas/runs.py @@ -0,0 +1,202 @@ +"""Marshmallow schemas for run request/response in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import BaseQuerySchema, CursorPaginationQuerySchema, PaginatedResponseSchema + +# Regex for standard 8-4-4-4-12 hyphenated UUID format (any version). +_UUID_RE = r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$' + + +# --------------------------------------------------------------------------- +# Request body schemas +# --------------------------------------------------------------------------- + +class RunSubmitMachineSchema(BaseSchema): + """Machine section of a run submission.""" + class Meta: + unknown = ma.INCLUDE + + name = ma.fields.String( + required=True, + metadata={'description': 'Machine name'}, + ) + + +class RunSubmitTestEntrySchema(BaseSchema): + """A single test entry in a run submission.""" + class Meta: + unknown = ma.INCLUDE + + name = ma.fields.String( + required=True, + metadata={'description': 'Test name'}, + ) + + +class RunSubmitBodySchema(BaseSchema): + """Request body for POST /runs (v5 report format). + + The ``tests`` entries may contain additional metric fields (e.g. + ``execution_time``, ``compile_time``) whose names are defined by + the test suite schema. Metric values can be scalars or arrays; + arrays create one sample per element. + """ + class Meta: + unknown = ma.INCLUDE + + format_version = ma.fields.Raw( + required=True, + metadata={'description': "Must be the string '5'", 'example': '5'}, + ) + uuid = ma.fields.String( + load_default=None, + validate=ma.validate.Regexp(_UUID_RE), + metadata={ + 'description': 'Optional client-provided UUID for the run. ' + 'Must be in 8-4-4-4-12 hyphenated hex format ' + '(any UUID version accepted). Normalized to ' + 'lowercase. If omitted, the server generates a ' + 'UUID v4. If a run with this UUID already exists, ' + 'the server returns 409.', + 'example': '550e8400-e29b-41d4-a716-446655440000', + }, + ) + machine = ma.fields.Nested( + RunSubmitMachineSchema, + required=True, + metadata={'description': 'Machine definition (name + optional info fields)'}, + ) + commit = ma.fields.String( + required=True, + metadata={ + 'description': 'Commit identifier for this run', + 'example': 'abc123def456', + }, + ) + commit_fields = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + load_default={}, + metadata={ + 'description': 'Optional commit metadata (first-write-wins)', + 'example': {'llvm_project_revision': 'abc123'}, + }, + ) + run_parameters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + load_default={}, + metadata={ + 'description': 'Optional run parameters stored as JSONB', + 'example': {'build_config': 'Release'}, + }, + ) + tests = ma.fields.List( + ma.fields.Nested(RunSubmitTestEntrySchema), + required=True, + metadata={ + 'description': 'Test results with metric values', + }, + ) + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class RunResponseSchema(BaseSchema): + """Schema for a single run in responses.""" + uuid = ma.fields.String( + required=True, + metadata={'description': 'UUID for the run (server-generated or client-provided)'}, + ) + machine = ma.fields.String( + required=True, + metadata={'description': 'Name of the machine this run was on'}, + ) + commit = ma.fields.String( + allow_none=True, + metadata={ + 'description': 'Commit string for this run', + 'example': 'abc123def456', + }, + ) + submitted_at = ma.fields.String( + allow_none=True, + metadata={'description': 'Run submission time (ISO 8601)'}, + ) + run_parameters = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + load_default=None, + metadata={ + 'description': 'Additional run parameters', + 'example': {'run_order': '1', 'optimization_level': '-O2'}, + }, + ) + + +class RunSubmitResponseSchema(BaseSchema): + """Schema for the POST /runs submission response.""" + success = ma.fields.Boolean(required=True) + run_uuid = ma.fields.String( + required=True, + metadata={'description': 'UUID of the newly created run'}, + ) + result_url = ma.fields.String( + metadata={'description': 'URL to fetch the submitted run'}, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedRunResponseSchema(PaginatedResponseSchema): + """Paginated list of runs.""" + items = ma.fields.List(ma.fields.Nested(RunResponseSchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class RunListQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /runs.""" + machine = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by machine name'}, + ) + commit = ma.fields.String( + load_default=None, + metadata={'description': 'Filter by commit string'}, + ) + after = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only runs submitted after this time (exclusive)'}, + ) + before = ma.fields.String( + load_default=None, + metadata={'description': 'ISO datetime; only runs submitted before this time (exclusive)'}, + ) + sort = ma.fields.String( + load_default=None, + metadata={'description': 'Sort order. Use -submitted_at for newest first'}, + ) + has_profiles = ma.fields.Boolean( + load_default=None, + metadata={'description': 'Filter: true = only runs with profiles, ' + 'false = only runs without profiles.'}, + ) + + +class RunSubmitQuerySchema(BaseQuerySchema): + """Query parameters for POST /runs.""" + on_machine_conflict = ma.fields.String( + load_default='reject', + validate=ma.validate.OneOf(['reject', 'update']), + metadata={'description': "What to do when machine metadata differs: " + "'reject' aborts, 'update' updates the existing machine"}, + ) diff --git a/lnt/server/api/v5/schemas/samples.py b/lnt/server/api/v5/schemas/samples.py new file mode 100644 index 000000000..5e6d8d3be --- /dev/null +++ b/lnt/server/api/v5/schemas/samples.py @@ -0,0 +1,49 @@ +"""Marshmallow schemas for sample responses in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import CursorPaginationQuerySchema, PaginatedResponseSchema + + +class SampleResponseSchema(BaseSchema): + """Schema for a single sample in API responses. + + Each sample includes the test name and a ``metrics`` dict containing + all non-null metric values. + """ + test = ma.fields.String( + required=True, + metadata={'description': 'Name of the test this sample belongs to'}, + ) + metrics = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.Raw(), + metadata={ + 'description': 'Metric field values (metric -> value)', + 'example': {'compile_time': 1.23, 'exec_time': 0.45}, + }, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedSampleResponseSchema(PaginatedResponseSchema): + """Paginated list of samples.""" + items = ma.fields.List(ma.fields.Nested(SampleResponseSchema)) + + +class SampleListResponseSchema(BaseSchema): + """Non-paginated list of samples (used for run+test specific queries).""" + items = ma.fields.List(ma.fields.Nested(SampleResponseSchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class RunSamplesQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /runs/{uuid}/samples.""" + pass diff --git a/lnt/server/api/v5/schemas/test_suites.py b/lnt/server/api/v5/schemas/test_suites.py new file mode 100644 index 000000000..086107cea --- /dev/null +++ b/lnt/server/api/v5/schemas/test_suites.py @@ -0,0 +1,146 @@ +"""Marshmallow schemas for the /api/v5/test-suites endpoints.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import BaseQuerySchema + + +# --------------------------------------------------------------------------- +# Nested field-definition schemas for the POST body +# --------------------------------------------------------------------------- + +class MachineFieldDefSchema(BaseSchema): + name = ma.fields.String( + required=True, + metadata={'description': 'Machine field name'}, + ) + searchable = ma.fields.Boolean( + load_default=False, + metadata={'description': 'Enable search on this field'}, + ) + + +class CommitFieldDefSchema(BaseSchema): + name = ma.fields.String( + required=True, + metadata={'description': 'Commit field name'}, + ) + type = ma.fields.String( + load_default='default', + metadata={'description': 'Data type: default, text, integer, datetime'}, + ) + searchable = ma.fields.Boolean( + load_default=False, + metadata={'description': 'Enable search on this field'}, + ) + display = ma.fields.Boolean( + load_default=False, + metadata={'description': 'Use this field for display instead of the commit string'}, + ) + + +class MetricDefSchema(BaseSchema): + name = ma.fields.String( + required=True, + metadata={'description': 'Metric name'}, + ) + type = ma.fields.String( + load_default='real', + metadata={'description': 'Data type: real, status, or hash'}, + ) + bigger_is_better = ma.fields.Boolean( + load_default=False, + metadata={'description': 'Whether larger values indicate better performance'}, + ) + display_name = ma.fields.String( + load_default=None, + allow_none=True, + metadata={'description': 'Human-readable display name'}, + ) + unit = ma.fields.String( + load_default=None, + allow_none=True, + metadata={'description': 'Unit of measurement (e.g. "seconds", "bytes")'}, + ) + unit_abbrev = ma.fields.String( + load_default=None, + allow_none=True, + metadata={'description': 'Abbreviated unit (e.g. "s", "B")'}, + ) + + +# --------------------------------------------------------------------------- +# Request schema (POST body) +# --------------------------------------------------------------------------- + +class TestSuiteCreateRequestSchema(BaseSchema): + name = ma.fields.String( + required=True, + validate=ma.validate.Regexp( + r'^[A-Za-z][A-Za-z0-9_]*$', + error='Name must start with a letter and contain only ' + 'letters, digits, and underscores.', + ), + ) + metrics = ma.fields.List( + ma.fields.Nested(MetricDefSchema), + required=True, + ) + commit_fields = ma.fields.List( + ma.fields.Nested(CommitFieldDefSchema), + load_default=[], + ) + machine_fields = ma.fields.List( + ma.fields.Nested(MachineFieldDefSchema), + load_default=[], + ) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class TestSuiteListQuerySchema(BaseQuerySchema): + pass + + +class TestSuiteCreateQuerySchema(BaseQuerySchema): + pass + + +class TestSuiteDetailQuerySchema(BaseQuerySchema): + pass + + +class TestSuiteDeleteQuerySchema(BaseQuerySchema): + confirm = ma.fields.String( + load_default=None, + metadata={'description': 'Must be "true" to confirm deletion'}, + ) + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class TestSuiteDetailResponseSchema(BaseSchema): + name = ma.fields.String(required=True) + schema = ma.fields.Dict( + metadata={'description': 'Full test suite schema definition'}, + ) + links = ma.fields.Dict( + keys=ma.fields.String(), + values=ma.fields.String(), + metadata={ + 'description': 'Links to per-suite API resources', + 'example': { + 'machines': '/api/v5/nts/machines', + 'runs': '/api/v5/nts/runs', + }, + }, + ) + + +class TestSuiteListResponseSchema(BaseSchema): + items = ma.fields.List(ma.fields.Nested(TestSuiteDetailResponseSchema)) diff --git a/lnt/server/api/v5/schemas/tests.py b/lnt/server/api/v5/schemas/tests.py new file mode 100644 index 000000000..0a8067f6e --- /dev/null +++ b/lnt/server/api/v5/schemas/tests.py @@ -0,0 +1,53 @@ +"""Marshmallow schemas for test entity request/response in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema +from .common import CursorPaginationQuerySchema, PaginatedResponseSchema + + +# --------------------------------------------------------------------------- +# Response schemas +# --------------------------------------------------------------------------- + +class TestResponseSchema(BaseSchema): + """Schema for a single test entity in responses.""" + name = ma.fields.String( + required=True, + metadata={'description': 'Test name (may contain slashes)'}, + ) + + +# --------------------------------------------------------------------------- +# Paginated response schemas +# --------------------------------------------------------------------------- + +class PaginatedTestResponseSchema(PaginatedResponseSchema): + """Paginated list of tests.""" + items = ma.fields.List(ma.fields.Nested(TestResponseSchema)) + + +# --------------------------------------------------------------------------- +# Query parameter schemas +# --------------------------------------------------------------------------- + +class TestListQuerySchema(CursorPaginationQuerySchema): + """Query parameters for GET /tests.""" + search = ma.fields.String( + load_default=None, + metadata={'description': 'Filter test names (case-insensitive substring match)'}, + ) + machine = ma.fields.String( + load_default=None, + metadata={ + 'description': 'Only return tests that have sample data ' + 'for this machine', + }, + ) + metric = ma.fields.String( + load_default=None, + metadata={ + 'description': 'Only return tests that have non-NULL values ' + 'for this metric', + }, + ) diff --git a/lnt/server/api/v5/schemas/trends.py b/lnt/server/api/v5/schemas/trends.py new file mode 100644 index 000000000..e7e42bbb1 --- /dev/null +++ b/lnt/server/api/v5/schemas/trends.py @@ -0,0 +1,72 @@ +"""Marshmallow schemas for the trends endpoint in the v5 API.""" + +import marshmallow as ma + +from . import BaseSchema + + +class TrendsQuerySchema(BaseSchema): + """JSON body for POST /trends.""" + + class Meta: + ordered = True + unknown = ma.RAISE + + metric = ma.fields.String( + required=True, + metadata={'description': 'Metric name (required)'}, + ) + machine = ma.fields.List( + ma.fields.String(), + load_default=[], + metadata={'description': 'Filter by machine name(s)'}, + ) + last_n = ma.fields.Integer( + load_default=None, + validate=ma.validate.Range(min=1, max=10000), + metadata={'description': 'Return only the last N commits by ordinal'}, + ) + + +class TrendsItemSchema(BaseSchema): + """Schema for a single trend item in the response.""" + machine = ma.fields.String( + required=True, + metadata={'description': 'Name of the machine'}, + ) + commit = ma.fields.String( + required=True, + metadata={ + 'description': 'Commit string (e.g. revision hash)', + 'example': 'abc123', + }, + ) + ordinal = ma.fields.Integer( + required=True, + metadata={'description': 'Commit ordinal position'}, + ) + tag = ma.fields.String( + allow_none=True, + metadata={'description': 'Commit tag (may be null)'}, + ) + submitted_at = ma.fields.String( + allow_none=True, + metadata={'description': 'Latest run submission time (ISO 8601)'}, + ) + value = ma.fields.Float( + required=True, + metadata={'description': 'Geometric mean of sample values'}, + ) + + +class TrendsResponseSchema(BaseSchema): + """Response schema for POST /api/v5/{ts}/trends.""" + metric = ma.fields.String( + required=True, + metadata={'description': 'The metric that was queried'}, + ) + items = ma.fields.List( + ma.fields.Nested(TrendsItemSchema), + required=True, + metadata={'description': 'Trend data points'}, + ) diff --git a/lnt/server/config.py b/lnt/server/config.py index 06caf5032..14a56558b 100644 --- a/lnt/server/config.py +++ b/lnt/server/config.py @@ -6,7 +6,7 @@ import re import tempfile -import lnt.server.db.v4db +import lnt.server.db.v4db # noqa: F401 -- used via dotted access in get_database() class EmailConfig: @@ -63,26 +63,29 @@ def from_data(baseDir, config_data, default_email_config, baseline_revision = config_data.get('baseline_revision', default_baseline_revision) db_version = config_data.get('db_version', '0.4') - if db_version != '0.4': + if db_version not in ('0.4', '5.0'): raise NotImplementedError("unable to load version %r database" % ( db_version)) return DBInfo(dbPath, config_data.get('shadow_import', None), email_config, - baseline_revision) + baseline_revision, + db_version=db_version) @staticmethod def dummy_instance(): return DBInfo("sqlite:///:memory:", None, EmailConfig(False, '', '', []), 0) - def __init__(self, path, shadow_import, email_config, baseline_revision): + def __init__(self, path, shadow_import, email_config, baseline_revision, + db_version='0.4'): self.config = None self.path = path self.shadow_import = shadow_import self.email_config = email_config self.baseline_revision = baseline_revision + self.db_version = db_version def __str__(self): return "DBInfo(" + self.path + ")" @@ -191,6 +194,10 @@ def get_database(self, name): if db_entry is None: return None + if db_entry.db_version == '5.0': + from lnt.server.db.v5 import V5DB + return V5DB(db_entry.path, self) + return lnt.server.db.v4db.V4DB(db_entry.path, self, db_entry.baseline_revision) diff --git a/lnt/server/db/fieldchange.py b/lnt/server/db/fieldchange.py index fdf3885e8..cc0800edb 100644 --- a/lnt/server/db/fieldchange.py +++ b/lnt/server/db/fieldchange.py @@ -1,4 +1,5 @@ import difflib +import uuid as uuid_module from sqlalchemy.orm import joinedload from sqlalchemy.orm.session import Session @@ -170,6 +171,7 @@ def regenerate_fieldchanges_for_run(session: Session, ts: TestSuiteDB, run_id: i machine=run.machine, test=test, field_id=field.id) + f.uuid = str(uuid_module.uuid4()) session.add(f) session.flush() try: diff --git a/lnt/server/db/migrations/upgrade_18_to_19.py b/lnt/server/db/migrations/upgrade_18_to_19.py new file mode 100644 index 000000000..d03add4dc --- /dev/null +++ b/lnt/server/db/migrations/upgrade_18_to_19.py @@ -0,0 +1,169 @@ +"""This migration adds: + +A) UUID columns (String(36)) to per-testsuite Run, FieldChange, and + Regression tables. Existing rows are backfilled with uuid4 values + in batches. A unique index is created after the backfill. + +B) A global APIKey table for v5 API authentication. +""" + +import uuid + +import sqlalchemy +from sqlalchemy import Column, String, Index, select, text +from lnt.server.db.migrations.util import introspect_table +from lnt.server.db.util import add_column +from lnt.util import logger + +BACKFILL_BATCH_SIZE = 1000 + + +def _add_uuid_column(engine, table_name): + """Add a nullable UUID column to the given table.""" + uuid_col = Column("UUID", String(36)) + try: + add_column(engine, table_name, uuid_col) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping UUID column on %s (may already exist): %s", + table_name, e) + + +def _backfill_uuids(engine, table_name): + """Backfill UUID values in batches.""" + table = introspect_table(engine, table_name) + # Find rows without a UUID + with engine.begin() as conn: + count_q = select([sqlalchemy.func.count()]).select_from(table).where( + table.c.UUID.is_(None) + ) + total = conn.execute(count_q).scalar() + + if total == 0: + return + + logger.info("Backfilling %d UUIDs on %s ...", total, table_name) + filled = 0 + while filled < total: + with engine.begin() as conn: + rows = conn.execute( + select([table.c.ID]).where( + table.c.UUID.is_(None) + ).limit(BACKFILL_BATCH_SIZE) + ).fetchall() + if not rows: + break + for row in rows: + conn.execute( + table.update().where( + table.c.ID == row[0] + ).values(UUID=str(uuid.uuid4())) + ) + filled += len(rows) + logger.info("Backfilled %d UUIDs on %s", filled, table_name) + + +def _create_uuid_index(engine, table_name, ts_name): + """Create a unique index on the UUID column.""" + index_name = "ix_{}_uuid".format(table_name.lower()) + table = introspect_table(engine, table_name) + idx = Index(index_name, table.c.UUID, unique=True) + try: + idx.create(engine) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping UUID index on %s (may already exist): %s", + table_name, e) + + +def _add_uuid_to_table(engine, table_name, ts_name): + """Add UUID column, backfill, and create unique index.""" + _add_uuid_column(engine, table_name) + _backfill_uuids(engine, table_name) + _create_uuid_index(engine, table_name, ts_name) + + +def _create_apikey_table(engine): + """Create the global api_key table for v5 API authentication.""" + # Detect dialect for Postgres vs SQLite differences + dialect = engine.dialect.name + + if dialect == 'postgresql': + create_sql = text(""" + CREATE TABLE IF NOT EXISTS "api_key" ( + "id" SERIAL PRIMARY KEY, + "name" VARCHAR(256) NOT NULL, + "key_prefix" VARCHAR(8) NOT NULL, + "key_hash" VARCHAR(64) NOT NULL, + "scope" VARCHAR(32) NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "last_used_at" TIMESTAMP, + "is_active" BOOLEAN NOT NULL DEFAULT TRUE + ) + """) + else: + create_sql = text(""" + CREATE TABLE IF NOT EXISTS "api_key" ( + "id" INTEGER PRIMARY KEY, + "name" VARCHAR(256) NOT NULL, + "key_prefix" VARCHAR(8) NOT NULL, + "key_hash" VARCHAR(64) NOT NULL, + "scope" VARCHAR(32) NOT NULL, + "created_at" TIMESTAMP NOT NULL, + "last_used_at" TIMESTAMP, + "is_active" BOOLEAN NOT NULL DEFAULT 1 + ) + """) + + with engine.begin() as conn: + try: + conn.execute(create_sql) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping api_key table creation " + "(may already exist): %s", e) + return # Don't try to create index if table creation failed + + # Create unique index on key_hash + try: + apikey_table = introspect_table(engine, "api_key") + idx = Index("ix_api_key_key_hash", apikey_table.c.key_hash, + unique=True) + idx.create(engine) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping api_key key_hash index " + "(may already exist): %s", e) + + +def upgrade(engine): + """Add UUID columns to per-testsuite tables and create api_key table.""" + + # Discover test suites dynamically + try: + test_suite = introspect_table(engine, 'TestSuite') + with engine.begin() as conn: + suites = list(conn.execute(select([test_suite]))) + except sqlalchemy.exc.NoSuchTableError: + # TestSuite table may not exist on a brand-new database + suites = [] + + for suite in suites: + # suite columns: ID, Name, DBKeyName, ... + ts_name = suite[2] # DBKeyName + logger.info("Adding UUID columns for test suite: %s", ts_name) + + run_table = "{}_Run".format(ts_name) + fc_table = "{}_FieldChangeV2".format(ts_name) + reg_table = "{}_Regression".format(ts_name) + + _add_uuid_to_table(engine, run_table, ts_name) + _add_uuid_to_table(engine, fc_table, ts_name) + _add_uuid_to_table(engine, reg_table, ts_name) + + # Create the global APIKey table + _create_apikey_table(engine) diff --git a/lnt/server/db/migrations/upgrade_19_to_20.py b/lnt/server/db/migrations/upgrade_19_to_20.py new file mode 100644 index 000000000..14ebad06c --- /dev/null +++ b/lnt/server/db/migrations/upgrade_19_to_20.py @@ -0,0 +1,50 @@ +"""Create TestSuiteRegistryVersion table. + +Single-row table with an integer version counter. Incremented whenever a +test suite is created or deleted so that other workers can detect the change +and reload their in-memory suite caches. +""" + +import sqlalchemy +from sqlalchemy import text +from lnt.util import logger + + +def upgrade(engine): + dialect = engine.dialect.name + + if dialect == 'postgresql': + create_sql = text(""" + CREATE TABLE IF NOT EXISTS "TestSuiteRegistryVersion" ( + "ID" SERIAL PRIMARY KEY, + "Version" INTEGER NOT NULL DEFAULT 0 + ) + """) + else: + create_sql = text(""" + CREATE TABLE IF NOT EXISTS "TestSuiteRegistryVersion" ( + "ID" INTEGER PRIMARY KEY, + "Version" INTEGER NOT NULL DEFAULT 0 + ) + """) + + with engine.begin() as conn: + try: + conn.execute(create_sql) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping TestSuiteRegistryVersion table creation " + "(may already exist): %s", e) + return + + # Insert the initial row + with engine.begin() as conn: + count = conn.execute( + text('SELECT COUNT(*) FROM "TestSuiteRegistryVersion"') + ).scalar() + if count == 0: + conn.execute( + text('INSERT INTO "TestSuiteRegistryVersion" ("Version") ' + 'VALUES (0)') + ) diff --git a/lnt/server/db/migrations/upgrade_20_to_21.py b/lnt/server/db/migrations/upgrade_20_to_21.py new file mode 100644 index 000000000..5670a1261 --- /dev/null +++ b/lnt/server/db/migrations/upgrade_20_to_21.py @@ -0,0 +1,49 @@ +"""Add Tag column to per-testsuite Order tables. + +Adds a nullable Tag VARCHAR(64) column with an index to each +{testsuite}_Order table, allowing users to label specific orders +(e.g. "release-18.1") for filtering and comparison. +""" + +import sqlalchemy +from sqlalchemy import Column, String, Index, select +from lnt.server.db.migrations.util import introspect_table +from lnt.server.db.util import add_column +from lnt.util import logger + + +def upgrade(engine): + # Discover test suites dynamically + try: + test_suite = introspect_table(engine, 'TestSuite') + with engine.begin() as conn: + suites = list(conn.execute(select([test_suite]))) + except sqlalchemy.exc.NoSuchTableError: + suites = [] + + for suite in suites: + ts_name = suite[2] # DBKeyName + table_name = "{}_Order".format(ts_name) + logger.info("Adding Tag column to %s", table_name) + + # Add the column + tag_col = Column("Tag", String(64)) + try: + add_column(engine, table_name, tag_col) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping Tag column on %s " + "(may already exist): %s", table_name, e) + + # Create index + index_name = "ix_{}_tag".format(table_name.lower()) + try: + table = introspect_table(engine, table_name) + idx = Index(index_name, table.c.Tag) + idx.create(engine) + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError, + sqlalchemy.exc.IntegrityError) as e: + logger.warning("Skipping Tag index on %s " + "(may already exist): %s", table_name, e) diff --git a/lnt/server/db/regression.py b/lnt/server/db/regression.py index 0f55a4da1..422df755d 100644 --- a/lnt/server/db/regression.py +++ b/lnt/server/db/regression.py @@ -1,6 +1,7 @@ from sqlalchemy import desc, asc import re +import uuid as uuid_module from collections import namedtuple from lnt.server.reporting.analysis import RunInfo from lnt.server.ui.util import guess_test_short_name as shortname @@ -45,6 +46,7 @@ def new_regression(session, ts, field_changes): MSG = "Regression of 0 benchmarks" title = MSG regression = ts.Regression(title, "", RegressionState.DETECTED) + regression.uuid = str(uuid_module.uuid4()) session.add(regression) new_ris = [] for fc_id in field_changes: diff --git a/lnt/server/db/testsuite.py b/lnt/server/db/testsuite.py index af88d4109..42d7e551c 100644 --- a/lnt/server/db/testsuite.py +++ b/lnt/server/db/testsuite.py @@ -16,6 +16,16 @@ Base = sqlalchemy.ext.declarative.declarative_base() # type: sqlalchemy.ext.declarative.api.DeclarativeMeta +class TestSuiteRegistryVersion(Base): + """Single-row table tracking how many times suites have been created or + deleted. Workers compare their cached version against this to know when + to reload the in-memory testsuite dict.""" + __tablename__ = 'TestSuiteRegistryVersion' + + id = Column("ID", Integer, primary_key=True) + version = Column("Version", Integer, nullable=False, default=0) + + class SampleType(Base): """ The SampleType table describes an enumeration for the possible types diff --git a/lnt/server/db/testsuitedb.py b/lnt/server/db/testsuitedb.py index 3dc641941..74b4da382 100644 --- a/lnt/server/db/testsuitedb.py +++ b/lnt/server/db/testsuitedb.py @@ -9,6 +9,7 @@ import json import os import itertools +import uuid as uuid_module import aniso8601 import sqlalchemy @@ -226,6 +227,10 @@ class Order(self.base, ParameterizedMixin): uselist=False) order_name_cache = {} + # Optional user-assigned tag for labeling specific orders + # (e.g. "release-18.1"). + tag = Column("Tag", String(64), nullable=True, index=True) + # Dynamically create fields for all of the test suite defined order # fields. class_dict = locals() @@ -354,6 +359,10 @@ class Run(self.base, ParameterizedMixin): end_time = Column("EndTime", DateTime) simple_run_id = Column("SimpleRunID", Integer) + # UUID for v5 API - public stable identifier + uuid = Column("UUID", String(36), unique=True, index=True, + default=lambda: str(uuid_module.uuid4())) + # The parameters blob is used to store any additional information # reported by the run but not promoted into the machine record. # Such data is stored as a JSON encoded blob. @@ -603,6 +612,9 @@ class FieldChange(self.base, ParameterizedMixin): __tablename__ = db_key_name + '_FieldChangeV2' id = Column("ID", Integer, primary_key=True) + # UUID for v5 API - public stable identifier + uuid = Column("UUID", String(36), unique=True, index=True, + default=lambda: str(uuid_module.uuid4())) old_value = Column("OldValue", Float) new_value = Column("NewValue", Float) start_order_id = Column("StartOrderID", Integer, @@ -660,6 +672,9 @@ class Regression(self.base, ParameterizedMixin): __tablename__ = db_key_name + '_Regression' id = Column("ID", Integer, primary_key=True) + # UUID for v5 API - public stable identifier + uuid = Column("UUID", String(36), unique=True, index=True, + default=lambda: str(uuid_module.uuid4())) title = Column("Title", String(256), unique=False, index=False) bug = Column("BugLink", String(256), unique=False, index=False) state = Column("State", Integer) @@ -772,8 +787,14 @@ def __str__(self): sqlalchemy.schema.Index("ix_%s_Sample_RunID_TestID" % db_key_name, Sample.run_id, Sample.test_id) - def create_tables(self, engine): - self.base.metadata.create_all(engine) + def create_tables(self, bind): + """Create per-suite tables. + + *bind* is any SQLAlchemy ``Connectable`` -- typically an ``Engine`` + for standalone use, or a ``Connection`` when the DDL must share a + transaction with pending DML (e.g. flushed metadata inserts). + """ + self.base.metadata.create_all(bind) def get_baselines(self, session): return session.query(self.Baseline).all() @@ -1015,6 +1036,7 @@ def _getOrCreateRun(self, session, run_data, machine, merge): run_parameters.pop('end_time') run = self.Run(new_id, machine, order, start_time, end_time) + run.uuid = str(uuid_module.uuid4()) # First, extract all of the specified run fields. for item in self.run_fields: diff --git a/lnt/server/db/v4db.py b/lnt/server/db/v4db.py index 77311d59c..6f399cd43 100644 --- a/lnt/server/db/v4db.py +++ b/lnt/server/db/v4db.py @@ -78,6 +78,50 @@ def __init__(self, path, config, baseline_revision=0): self.testsuite = dict() self._load_schemas() + # Cache the registry version so we can detect out-of-band changes + # made by other workers (see check_registry_version). + self._registry_version = self._read_registry_version() + + def _read_registry_version(self, session=None): + """Read the current registry version from the DB. + + Returns 0 if the table does not exist yet (pre-migration DB). + """ + close_after = session is None + if close_after: + session = self.make_session() + try: + row = session.query(testsuite.TestSuiteRegistryVersion).first() + return row.version if row is not None else 0 + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError): + return 0 + finally: + if close_after: + session.close() + + def check_registry_version(self, session=None): + """Compare cached version with the DB; reload suites if stale.""" + current = self._read_registry_version(session) + if current != self._registry_version: + self.reload_suites() + self._registry_version = current + + def reload_suites(self): + """Rebuild ``self.testsuite`` from both YAML files and the DB.""" + self.testsuite = dict() + self._load_schemas() + + def increment_registry_version(self, session): + """Bump the registry version so other workers detect the change.""" + try: + row = session.query(testsuite.TestSuiteRegistryVersion).first() + if row is not None: + row.version = row.version + 1 + except (sqlalchemy.exc.OperationalError, + sqlalchemy.exc.ProgrammingError): + pass + def close(self): self.engine.dispose() diff --git a/lnt/server/db/v5/__init__.py b/lnt/server/db/v5/__init__.py new file mode 100644 index 000000000..83abc7c63 --- /dev/null +++ b/lnt/server/db/v5/__init__.py @@ -0,0 +1,1565 @@ +""" +v5 database layer. + +Provides :class:`V5DB` (engine, sessions, schema loading) and +:class:`V5TestSuiteDB` (per-suite CRUD operations). + +Postgres only. No imports from v4 DB code. +""" + +from __future__ import annotations + +import base64 +import datetime +import json +import sys +import uuid as uuid_module +from typing import Any, Iterable + +if sys.version_info >= (3, 12): + from itertools import batched +else: + from itertools import islice + + def batched(iterable, n): # type: ignore[no-redef] + it = iter(iterable) + while batch := tuple(islice(it, n)): + yield batch + +import sqlalchemy +import sqlalchemy.exc +import sqlalchemy.orm +from sqlalchemy import or_ +from sqlalchemy.dialects.postgresql import insert as pg_insert + +from .models import ( + SuiteModels, + V5Schema, + V5SchemaVersion, + create_global_tables, + create_suite_models, + utcnow, +) +from .schema import TestSuiteSchema, parse_schema + +DEFAULT_LIMIT = 1000 + +# Maximum number of names per IN-clause chunk for batch operations. +# Stays under psycopg2's 32,767 bind-parameter limit. +_BATCH_CHUNK_SIZE = 32_000 + +# Regression state values (see design D5). +REGRESSION_STATES = { + 0: "detected", + 1: "active", + 2: "not_to_be_fixed", + 3: "fixed", + 4: "false_positive", +} +VALID_REGRESSION_STATES = frozenset(REGRESSION_STATES) + + +def _escape_like(s: str) -> str: + """Escape SQL special characters for use in ILIKE patterns. + + Replaces ``\\``, ``%``, and ``_`` with their escaped equivalents + so the caller can safely use ``ESCAPE '\\'`` in the ILIKE clause. + """ + return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + + +def initialize_v5_database(path: str) -> None: + """Create v5 global tables and seed rows. + + Idempotent -- safe to call on an already-initialized database. + Called by ``lnt create --db-version 5.0``. + """ + engine = sqlalchemy.create_engine(path) + try: + create_global_tables(engine) + session = sqlalchemy.orm.sessionmaker(engine)() + try: + if session.query(V5SchemaVersion).get(1) is None: + session.add(V5SchemaVersion(id=1, version=0)) + session.commit() + except sqlalchemy.exc.IntegrityError: + session.rollback() + except Exception: + session.rollback() + raise + finally: + session.close() + finally: + engine.dispose() + + +class V5DB: + """Top-level database handle for a v5 LNT instance. + + Owns the SQLAlchemy engine, session factory, and a dict of per-suite + :class:`V5TestSuiteDB` wrappers. Schemas are stored in the database + (``v5_schema`` table), not on the filesystem. + """ + + def __init__(self, path: str, config: Any): + self.path = path + self.config = config + self.engine = sqlalchemy.create_engine( + path, + pool_size=10, + max_overflow=20, + pool_pre_ping=True, + pool_recycle=3600, + ) + self.sessionmaker = sqlalchemy.orm.sessionmaker(self.engine) + self.testsuite: dict[str, V5TestSuiteDB] = {} + self._schema_version: int | None = None + + try: + self._load_schemas_from_db() + except sqlalchemy.exc.ProgrammingError as e: + self.engine.dispose() + if "v5_schema" in str(e): + raise RuntimeError( + "v5 database not initialized. " + "Run: lnt create --db-version 5.0" + ) from e + raise + except Exception: + self.engine.dispose() + raise + + # -- schema storage -------------------------------------------------------- + + @staticmethod + def _schema_to_dict(schema: TestSuiteSchema) -> dict[str, Any]: + """Serialize a TestSuiteSchema to a JSON-serializable dict.""" + return { + "name": schema.name, + "metrics": [ + { + "name": m.name, + "type": m.type, + **({"display_name": m.display_name} if m.display_name else {}), + **({"unit": m.unit} if m.unit else {}), + **({"unit_abbrev": m.unit_abbrev} if m.unit_abbrev else {}), + **({"bigger_is_better": True} if m.bigger_is_better else {}), + } + for m in schema.metrics + ], + "commit_fields": [ + { + "name": cf.name, + **({"type": cf.type} if cf.type != "default" else {}), + **({"searchable": True} if cf.searchable else {}), + **({"display": True} if cf.display else {}), + } + for cf in schema.commit_fields + ], + "machine_fields": [ + { + "name": mf.name, + **({"searchable": True} if mf.searchable else {}), + } + for mf in schema.machine_fields + ], + } + + def _load_schemas_from_db(self) -> None: + """Read all rows from ``v5_schema``, parse each, and build models. + + Read ordering matters for multi-process safety under PostgreSQL's + default READ COMMITTED isolation: version is read *before* schemas + so that a concurrent commit between the two reads leaves the cached + version behind (triggering a harmless reload next request) rather + than ahead (silently missing the new suite). ``_schema_version`` + is set *last* so that a failure during the rebuild leaves it stale, + causing the next ``ensure_fresh`` call to retry. + """ + session = self.sessionmaker() + try: + ver = session.query(V5SchemaVersion).get(1) + version = ver.version if ver else 0 + rows = session.query(V5Schema).all() + + new_suites: dict[str, V5TestSuiteDB] = {} + for row in rows: + data = json.loads(row.schema_json) + schema = parse_schema(data) + models = create_suite_models(schema) + tsdb = V5TestSuiteDB(self, schema, models) + new_suites[schema.name] = tsdb + + self.testsuite = new_suites + self._schema_version = version + finally: + session.close() + + def _check_schema_version(self, session: sqlalchemy.orm.Session) -> bool: + """Return True if the cached schema version is stale.""" + row = session.query(V5SchemaVersion).get(1) + current = row.version if row else 0 + return current != self._schema_version + + @staticmethod + def _bump_schema_version(session: sqlalchemy.orm.Session) -> None: + """Increment the version counter (caller must commit).""" + row = session.query(V5SchemaVersion).get(1) + if row is None: + row = V5SchemaVersion(id=1, version=1) + session.add(row) + else: + row.version = row.version + 1 + + def ensure_fresh(self, session: sqlalchemy.orm.Session) -> None: + """Reload schemas from the DB if the cached version is stale. + + Call this once per request (e.g. in middleware) so that all + endpoints see up-to-date test-suite definitions, even when + another worker created or deleted a suite. + """ + if self._check_schema_version(session): + self._load_schemas_from_db() + + def get_suite(self, name: str) -> V5TestSuiteDB | None: + """Return a suite by name, or None.""" + return self.testsuite.get(name) + + def create_suite( + self, + session: sqlalchemy.orm.Session, + schema: TestSuiteSchema, + ) -> V5TestSuiteDB: + """Persist a new suite schema in the DB and create its tables.""" + if schema.name in self.testsuite: + raise ValueError(f"suite {schema.name!r} already exists") + schema_dict = self._schema_to_dict(schema) + row = V5Schema( + name=schema.name, + schema_json=json.dumps(schema_dict), + created_at=utcnow(), + ) + session.add(row) + self._bump_schema_version(session) + session.flush() + + models = create_suite_models(schema) + models.base.metadata.create_all(self.engine) + tsdb = V5TestSuiteDB(self, schema, models) + self.testsuite[schema.name] = tsdb + + ver = session.query(V5SchemaVersion).get(1) + self._schema_version = ver.version if ver else 0 + + return tsdb + + def delete_suite( + self, + session: sqlalchemy.orm.Session, + name: str, + ) -> None: + """Delete a suite schema from the DB and drop its tables.""" + tsdb = self.testsuite.get(name) + if tsdb is None: + raise ValueError(f"suite {name!r} does not exist") + + row = session.query(V5Schema).get(name) + if row is not None: + session.delete(row) + self._bump_schema_version(session) + session.flush() + + tsdb.models.base.metadata.drop_all(self.engine) + del self.testsuite[name] + + ver = session.query(V5SchemaVersion).get(1) + self._schema_version = ver.version if ver else 0 + + # -- session helpers ------------------------------------------------------- + + def make_session(self, expire_on_commit: bool = True) -> sqlalchemy.orm.Session: + """Return a new SQLAlchemy session.""" + return self.sessionmaker(expire_on_commit=expire_on_commit) + + def close(self) -> None: + self.engine.dispose() + + +def _validate_regression_state(state: int) -> None: + """Raise ValueError if *state* is not a valid regression state.""" + if state not in VALID_REGRESSION_STATES: + raise ValueError( + f"invalid regression state {state!r}; " + f"valid states: {sorted(VALID_REGRESSION_STATES)}" + ) + + +class V5TestSuiteDB: + """Per-suite database operations for the v5 layer. + + Provides a clean CRUD interface consumed by the v5 API endpoints. + """ + + def __init__(self, v5db: V5DB, schema: TestSuiteSchema, models: SuiteModels): + self.v5db = v5db + self.name = schema.name + self.schema = schema + self.models = models + self._commit_field_names: frozenset[str] = frozenset(cf.name for cf in schema.commit_fields) + self._machine_field_names: frozenset[str] = frozenset(mf.name for mf in schema.machine_fields) + self._metric_names: frozenset[str] = frozenset(m.name for m in schema.metrics) + self.Commit = models.Commit + self.Machine = models.Machine + self.Run = models.Run + self.Test = models.Test + self.Sample = models.Sample + self.Profile = models.Profile + self.Regression = models.Regression + self.RegressionIndicator = models.RegressionIndicator + + # =================================================================== + # Field / metric validation + # =================================================================== + + def _validate_commit_fields(self, keys: Iterable[str]) -> None: + """Raise ValueError if *keys* contains names not in the schema.""" + unknown = set(keys) - self._commit_field_names + if unknown: + raise ValueError( + f"Unknown commit field(s): {', '.join(sorted(unknown))}. " + f"Valid names: {', '.join(sorted(self._commit_field_names))}" + ) + + def _validate_machine_fields(self, keys: Iterable[str]) -> None: + """Raise ValueError if *keys* contains names not in the schema.""" + unknown = set(keys) - self._machine_field_names + if unknown: + raise ValueError( + f"Unknown machine field(s): {', '.join(sorted(unknown))}. " + f"Valid names: {', '.join(sorted(self._machine_field_names))}" + ) + + def _validate_metric_names(self, keys: Iterable[str]) -> None: + """Raise ValueError if *keys* contains names not in the schema.""" + unknown = set(keys) - self._metric_names + if unknown: + raise ValueError( + f"Unknown metric(s): {', '.join(sorted(unknown))}. " + f"Valid names: {', '.join(sorted(self._metric_names))}" + ) + + # =================================================================== + # Commits + # =================================================================== + + def get_or_create_commit( + self, + session: sqlalchemy.orm.Session, + commit: str, + **metadata: Any, + ): + """Return existing Commit or create a new one. + + *metadata* keys correspond to ``commit_fields`` defined in the schema. + On creation, all metadata is stored. If the commit already exists, + metadata is NOT overwritten (first-write-wins). + """ + if not commit: + raise ValueError("commit string must be non-empty") + existing = ( + session.query(self.Commit) + .filter(self.Commit.commit == commit) + .first() + ) + if existing is not None: + return existing + + obj = self.Commit() + obj.commit = commit + # ordinal is always NULL on creation + self._validate_commit_fields(metadata.keys()) + for key, value in metadata.items(): + setattr(obj, key, value) + try: + with session.begin_nested(): + session.add(obj) + session.flush() + except sqlalchemy.exc.IntegrityError: + # Race condition: another session created it; fetch and return. + existing = ( + session.query(self.Commit) + .filter(self.Commit.commit == commit) + .first() + ) + if existing is None: + raise # pragma: no cover + return existing + return obj + + def get_commit( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + commit: str | None = None, + ): + """Fetch a single Commit by id or commit string. Returns None if not found.""" + q = session.query(self.Commit) + if id is not None: + return q.filter(self.Commit.id == id).first() + if commit is not None: + return q.filter(self.Commit.commit == commit).first() + raise ValueError("must specify id or commit") + + def get_commits_by_values( + self, + session: sqlalchemy.orm.Session, + commit_values: list[str], + ) -> list: + """Fetch multiple Commits by their commit strings in a single query. + + Returns a list of Commit objects for values that exist. + Order is not guaranteed. + """ + if not commit_values: + return [] + return ( + session.query(self.Commit) + .filter(self.Commit.commit.in_(commit_values)) + .all() + ) + + def update_commit( + self, + session: sqlalchemy.orm.Session, + commit_obj, + *, + ordinal: int | None = None, + clear_ordinal: bool = False, + tag: str | None = None, + clear_tag: bool = False, + **commit_fields: Any, + ): + """Update mutable fields on a Commit. + + *ordinal* sets the ordering position. Pass ``clear_ordinal=True`` + to explicitly set ordinal to ``None``. + *tag* sets the human-readable label. Pass ``clear_tag=True`` + to explicitly set tag to ``None``. + Additional keyword arguments correspond to ``commit_fields`` defined + in the schema and update the matching columns. + """ + if clear_ordinal: + commit_obj.ordinal = None + elif ordinal is not None: + commit_obj.ordinal = ordinal + if clear_tag: + commit_obj.tag = None + elif tag is not None: + commit_obj.tag = tag + self._validate_commit_fields(commit_fields.keys()) + for key, value in commit_fields.items(): + setattr(commit_obj, key, value) + session.flush() + return commit_obj + + def list_commits( + self, + session: sqlalchemy.orm.Session, + *, + search: str | None = None, + ordinal_range: tuple[int, int] | None = None, + limit: int | None = None, + ) -> list: + """List commits with optional search / ordinal-range filtering. + + *search* performs case-insensitive OR substring matching across the + ``commit`` column, the built-in ``tag`` column, and all ``searchable`` + commit_fields. + """ + q = session.query(self.Commit) + + if search: + escaped = _escape_like(search) + pattern = f"%{escaped}%" + clauses = [self.Commit.commit.ilike(pattern, escape="\\")] + clauses.append(self.Commit.tag.ilike(pattern, escape="\\")) + for cf in self.schema.searchable_commit_fields: + col = getattr(self.Commit, cf.name) + clauses.append(col.ilike(pattern, escape="\\")) + q = q.filter(or_(*clauses)) + + if ordinal_range is not None: + lo, hi = ordinal_range + q = q.filter( + self.Commit.ordinal.isnot(None), + self.Commit.ordinal >= lo, + self.Commit.ordinal <= hi, + ) + + q = q.order_by(self.Commit.id) + + q = q.limit(limit if limit is not None else DEFAULT_LIMIT) + + return q.all() + + def delete_commit( + self, + session: sqlalchemy.orm.Session, + commit_id: int, + ) -> None: + """Delete a commit by ID (cascades to runs and samples). + + Raises ``ValueError`` if any Regressions reference this commit + (via ``commit_id``). Those must be updated first. + """ + commit = session.query(self.Commit).get(commit_id) + if commit is None: + return + + reg_count = ( + session.query(self.Regression) + .filter(self.Regression.commit_id == commit_id) + .count() + ) + if reg_count > 0: + raise ValueError( + f"Cannot delete commit {commit_id}: " + f"{reg_count} Regression(s) reference it; " + f"clear their commit_id first" + ) + + session.delete(commit) + session.flush() + + # =================================================================== + # Machines + # =================================================================== + + def get_or_create_machine( + self, + session: sqlalchemy.orm.Session, + name: str, + *, + strategy: str = "reject", + parameters: dict[str, Any] | None = None, + **fields: Any, + ): + """Get or create a Machine by name. + + With ``strategy='reject'`` (default), raises ``ValueError`` if the + existing machine's schema-defined fields differ from *fields*. + + Uses a savepoint so that a concurrent insert by another session + does not invalidate earlier work in the same transaction. + """ + self._validate_machine_fields(fields.keys()) + existing = ( + session.query(self.Machine) + .filter(self.Machine.name == name) + .first() + ) + if existing is not None: + self._apply_machine_fields(existing, strategy, parameters, fields) + return existing + + machine = self.Machine() + machine.name = name + for key, value in fields.items(): + setattr(machine, key, value) + machine.parameters = parameters or {} + try: + with session.begin_nested(): + session.add(machine) + session.flush() + except sqlalchemy.exc.IntegrityError: + # Race condition: another session created it between our + # SELECT and INSERT. Re-query and apply field merge logic. + existing = ( + session.query(self.Machine) + .filter(self.Machine.name == name) + .first() + ) + if existing is None: + raise # pragma: no cover + self._apply_machine_fields(existing, strategy, parameters, fields) + return existing + return machine + + def _apply_machine_fields( + self, + machine, + strategy: str, + parameters: dict[str, Any] | None, + fields: dict[str, Any], + ): + """Apply field merge / parameter merge logic to an existing machine.""" + if strategy == "reject": + for key, value in fields.items(): + if value is None: + continue + existing_value = getattr(machine, key, None) + if existing_value is not None and existing_value != value: + raise ValueError( + f"Machine {machine.name!r}: field {key!r} changed " + f"from {existing_value!r} to {value!r}" + ) + if existing_value is None: + setattr(machine, key, value) + elif strategy == "update": + for key, value in fields.items(): + if value is not None: + setattr(machine, key, value) + if parameters: + merged = dict(machine.parameters or {}) + merged.update(parameters) + machine.parameters = merged + + def get_machine( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + name: str | None = None, + ): + """Fetch a single Machine by id or name.""" + q = session.query(self.Machine) + if id is not None: + return q.filter(self.Machine.id == id).first() + if name is not None: + return q.filter(self.Machine.name == name).first() + raise ValueError("must specify id or name") + + def list_machines( + self, + session: sqlalchemy.orm.Session, + *, + search: str | None = None, + limit: int | None = None, + ) -> list: + """List machines with optional search. + + *search* performs case-insensitive OR substring matching across + ``name`` and all ``searchable`` machine_fields. + """ + q = session.query(self.Machine) + if search: + escaped = _escape_like(search) + pattern = f"%{escaped}%" + clauses = [self.Machine.name.ilike(pattern, escape="\\")] + for mf in self.schema.searchable_machine_fields: + col = getattr(self.Machine, mf.name) + clauses.append(col.ilike(pattern, escape="\\")) + q = q.filter(or_(*clauses)) + return q.order_by(self.Machine.id).limit(limit if limit is not None else DEFAULT_LIMIT).all() + + def delete_machine(self, session: sqlalchemy.orm.Session, machine_id: int) -> None: + """Delete a machine by ID (cascades to runs and samples).""" + machine = session.query(self.Machine).get(machine_id) + if machine is not None: + session.delete(machine) + session.flush() + + def update_machine( + self, + session: sqlalchemy.orm.Session, + machine, + *, + name: str | None = None, + parameters: dict[str, Any] | None = None, + **fields: Any, + ): + """Update mutable fields on a Machine. + + *name* renames the machine (caller must ensure uniqueness). + *parameters* replaces the JSONB blob. + Additional keyword arguments update schema-defined machine_fields. + """ + if name is not None: + machine.name = name + if parameters is not None: + machine.parameters = parameters + self._validate_machine_fields(fields.keys()) + for key, value in fields.items(): + setattr(machine, key, value) + session.flush() + return machine + + # =================================================================== + # Runs + # =================================================================== + + def create_run( + self, + session: sqlalchemy.orm.Session, + machine, + *, + commit, + uuid: str | None = None, + submitted_at: datetime.datetime | None = None, + run_parameters: dict[str, Any] | None = None, + ): + """Create a new Run attached to *machine* and *commit*. + + *commit* is required -- every run must have a commit (design D2). + *uuid* is optional -- if provided, the run uses this UUID + (must already be validated and normalized to lowercase by the + caller); if ``None``, a random UUID v4 is generated. + """ + if commit is None: + raise ValueError("commit is required (every run must have a commit)") + run = self.Run() + run.uuid = uuid if uuid is not None else str(uuid_module.uuid4()) + run.machine_id = machine.id + run.commit_id = commit.id + run.submitted_at = submitted_at or utcnow() + run.run_parameters = run_parameters or {} + session.add(run) + session.flush() + return run + + def get_run( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + uuid: str | None = None, + ): + """Fetch a single Run by id or uuid. + + UUID lookup is case-insensitive (normalized to lowercase) per + RFC 9562. + """ + q = session.query(self.Run) + if id is not None: + return q.filter(self.Run.id == id).first() + if uuid is not None: + return q.filter(self.Run.uuid == uuid.lower()).first() + raise ValueError("must specify id or uuid") + + def list_runs( + self, + session: sqlalchemy.orm.Session, + *, + machine_id: int | None = None, + commit_id: int | None = None, + limit: int | None = None, + ) -> list: + """List runs with optional filters.""" + q = session.query(self.Run) + if machine_id is not None: + q = q.filter(self.Run.machine_id == machine_id) + if commit_id is not None: + q = q.filter(self.Run.commit_id == commit_id) + q = q.order_by(self.Run.id) + q = q.limit(limit if limit is not None else DEFAULT_LIMIT) + return q.all() + + def delete_run(self, session: sqlalchemy.orm.Session, run_id: int) -> None: + """Delete a run by ID (cascades to samples).""" + run = session.query(self.Run).get(run_id) + if run is not None: + session.delete(run) + session.flush() + + # =================================================================== + # Tests & Samples + # =================================================================== + + def get_or_create_tests( + self, + session: sqlalchemy.orm.Session, + names: Iterable[str], + ) -> dict[str, int]: + """Resolve test names to IDs, creating missing tests in bulk. + + Returns a ``{name: id}`` mapping for every name in *names*. + Duplicate names in *names* are handled (deduplicated internally). + + Uses INSERT ... ON CONFLICT DO NOTHING for safe concurrent creation. + + Note: this uses a Core INSERT, bypassing the ORM identity map. + Only integer IDs are returned, not ORM objects. + + Raises ``RuntimeError`` if a name cannot be resolved after insert + (indicates a concurrent DELETE, which should not happen in normal + operation). + """ + unique_names = list(set(names)) + if not unique_names: + return {} + + name_to_id: dict[str, int] = {} + + for chunk in batched(unique_names, _BATCH_CHUNK_SIZE): + existing = ( + session.query(self.Test.id, self.Test.name) + .filter(self.Test.name.in_(chunk)) + .all() + ) + for test_id, test_name in existing: + name_to_id[test_name] = test_id + + missing = [n for n in chunk if n not in name_to_id] + if not missing: + continue + + # ON CONFLICT DO NOTHING handles races with concurrent inserts. + stmt = ( + pg_insert(self.Test.__table__) + .values([{"name": n} for n in missing]) + .on_conflict_do_nothing(index_elements=["name"]) + ) + session.execute(stmt) + + # Re-SELECT to pick up rows from concurrent inserts we lost + # the race to (RETURNING only covers actually-inserted rows). + new_rows = ( + session.query(self.Test.id, self.Test.name) + .filter(self.Test.name.in_(missing)) + .all() + ) + for test_id, test_name in new_rows: + name_to_id[test_name] = test_id + + still_missing = [n for n in missing if n not in name_to_id] + if still_missing: + raise RuntimeError( + f"Failed to resolve test names after insert " + f"(concurrent DELETE?): {still_missing[:5]}" + ) + + return name_to_id + + def get_test( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + name: str | None = None, + ): + """Fetch a single Test by id or name. Returns None if not found.""" + q = session.query(self.Test) + if id is not None: + return q.filter(self.Test.id == id).first() + if name is not None: + return q.filter(self.Test.name == name).first() + raise ValueError("must specify id or name") + + def list_samples( + self, + session: sqlalchemy.orm.Session, + *, + run_id: int | None = None, + test_id: int | None = None, + limit: int | None = None, + ) -> list: + """List samples with optional filters.""" + q = session.query(self.Sample) + if run_id is not None: + q = q.filter(self.Sample.run_id == run_id) + if test_id is not None: + q = q.filter(self.Sample.test_id == test_id) + q = q.order_by(self.Sample.id) + q = q.limit(limit if limit is not None else DEFAULT_LIMIT) + return q.all() + + def create_samples( + self, + session: sqlalchemy.orm.Session, + run, + samples: list[dict[str, Any]], + ) -> None: + """Create Sample rows for *run*. + + Each dict in *samples* must have ``test_id`` plus metric fields. + Uses a Core multi-row INSERT for performance. + """ + if not samples: + return + + all_metric_keys: set[str] = set() + for s in samples: + all_metric_keys.update(s) + all_metric_keys.discard("test_id") + self._validate_metric_names(all_metric_keys) + + # Multi-row VALUES requires uniform keys across all dicts. + all_keys = {"run_id", "test_id"} | all_metric_keys + template = dict.fromkeys(all_keys) + template["run_id"] = run.id + rows = [{**template, **s} for s in samples] + + # Chunk to stay under psycopg2's 32,767 bind-parameter limit. + cols_per_row = len(all_keys) + chunk_size = max(1, _BATCH_CHUNK_SIZE // cols_per_row) + + for chunk in batched(rows, chunk_size): + stmt = pg_insert(self.Sample.__table__).values(chunk) + session.execute(stmt) + + # =================================================================== + # Profile CRUD + # =================================================================== + + _MAX_PROFILE_SIZE = 50 * 1024 * 1024 # 50 MB + + def _create_profiles( + self, + session: sqlalchemy.orm.Session, + run, + profiles: list[tuple], + ) -> list: + """Create Profile rows from (test_name, test_id, base64_data) tuples. + + Validates base64 encoding, size limit, and format version byte. + """ + from .profile import ProfileData, ProfileParseError + + created = [] + for test_name, test_id, b64_data in profiles: + if not isinstance(b64_data, str) or not b64_data: + raise ValueError( + f"profile for test '{test_name}' must be a non-empty string" + ) + try: + raw = base64.b64decode(b64_data) + except Exception: + raise ValueError( + f"invalid base64 in profile for test '{test_name}'" + ) + if len(raw) > self._MAX_PROFILE_SIZE: + raise ValueError( + f"profile for test '{test_name}' exceeds 50 MB size limit" + ) + try: + ProfileData.validate_version(raw) + except ProfileParseError as e: + raise ValueError( + f"invalid profile for test '{test_name}': {e}" + ) + p = self.Profile() + p.run_id = run.id + p.test_id = test_id + p.created_at = utcnow() + p.data = raw + created.append(p) + session.add_all(created) + session.flush() + return created + + def get_profile( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + uuid: str | None = None, + load_data: bool = False, + ): + """Fetch a single Profile by id or uuid. + + When *load_data* is True, eagerly loads the deferred ``data`` + column and joins the ``test`` and ``run`` relations to avoid + lazy-load queries. + """ + from sqlalchemy.orm import joinedload, undefer + + q = session.query(self.Profile) + if load_data: + q = q.options( + undefer(self.Profile.data), + joinedload(self.Profile.run), + joinedload(self.Profile.test), + ) + if id is not None: + return q.filter(self.Profile.id == id).first() + if uuid is not None: + return q.filter(self.Profile.uuid == uuid).first() + raise ValueError("must specify id or uuid") + + def get_profiles_for_run( + self, + session: sqlalchemy.orm.Session, + run, + ) -> list[tuple[str, str]]: + """Return (uuid, test_name) pairs for all profiles in a run. + + Uses column projection -- no ORM objects, no blob loading. + """ + return ( + session.query(self.Profile.uuid, self.Test.name) + .join(self.Test, self.Profile.test_id == self.Test.id) + .filter(self.Profile.run_id == run.id) + .all() + ) + + # =================================================================== + # Time-series query + # =================================================================== + + def query_time_series( + self, + session: sqlalchemy.orm.Session, + machine, + test, + metric: str, + *, + commit_range: tuple[int, int] | None = None, + time_range: tuple[datetime.datetime, datetime.datetime] | None = None, + sort: str | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Query time-series data for a (machine, test, metric) triple. + + Returns a list of dicts with keys: ``commit``, ``ordinal``, ``tag``, + ``value``, ``run_id``, ``submitted_at``. + + *commit_range* filters by ordinal [lo, hi]. When sorting by ordinal, + commits without ordinals are excluded. + """ + metric_col = getattr(self.Sample, metric, None) + if metric_col is None: + raise ValueError(f"unknown metric {metric!r}") + + q = ( + session.query( + self.Commit.commit, + self.Commit.ordinal, + self.Commit.tag, + metric_col.label("value"), + self.Run.id.label("run_id"), + self.Run.submitted_at, + ) + .select_from(self.Sample) + .join(self.Run, self.Sample.run_id == self.Run.id) + .join(self.Commit, self.Run.commit_id == self.Commit.id) + .filter(self.Run.machine_id == machine.id) + .filter(self.Sample.test_id == test.id) + .filter(metric_col.isnot(None)) + ) + + if commit_range is not None: + lo, hi = commit_range + q = q.filter( + self.Commit.ordinal.isnot(None), + self.Commit.ordinal >= lo, + self.Commit.ordinal <= hi, + ) + + if time_range is not None: + start, end = time_range + q = q.filter( + self.Run.submitted_at >= start, + self.Run.submitted_at <= end, + ) + + if sort == "ordinal": + q = q.filter(self.Commit.ordinal.isnot(None)) + q = q.order_by(self.Commit.ordinal) + elif sort == "submitted_at": + q = q.order_by(self.Run.submitted_at) + else: + q = q.order_by(self.Run.id) + + if limit is not None: + q = q.limit(limit) + + results = [] + for row in q.all(): + results.append({ + "commit": row.commit, + "ordinal": row.ordinal, + "tag": row.tag, + "value": row.value, + "run_id": row.run_id, + "submitted_at": row.submitted_at, + }) + return results + + def query_trends( + self, + session: sqlalchemy.orm.Session, + metric: str, + *, + machine_ids: list[int] | None = None, + last_n: int | None = None, + limit: int | None = None, + ) -> list[dict[str, Any]]: + """Query geomean-aggregated trend data grouped by (machine, commit). + + Computes ``exp(avg(ln(metric)))`` over all samples for each + (machine, commit) pair. Only positive metric values are included + (required for ``ln``). Only commits with a non-null ordinal are + included. + + *last_n* limits results to the most recent N commits by ordinal. + *limit* caps the total number of rows returned. + + Returns a list of dicts with keys: ``machine_name``, ``commit``, + ``ordinal``, ``tag``, ``value``, ``submitted_at``. + """ + from sqlalchemy import func + + metric_col = getattr(self.Sample, metric, None) + if metric_col is None: + raise ValueError(f"unknown metric {metric!r}") + + q = ( + session.query( + self.Machine.name.label("machine_name"), + self.Commit.id.label("commit_id"), + self.Commit.commit, + self.Commit.ordinal, + self.Commit.tag, + func.exp(func.avg(func.ln(metric_col))).label("value"), + func.max(self.Run.submitted_at).label("submitted_at"), + ) + .select_from(self.Sample) + .join(self.Run, self.Sample.run_id == self.Run.id) + .join(self.Commit, self.Run.commit_id == self.Commit.id) + .join(self.Machine, self.Run.machine_id == self.Machine.id) + .filter(metric_col > 0) + .filter(self.Commit.ordinal.isnot(None)) + ) + + if machine_ids: + q = q.filter(self.Machine.id.in_(machine_ids)) + + if last_n is not None: + # Find the ordinal cutoff: the Nth-highest ordinal. + # Commit.ordinal has a unique constraint -> implicit B-tree index. + cutoff = ( + session.query(self.Commit.ordinal) + .filter(self.Commit.ordinal.isnot(None)) + .order_by(self.Commit.ordinal.desc()) + .offset(last_n - 1) + .limit(1) + .scalar() + ) + if cutoff is not None: + q = q.filter(self.Commit.ordinal >= cutoff) + # If cutoff is None, fewer than last_n commits exist -- return all. + + q = q.group_by( + self.Machine.name, self.Commit.id, + self.Commit.commit, self.Commit.ordinal, + self.Commit.tag, + ) + + q = q.order_by(self.Machine.name, self.Commit.ordinal.asc()) + + if limit is not None: + q = q.limit(limit) + + results = [] + for row in q.all(): + results.append({ + "machine_name": row.machine_name, + "commit": row.commit, + "ordinal": row.ordinal, + "tag": row.tag, + "value": row.value, + "submitted_at": row.submitted_at, + }) + return results + + # =================================================================== + # Regressions (CRUD) + # =================================================================== + + def create_regression( + self, + session: sqlalchemy.orm.Session, + title: str | None, + indicators: list[dict[str, Any]], + *, + bug: str | None = None, + notes: str | None = None, + commit: Any | None = None, + state: int = 0, + ): + """Create a Regression with the given indicators. + + Each dict in *indicators* must have keys ``machine_id`` (int), + ``test_id`` (int), and ``metric`` (str). + + *commit* is an optional Commit object whose id is stored on the + Regression (nullable FK). + """ + _validate_regression_state(state) + reg = self.Regression() + reg.uuid = str(uuid_module.uuid4()) + reg.title = title + reg.bug = bug + reg.notes = notes + reg.state = state + reg.commit_id = commit.id if commit is not None else None + session.add(reg) + session.flush() + + ri_objects = [] + for ind in indicators: + ri_objects.append( + self._build_indicator(reg.id, ind["machine_id"], + ind["test_id"], ind["metric"])) + session.add_all(ri_objects) + session.flush() + return reg + + def get_regression( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + uuid: str | None = None, + ): + """Fetch a single Regression by id or uuid.""" + q = session.query(self.Regression) + if id is not None: + return q.filter(self.Regression.id == id).first() + if uuid is not None: + return q.filter(self.Regression.uuid == uuid).first() + raise ValueError("must specify id or uuid") + + _UNSET = object() + + def update_regression( + self, + session: sqlalchemy.orm.Session, + regression, + *, + title: Any = _UNSET, + bug: Any = _UNSET, + notes: Any = _UNSET, + commit: Any = _UNSET, + state: int | None = None, + ): + """Update mutable fields on a Regression. + + For *title*, *bug*, *notes*, and *commit*: pass a value to set, + ``None`` to clear, or omit (default ``_UNSET``) to leave unchanged. + *state* uses ``None`` as "leave unchanged" (state cannot be null). + """ + if title is not self._UNSET: + regression.title = title + if bug is not self._UNSET: + regression.bug = bug + if notes is not self._UNSET: + regression.notes = notes + if commit is not self._UNSET: + regression.commit_id = commit.id if commit is not None else None + if state is not None: + _validate_regression_state(state) + regression.state = state + session.flush() + return regression + + def list_regressions( + self, + session: sqlalchemy.orm.Session, + *, + state: int | None = None, + limit: int | None = None, + ) -> list: + """List regressions, optionally filtered by state.""" + q = session.query(self.Regression) + if state is not None: + q = q.filter(self.Regression.state == state) + return q.order_by(self.Regression.id).limit(limit if limit is not None else DEFAULT_LIMIT).all() + + def delete_regression( + self, + session: sqlalchemy.orm.Session, + regression_id: int, + ) -> None: + """Delete a regression by ID (cascades to indicators).""" + reg = session.query(self.Regression).get(regression_id) + if reg is not None: + session.delete(reg) + session.flush() + + def _build_indicator(self, regression_id, machine_id, test_id, metric): + """Construct a RegressionIndicator object (not yet added to session).""" + ri = self.RegressionIndicator() + ri.uuid = str(uuid_module.uuid4()) + ri.regression_id = regression_id + ri.machine_id = machine_id + ri.test_id = test_id + ri.metric = metric + return ri + + def add_regression_indicator( + self, + session: sqlalchemy.orm.Session, + regression, + machine_id: int, + test_id: int, + metric: str, + ): + """Add an indicator to a Regression. + + Returns the created RegressionIndicator. Raises + ``sqlalchemy.exc.IntegrityError`` if the (regression, machine, + test, metric) combination already exists. + """ + ri = self._build_indicator(regression.id, machine_id, test_id, metric) + session.add(ri) + session.flush() + return ri + + def add_regression_indicators_batch( + self, + session: sqlalchemy.orm.Session, + regression, + indicators: list[dict[str, Any]], + ) -> list: + """Add multiple indicators to a Regression, silently ignoring duplicates. + + Each dict must have keys ``machine_id``, ``test_id``, ``metric``. + Returns the list of newly created RegressionIndicator objects + (excludes duplicates that were skipped). + + Note: the check-then-insert has a TOCTOU window under concurrent + access. The unique constraint catches this at the DB level; the + API layer is expected to serialize regression updates. + """ + # Fetch all existing indicators for this regression in one query. + existing_rows = ( + session.query( + self.RegressionIndicator.machine_id, + self.RegressionIndicator.test_id, + self.RegressionIndicator.metric, + ) + .filter_by(regression_id=regression.id) + .all() + ) + existing_keys = { + (r.machine_id, r.test_id, r.metric) for r in existing_rows + } + + created = [] + for ind in indicators: + key = (ind["machine_id"], ind["test_id"], ind["metric"]) + if key in existing_keys: + continue + ri = self._build_indicator( + regression.id, ind["machine_id"], + ind["test_id"], ind["metric"]) + session.add(ri) + created.append(ri) + existing_keys.add(key) + session.flush() + return created + + def remove_regression_indicator( + self, + session: sqlalchemy.orm.Session, + regression_id: int, + indicator_uuid: str, + ) -> bool: + """Remove an indicator from a regression by UUID. + + Returns True if an indicator was removed, False if none matched. + """ + count = ( + session.query(self.RegressionIndicator) + .filter( + self.RegressionIndicator.regression_id == regression_id, + self.RegressionIndicator.uuid == indicator_uuid, + ) + .delete() + ) + if count: + session.flush() + return count > 0 + + def get_regression_indicator( + self, + session: sqlalchemy.orm.Session, + *, + id: int | None = None, + uuid: str | None = None, + ): + """Fetch a single RegressionIndicator by id or uuid.""" + q = session.query(self.RegressionIndicator) + if id is not None: + return q.filter(self.RegressionIndicator.id == id).first() + if uuid is not None: + return q.filter(self.RegressionIndicator.uuid == uuid).first() + raise ValueError("must specify id or uuid") + + # =================================================================== + # Bulk import (run submission) -- helpers + # =================================================================== + + def _parse_machine_data( + self, + data: dict[str, Any], + ) -> tuple[str, dict[str, Any], dict[str, Any]]: + """Extract machine name, schema-defined fields, and extra parameters + from the submission data. + + Returns ``(name, fields, params)``. + """ + machine_data = data.get("machine", {}) + machine_name = machine_data.get("name") + if not machine_name: + raise ValueError("machine.name is required") + + valid_machine_fields = self._machine_field_names + machine_fields: dict[str, Any] = {} + machine_params: dict[str, Any] = {} + for key, value in machine_data.items(): + if key == "name": + continue + if key in valid_machine_fields: + machine_fields[key] = value + else: + machine_params[key] = value + + return machine_name, machine_fields, machine_params + + def _parse_commit_data( + self, + session: sqlalchemy.orm.Session, + data: dict[str, Any], + ): + """Extract and get-or-create the Commit from the submission data. + + Returns the Commit object. + """ + commit_str = data.get("commit") + if not commit_str: + raise ValueError("commit is required (every run must have a commit)") + commit_field_data = data.get("commit_fields", {}) + return self.get_or_create_commit(session, commit_str, **commit_field_data) + + def _parse_tests_data( + self, + session: sqlalchemy.orm.Session, + data: dict[str, Any], + run, + ) -> None: + """Parse test entries and create all samples in a single batch. + + Metric values may be scalars or lists. A list value (e.g. + ``"execution_time": [0.1, 0.2]``) creates one Sample per element. + All list values in a single test entry must have the same length; + scalar values are repeated across the resulting Samples. + """ + tests_data = data.get("tests", []) + all_samples: list[dict[str, Any]] = [] + all_profiles: list[tuple] = [] # (test, base64_data) + + all_test_names: list[str] = [] + for test_entry in tests_data: + test_name = test_entry.get("name") + if not test_name: + raise ValueError("each test entry must have a 'name'") + all_test_names.append(test_name) + + name_to_id = self.get_or_create_tests(session, all_test_names) + + for test_entry in tests_data: + test_name = test_entry["name"] + test_id = name_to_id[test_name] + + # "profile" is a reserved key for profile binary data, not a metric. + self._validate_metric_names(test_entry.keys() - {"name", "profile"}) + metrics: dict[str, Any] = {} + profile_data_b64 = None + for key, value in test_entry.items(): + if key == "name": + continue + if key == "profile": + profile_data_b64 = value + continue + metrics[key] = value + + list_len = None + for key, value in metrics.items(): + if isinstance(value, list): + if list_len is None: + list_len = len(value) + elif len(value) != list_len: + raise ValueError( + f"metric lists for test '{test_name}' have " + f"inconsistent lengths" + ) + + if list_len is not None and list_len == 0: + raise ValueError( + f"metric lists for test '{test_name}' must not be empty" + ) + + if list_len is None: + sample_dict: dict[str, Any] = {"test_id": test_id} + sample_dict.update(metrics) + all_samples.append(sample_dict) + else: + for i in range(list_len): + sample_dict = {"test_id": test_id} + for key, value in metrics.items(): + sample_dict[key] = value[i] if isinstance(value, list) else value + all_samples.append(sample_dict) + + if profile_data_b64 is not None: + all_profiles.append((test_name, test_id, profile_data_b64)) + + if all_samples: + self.create_samples(session, run, all_samples) + + if all_profiles: + self._create_profiles(session, run, all_profiles) + + # =================================================================== + # Bulk import (run submission) + # =================================================================== + + def import_run( + self, + session: sqlalchemy.orm.Session, + data: dict[str, Any], + *, + machine_strategy: str = "reject", + ): + """Import a run from the v5 submission format. + + See the implementation plan (Phase 1c) for the expected JSON schema. + + Returns the created Run. + """ + fmt = data.get("format_version") + if fmt != "5": + raise ValueError( + f"format_version is required and must be '5', got {fmt!r}" + ) + + # -- Machine -------------------------------------------------------- + machine_name, machine_fields, machine_params = self._parse_machine_data(data) + machine = self.get_or_create_machine( + session, + machine_name, + strategy=machine_strategy, + parameters=machine_params if machine_params else None, + **machine_fields, + ) + + # -- Commit (required) ---------------------------------------------- + commit_obj = self._parse_commit_data(session, data) + + # -- Run ------------------------------------------------------------ + run_parameters = data.get("run_parameters", {}) + client_uuid = data.get("uuid") + run = self.create_run( + session, + machine, + commit=commit_obj, + uuid=client_uuid, + run_parameters=run_parameters, + ) + + # -- Tests & Samples (batched) -------------------------------------- + self._parse_tests_data(session, data, run) + + return run diff --git a/lnt/server/db/v5/models.py b/lnt/server/db/v5/models.py new file mode 100644 index 000000000..2895d4cfe --- /dev/null +++ b/lnt/server/db/v5/models.py @@ -0,0 +1,454 @@ +""" +Dynamic SQLAlchemy model factory for v5 per-suite tables. + +Creates model classes at runtime based on a :class:`TestSuiteSchema`, producing +per-suite tables such as ``nts_Commit``, ``nts_Machine``, etc. + +Postgres only. SQLAlchemy 1.3 style (Column, relation, declarative_base). +""" + +from __future__ import annotations + +import datetime +import uuid as uuid_module +from dataclasses import dataclass +from typing import Any + +import sqlalchemy +import sqlalchemy.ext.declarative +from sqlalchemy import ( + Boolean, + Column, + DateTime, + Float, + ForeignKey, + Index, + Integer, + LargeBinary, + String, + Text, + UniqueConstraint, +) +from sqlalchemy.dialects.postgresql import JSONB +from sqlalchemy.orm import deferred, relation + +from .schema import TestSuiteSchema + + +# --------------------------------------------------------------------------- +# Utilities +# --------------------------------------------------------------------------- + +def utcnow(): + """Return the current UTC time as a timezone-aware datetime.""" + return datetime.datetime.now(datetime.timezone.utc) + + +# --------------------------------------------------------------------------- +# Global tables (shared across all suites) +# --------------------------------------------------------------------------- + +_global_base = sqlalchemy.ext.declarative.declarative_base() + + +class V5Schema(_global_base): # type: ignore[misc] + """Persisted test suite schema (one row per suite).""" + __tablename__ = "v5_schema" + name = Column("name", String(256), primary_key=True) + schema_json = Column("schema_json", Text, nullable=False) + created_at = Column("created_at", DateTime(timezone=True), nullable=False) + + +class V5SchemaVersion(_global_base): # type: ignore[misc] + """Single-row counter for multi-process schema cache invalidation.""" + __tablename__ = "v5_schema_version" + id = Column("id", Integer, primary_key=True) + version = Column("version", Integer, nullable=False) + + +class APIKey(_global_base): # type: ignore[misc] + """API key for v5 REST API authentication.""" + __tablename__ = "api_key" + id = Column("id", Integer, primary_key=True) + name = Column("name", String(256), nullable=False) + key_prefix = Column("key_prefix", String(8), nullable=False) + key_hash = Column("key_hash", String(64), nullable=False, unique=True, + index=True) + scope = Column("scope", String(32), nullable=False) + created_at = Column("created_at", DateTime(timezone=True), nullable=False) + last_used_at = Column("last_used_at", DateTime(timezone=True), nullable=True) + is_active = Column("is_active", Boolean, nullable=False, default=True) + + +def create_global_tables(engine) -> None: + """Create the global v5 tables (schema, schema_version, api_key).""" + _global_base.metadata.create_all(engine) + + +# --------------------------------------------------------------------------- +# Column type mapping +# --------------------------------------------------------------------------- + +_COMMIT_FIELD_TYPE_MAP: dict[str, Any] = { + "default": lambda: String(256), + "text": lambda: Text, + "integer": lambda: Integer, + "datetime": lambda: DateTime(timezone=True), +} + +_METRIC_TYPE_MAP: dict[str, Any] = { + "real": lambda: Float, + "status": lambda: Integer, + "hash": lambda: String(256), +} + + +# --------------------------------------------------------------------------- +# Public dataclass holding all generated models for a single test suite +# --------------------------------------------------------------------------- + +@dataclass +class SuiteModels: + """Container for all SQLAlchemy model classes of a single test suite.""" + base: Any # declarative base + Commit: Any = None + Machine: Any = None + Run: Any = None + Test: Any = None + Sample: Any = None + Profile: Any = None + Regression: Any = None + RegressionIndicator: Any = None + + +# --------------------------------------------------------------------------- +# Factory +# --------------------------------------------------------------------------- + +def create_suite_models(schema: TestSuiteSchema) -> SuiteModels: + """Build SQLAlchemy model classes for the given *schema*. + + Each suite gets its own ``declarative_base()`` so that per-suite tables + can be created independently. + """ + base = sqlalchemy.ext.declarative.declarative_base() + prefix = schema.name + + # ----------------------------------------------------------------------- + # Commit + # ----------------------------------------------------------------------- + commit_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Commit", + "id": Column("id", Integer, primary_key=True), + "commit": Column("commit", String(256), unique=True, nullable=False), + "ordinal": Column("ordinal", Integer, nullable=True), + "tag": Column("tag", String(256), nullable=True), + "__table_args__": ( + UniqueConstraint( + "ordinal", + name=f"{prefix}_Commit_ordinal_unique", + ), + Index( + f"{prefix}_Commit_tag_idx", + "tag", + postgresql_where=sqlalchemy.text("tag IS NOT NULL"), + ), + ), + } + # Dynamic commit_fields columns + for cf in schema.commit_fields: + if cf.name in commit_attrs: + raise ValueError( + f"commit_fields name {cf.name!r} collides with a built-in column" + ) + col_type = _COMMIT_FIELD_TYPE_MAP[cf.type]() + commit_attrs[cf.name] = Column( + cf.name, col_type, nullable=True, index=cf.searchable, + ) + + Commit = type("Commit", (base,), commit_attrs) + + # ----------------------------------------------------------------------- + # Machine + # ----------------------------------------------------------------------- + machine_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Machine", + "id": Column("id", Integer, primary_key=True), + "name": Column("name", String(256), unique=True, nullable=False), + "parameters": Column( + "parameters", JSONB, nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + } + for mf in schema.machine_fields: + if mf.name in machine_attrs: + raise ValueError( + f"machine_fields name {mf.name!r} collides with a built-in column" + ) + machine_attrs[mf.name] = Column( + mf.name, String(256), nullable=True, index=mf.searchable, + ) + + Machine = type("Machine", (base,), machine_attrs) + + # ----------------------------------------------------------------------- + # Run + # ----------------------------------------------------------------------- + run_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Run", + "id": Column("id", Integer, primary_key=True), + "uuid": Column( + "uuid", String(36), unique=True, nullable=False, index=True, + default=lambda: str(uuid_module.uuid4()), + ), + "machine_id": Column( + "machine_id", Integer, + ForeignKey(f"{prefix}_Machine.id", ondelete="CASCADE"), + nullable=False, index=True, + ), + "commit_id": Column( + "commit_id", Integer, + ForeignKey(f"{prefix}_Commit.id", ondelete="CASCADE"), + nullable=False, index=True, + ), + "submitted_at": Column("submitted_at", DateTime(timezone=True), nullable=False, index=True), + "run_parameters": Column( + "run_parameters", JSONB, nullable=False, + server_default=sqlalchemy.text("'{}'::jsonb"), + ), + "machine": relation("Machine", foreign_keys=f"{prefix}_Run.c.machine_id"), + "commit_obj": relation("Commit", foreign_keys=f"{prefix}_Run.c.commit_id"), + } + + Run = type("Run", (base,), run_attrs) + + # Compound index on (machine_id, commit_id) for time-series join pattern + Index( + f"ix_{prefix}_Run_machine_id_commit_id", + Run.machine_id, Run.commit_id, # type: ignore[attr-defined] + ) + + # ----------------------------------------------------------------------- + # Test + # ----------------------------------------------------------------------- + test_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Test", + "id": Column("id", Integer, primary_key=True), + "name": Column("name", String(256), unique=True, nullable=False), + } + Test = type("Test", (base,), test_attrs) + + # ----------------------------------------------------------------------- + # Sample + # ----------------------------------------------------------------------- + sample_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Sample", + "id": Column("id", Integer, primary_key=True), + "run_id": Column( + "run_id", Integer, + ForeignKey(f"{prefix}_Run.id", ondelete="CASCADE"), + nullable=False, + ), + "test_id": Column( + "test_id", Integer, + ForeignKey(f"{prefix}_Test.id", ondelete="CASCADE"), + nullable=False, + ), + "run": relation("Run", foreign_keys=f"{prefix}_Sample.c.run_id"), + "test": relation("Test", foreign_keys=f"{prefix}_Sample.c.test_id"), + } + # Dynamic metric columns + for metric in schema.metrics: + if metric.name in sample_attrs: + raise ValueError( + f"metric name {metric.name!r} collides with a built-in column" + ) + col_type = _METRIC_TYPE_MAP[metric.type]() + sample_attrs[metric.name] = Column(metric.name, col_type, nullable=True) + + Sample = type("Sample", (base,), sample_attrs) + + # Covers the most common sample query: "all metrics for a given run+test pair" + # Compound index on (run_id, test_id) + Index( + f"ix_{prefix}_Sample_run_id_test_id", + Sample.run_id, Sample.test_id, # type: ignore[attr-defined] + ) + + # Covers time-series queries: "all samples for a given test across runs" + Index( + f"ix_{prefix}_Sample_test_id_run_id", + Sample.test_id, Sample.run_id, # type: ignore[attr-defined] + ) + + # ----------------------------------------------------------------------- + # Profile + # ----------------------------------------------------------------------- + profile_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Profile", + "id": Column("id", Integer, primary_key=True), + "uuid": Column( + "uuid", String(36), unique=True, nullable=False, index=True, + default=lambda: str(uuid_module.uuid4()), + ), + "run_id": Column( + "run_id", Integer, + ForeignKey(f"{prefix}_Run.id", ondelete="CASCADE"), + nullable=False, + ), + "test_id": Column( + "test_id", Integer, + ForeignKey(f"{prefix}_Test.id", ondelete="CASCADE"), + nullable=False, index=True, + ), + "created_at": Column( + "created_at", DateTime(timezone=True), nullable=False, + ), + "data": deferred(Column("data", LargeBinary, nullable=False)), + "run": relation("Run", foreign_keys=f"{prefix}_Profile.c.run_id"), + "test": relation("Test", foreign_keys=f"{prefix}_Profile.c.test_id"), + "__table_args__": ( + UniqueConstraint( + "run_id", "test_id", + name=f"{prefix}_Profile_run_test_unique", + ), + ), + } + Profile = type("Profile", (base,), profile_attrs) + + # ----------------------------------------------------------------------- + # Regression + # ----------------------------------------------------------------------- + reg_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_Regression", + "id": Column("id", Integer, primary_key=True), + "uuid": Column( + "uuid", String(36), unique=True, nullable=False, index=True, + default=lambda: str(uuid_module.uuid4()), + ), + "title": Column("title", String(256), nullable=True), + "bug": Column("bug", String(256), nullable=True), + "notes": Column("notes", Text, nullable=True), + "state": Column("state", Integer, nullable=False, index=True), + "commit_id": Column( + "commit_id", Integer, + ForeignKey(f"{prefix}_Commit.id"), + nullable=True, index=True, + ), + "commit_obj": relation( + "Commit", foreign_keys=f"{prefix}_Regression.c.commit_id", + ), + } + Regression = type("Regression", (base,), reg_attrs) + + # ----------------------------------------------------------------------- + # RegressionIndicator + # ----------------------------------------------------------------------- + ri_attrs: dict[str, Any] = { + "__tablename__": f"{prefix}_RegressionIndicator", + "id": Column("id", Integer, primary_key=True), + "uuid": Column( + "uuid", String(36), unique=True, nullable=False, index=True, + default=lambda: str(uuid_module.uuid4()), + ), + "regression_id": Column( + "regression_id", Integer, + ForeignKey(f"{prefix}_Regression.id", ondelete="CASCADE"), + nullable=False, index=True, + ), + "machine_id": Column( + "machine_id", Integer, + ForeignKey(f"{prefix}_Machine.id"), + nullable=False, index=True, + ), + "test_id": Column( + "test_id", Integer, + ForeignKey(f"{prefix}_Test.id"), + nullable=False, index=True, + ), + "metric": Column("metric", String(256), nullable=False), + "__table_args__": ( + UniqueConstraint( + "regression_id", "machine_id", "test_id", "metric", + name=f"uq_{prefix}_ri_reg_machine_test_metric", + ), + ), + "regression": relation( + "Regression", + foreign_keys=f"{prefix}_RegressionIndicator.c.regression_id", + ), + "machine": relation( + "Machine", + foreign_keys=f"{prefix}_RegressionIndicator.c.machine_id", + ), + "test": relation( + "Test", + foreign_keys=f"{prefix}_RegressionIndicator.c.test_id", + ), + } + RegressionIndicator = type("RegressionIndicator", (base,), ri_attrs) + + # ----------------------------------------------------------------------- + # Back-references (added after all classes exist) + # ----------------------------------------------------------------------- + Machine.runs = relation( # type: ignore[attr-defined] + Run, + foreign_keys=[Run.machine_id], # type: ignore[attr-defined] + back_populates="machine", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Commit.runs = relation( # type: ignore[attr-defined] + Run, + foreign_keys=[Run.commit_id], # type: ignore[attr-defined] + back_populates="commit_obj", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Run.samples = relation( # type: ignore[attr-defined] + Sample, + foreign_keys=[Sample.run_id], # type: ignore[attr-defined] + back_populates="run", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Test.samples = relation( # type: ignore[attr-defined] + Sample, + foreign_keys=[Sample.test_id], # type: ignore[attr-defined] + back_populates="test", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Regression.indicators = relation( # type: ignore[attr-defined] + RegressionIndicator, + foreign_keys=[RegressionIndicator.regression_id], # type: ignore[attr-defined] + back_populates="regression", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Run.profiles = relation( # type: ignore[attr-defined] + Profile, + foreign_keys=[Profile.run_id], # type: ignore[attr-defined] + back_populates="run", + cascade="all, delete-orphan", + passive_deletes=True, + ) + Test.profiles = relation( # type: ignore[attr-defined] + Profile, + foreign_keys=[Profile.test_id], # type: ignore[attr-defined] + back_populates="test", + cascade="all, delete-orphan", + passive_deletes=True, + ) + + return SuiteModels( + base=base, + Commit=Commit, + Machine=Machine, + Run=Run, + Test=Test, + Sample=Sample, + Profile=Profile, + Regression=Regression, + RegressionIndicator=RegressionIndicator, + ) diff --git a/lnt/server/db/v5/profile.py b/lnt/server/db/v5/profile.py new file mode 100644 index 000000000..04a4f7a40 --- /dev/null +++ b/lnt/server/db/v5/profile.py @@ -0,0 +1,320 @@ +""" +Profile binary format parser for LNT v5. + +Read-only parser for the LNT profile binary format (wire-compatible with +the v4 "ProfileV2" format). Provides lazy decompression: metadata and +function indices are available immediately, while per-instruction data +(addresses, counters, disassembly text) is decompressed on first access. + +The server only reads profiles -- no serialization support is provided. +""" + +from __future__ import annotations + +import bz2 +import io +import struct +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Exceptions +# --------------------------------------------------------------------------- + +class ProfileParseError(Exception): + """Raised when profile binary data is corrupt or invalid.""" + + +# --------------------------------------------------------------------------- +# Primitive readers +# --------------------------------------------------------------------------- + +def read_uleb128(f: io.BufferedIOBase | io.BytesIO) -> int: + """Read a ULEB128-encoded unsigned integer.""" + n = 0 + shift = 0 + while True: + raw = f.read(1) + if not raw: + raise ProfileParseError("unexpected end of data in ULEB128") + b = raw[0] + n |= (b & 0x7F) << shift + shift += 7 + if (b & 0x80) == 0: + return n + + +def _read_string(f: io.BufferedIOBase | io.BytesIO) -> str: + """Read a newline-terminated UTF-8 string.""" + line = f.readline() + if not line or not line.endswith(b"\n"): + raise ProfileParseError("unexpected end of data in string") + return line[:-1].decode("utf-8") + + +def _read_float(f: io.BufferedIOBase | io.BytesIO) -> float: + """Read a float stored as ULEB128 bit-pattern.""" + num = read_uleb128(f) + if num == 0: + return 0.0 + packed = struct.pack(">I", num & 0xFFFFFFFF) + return struct.unpack(">f", packed)[0] + + +# --------------------------------------------------------------------------- +# Data classes +# --------------------------------------------------------------------------- + +@dataclass +class FunctionInfo: + """Metadata for a single function in a profile.""" + counters: dict[str, float] + length: int + + +@dataclass +class Instruction: + """Per-instruction data from a profile.""" + address: int + counters: dict[str, float] + text: str + + +# --------------------------------------------------------------------------- +# Profile parser +# --------------------------------------------------------------------------- + +class ProfileData: + """Read-only profile binary format parser with lazy decompression. + + The binary format consists of 8 sections. Sections 0-2 and 7 + (Header, CounterNamePool, TopLevelCounters, Functions) are + uncompressed and read eagerly. Sections 3-6 (LineCounters, + LineAddresses, LineText, TextPool) are BZ2-compressed and read + lazily on first call to :meth:`get_code_for_function`. + """ + + def __init__(self) -> None: + # Eagerly parsed (uncompressed sections) + self._disassembly_format: str = "" + self._counter_names: dict[int, str] = {} + self._top_level_counters: dict[str, int] = {} + self._functions: dict[str, FunctionInfo] = {} + # Per-function offsets into the compressed sections + self._fn_lc_offset: dict[str, int] = {} + self._fn_la_offset: dict[str, int] = {} + self._fn_lt_offset: dict[str, int] = {} + # Raw compressed bytes (decompressed lazily) + self._raw_line_counters: bytes = b"" + self._raw_line_addresses: bytes = b"" + self._raw_line_text: bytes = b"" + self._raw_text_pool: bytes = b"" + # Decompressed caches (None = not yet decompressed) + self._line_counters: bytes | None = None + self._line_addresses: bytes | None = None + self._line_text: bytes | None = None + self._text_pool: bytes | None = None + + # -- Public API -------------------------------------------------------- + + @staticmethod + def validate_version(data: bytes) -> None: + """Validate that *data* starts with a supported version byte. + + Raises :class:`ProfileParseError` if the data is empty or the + version is not 2. This is a lightweight check for use during + submission -- it does not parse the full blob. + """ + if not data or data[0] != 2: + version = data[0] if data else "empty" + raise ProfileParseError( + f"unsupported profile format version {version} (expected 2)" + ) + + @staticmethod + def deserialize(data: bytes) -> ProfileData: + """Parse a profile binary blob. + + Raises :class:`ProfileParseError` on invalid or corrupt data. + """ + f = io.BytesIO(data) + + # Version + try: + version = read_uleb128(f) + except ProfileParseError: + raise ProfileParseError("empty or truncated profile data") + if version != 2: + raise ProfileParseError( + f"unsupported profile format version {version} (expected 2)" + ) + + # Read all 8 section headers. + try: + return ProfileData._parse_sections(f) + except (KeyError, struct.error) as e: + raise ProfileParseError(f"corrupt profile data: {e}") from e + + @staticmethod + def _parse_sections(f: io.BytesIO) -> ProfileData: + """Parse section headers and data from a positioned stream.""" + p = ProfileData() + + # Sections 0-5,7: offset (ULEB128) + size (ULEB128) + # Section 6 (TextPool): offset + size + pool_fname (string) + headers: list[tuple[int, int]] = [] + for i in range(8): + offset = read_uleb128(f) + size = read_uleb128(f) + if i == 6: # TextPool: extra pool_fname string + _read_string(f) # discard (always empty) + headers.append((offset, size)) + + data_start = f.tell() + + def _section_bytes(idx: int) -> bytes: + off, sz = headers[idx] + f.seek(data_start + off) + return f.read(sz) + + # -- Section 0: Header (uncompressed) ------------------------------ + sec = io.BytesIO(_section_bytes(0)) + p._disassembly_format = _read_string(sec) + + # -- Section 1: CounterNamePool (uncompressed) --------------------- + sec = io.BytesIO(_section_bytes(1)) + n_names = read_uleb128(sec) + for i in range(n_names): + p._counter_names[i] = _read_string(sec) + + # -- Section 2: TopLevelCounters (uncompressed) -------------------- + sec = io.BytesIO(_section_bytes(2)) + n_counters = read_uleb128(sec) + for _ in range(n_counters): + idx = read_uleb128(sec) + val = read_uleb128(sec) + p._top_level_counters[p._counter_names[idx]] = val + + # -- Sections 3-6: store raw compressed bytes (lazy) --------------- + p._raw_line_counters = _section_bytes(3) + p._raw_line_addresses = _section_bytes(4) + p._raw_line_text = _section_bytes(5) + p._raw_text_pool = _section_bytes(6) + + # -- Section 7: Functions (uncompressed) --------------------------- + sec = io.BytesIO(_section_bytes(7)) + n_functions = read_uleb128(sec) + for _ in range(n_functions): + name = _read_string(sec) + length = read_uleb128(sec) + lc_off = read_uleb128(sec) + la_off = read_uleb128(sec) + lt_off = read_uleb128(sec) + counters: dict[str, float] = {} + n_fn_counters = read_uleb128(sec) + for _ in range(n_fn_counters): + cidx = read_uleb128(sec) + cval = _read_float(sec) + counters[p._counter_names[cidx]] = cval + p._functions[name] = FunctionInfo(counters=counters, length=length) + p._fn_lc_offset[name] = lc_off + p._fn_la_offset[name] = la_off + p._fn_lt_offset[name] = lt_off + + return p + + def get_disassembly_format(self) -> str: + """Return the disassembly format string (e.g., ``'llvm-objdump'``).""" + return self._disassembly_format + + def get_top_level_counters(self) -> dict[str, int]: + """Return aggregate counters for the entire profile. + + No BZ2 decompression needed. + """ + return dict(self._top_level_counters) + + def get_functions(self) -> dict[str, FunctionInfo]: + """Return function metadata keyed by name. + + No BZ2 decompression needed. + """ + return dict(self._functions) + + def get_code_for_function(self, name: str) -> list[Instruction]: + """Return per-instruction data for a function. + + Triggers BZ2 decompression of compressed sections on first call. + + Raises ``KeyError`` if *name* is not found. + """ + fn = self._functions[name] # raises KeyError if missing + self._ensure_decompressed() + + assert self._line_counters is not None + assert self._line_addresses is not None + assert self._line_text is not None + assert self._text_pool is not None + + counter_names = sorted(fn.counters.keys()) + + # LineCounters + lc_io = io.BytesIO(self._line_counters) + lc_io.seek(self._fn_lc_offset[name]) + + # LineAddresses + la_io = io.BytesIO(self._line_addresses) + la_io.seek(self._fn_la_offset[name]) + + # LineText + lt_io = io.BytesIO(self._line_text) + lt_io.seek(self._fn_lt_offset[name]) + + tp_io = io.BytesIO(self._text_pool) + + instructions: list[Instruction] = [] + prev_address = 0 + for _ in range(fn.length): + # Counters (one float per counter name, sorted) + ctrs: dict[str, float] = {} + for cname in counter_names: + ctrs[cname] = _read_float(lc_io) + + # Address (delta-encoded) + delta = read_uleb128(la_io) + address = prev_address + delta + prev_address = address + + # Text (offset into TextPool) + tp_offset = read_uleb128(lt_io) + tp_io.seek(tp_offset) + text = _read_string(tp_io) + + instructions.append(Instruction( + address=address, + counters=ctrs, + text=text, + )) + + return instructions + + # -- Internal ---------------------------------------------------------- + + def _ensure_decompressed(self) -> None: + """Decompress BZ2 sections on first access.""" + if self._line_counters is not None: + return + try: + self._line_counters = bz2.decompress(self._raw_line_counters) + self._line_addresses = bz2.decompress(self._raw_line_addresses) + self._line_text = bz2.decompress(self._raw_line_text) + self._text_pool = bz2.decompress(self._raw_text_pool) + except Exception as e: + raise ProfileParseError( + f"failed to decompress profile sections: {e}" + ) from e + # Release raw compressed bytes now that we have decompressed copies. + self._raw_line_counters = b"" + self._raw_line_addresses = b"" + self._raw_line_text = b"" + self._raw_text_pool = b"" diff --git a/lnt/server/db/v5/schema.py b/lnt/server/db/v5/schema.py new file mode 100644 index 000000000..13ca3189c --- /dev/null +++ b/lnt/server/db/v5/schema.py @@ -0,0 +1,230 @@ +""" +v5 YAML schema parser. + +Parses test suite schema files that define commit_fields, machine_fields, +and metrics for a v5 test suite. Produces dataclass-based schema objects +used by the dynamic model factory. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +import yaml + + +# Column names that are always present on the Commit table and therefore +# cannot be used as user-defined commit_field names. +_RESERVED_COMMIT_NAMES = frozenset({"id", "commit", "ordinal", "tag"}) + +# Column names that are always present on the Machine table. +_RESERVED_MACHINE_NAMES = frozenset({"id", "name", "parameters"}) + +# Column names that are always present on the Sample table and therefore +# cannot be used as metric names. +_RESERVED_SAMPLE_NAMES = frozenset({"id", "run_id", "test_id"}) + +# Supported column types for commit_fields and the SQLAlchemy type they map to. +# The mapping to actual SQLAlchemy types is done in models.py; here we just +# validate the string values. +VALID_COMMIT_FIELD_TYPES = frozenset({"default", "text", "integer", "datetime"}) + +# Supported metric types (maps to Sample column types). +VALID_METRIC_TYPES = frozenset({"real", "status", "hash"}) + + +@dataclass(frozen=True, slots=True) +class CommitField: + """A user-defined metadata column on the Commit table.""" + name: str + type: str = "default" # default | text | integer | datetime + searchable: bool = False + display: bool = False + + +@dataclass(frozen=True, slots=True) +class MachineField: + """A user-defined column on the Machine table.""" + name: str + searchable: bool = False + + +@dataclass(frozen=True, slots=True) +class Metric: + """A metric column on the Sample table.""" + name: str + type: str = "real" # real | status | hash + display_name: str | None = None + unit: str | None = None + unit_abbrev: str | None = None + bigger_is_better: bool = False + + +@dataclass(frozen=True, slots=True) +class TestSuiteSchema: + """Parsed v5 test suite schema.""" + name: str + metrics: list[Metric] = field(default_factory=list) + commit_fields: list[CommitField] = field(default_factory=list) + machine_fields: list[MachineField] = field(default_factory=list) + # Cached filtered lists (set in __post_init__) + _searchable_commit_fields: list[CommitField] = field( + default_factory=list, init=False, repr=False, compare=False, + ) + _searchable_machine_fields: list[MachineField] = field( + default_factory=list, init=False, repr=False, compare=False, + ) + + def __post_init__(self) -> None: + object.__setattr__( + self, + "_searchable_commit_fields", + [f for f in self.commit_fields if f.searchable], + ) + object.__setattr__( + self, + "_searchable_machine_fields", + [f for f in self.machine_fields if f.searchable], + ) + + @property + def searchable_commit_fields(self) -> list[CommitField]: + return self._searchable_commit_fields + + @property + def searchable_machine_fields(self) -> list[MachineField]: + return self._searchable_machine_fields + + +class SchemaError(Exception): + """Raised when a schema file is invalid.""" + + +def _parse_commit_fields(raw: list[dict[str, Any]]) -> list[CommitField]: + fields: list[CommitField] = [] + seen: set[str] = set() + for entry in raw: + name = entry.get("name") + if not name or not isinstance(name, str): + raise SchemaError("commit_fields entry missing 'name'") + if name in _RESERVED_COMMIT_NAMES: + raise SchemaError( + f"commit_fields name {name!r} is reserved " + f"(cannot use {sorted(_RESERVED_COMMIT_NAMES)})" + ) + if name in seen: + raise SchemaError(f"duplicate commit_fields name: {name!r}") + seen.add(name) + + ftype = entry.get("type", "default") + if ftype not in VALID_COMMIT_FIELD_TYPES: + raise SchemaError( + f"commit_fields[{name!r}] has unknown type {ftype!r}; " + f"valid types: {sorted(VALID_COMMIT_FIELD_TYPES)}" + ) + searchable = bool(entry.get("searchable", False)) + display = bool(entry.get("display", False)) + fields.append(CommitField( + name=name, type=ftype, searchable=searchable, display=display, + )) + + # At most one commit_field may have display=True (design D4). + display_count = sum(1 for f in fields if f.display) + if display_count > 1: + display_names = [f.name for f in fields if f.display] + raise SchemaError( + f"at most one commit_field may have display=true, " + f"but found {display_count}: {display_names}" + ) + + return fields + + +def _parse_machine_fields(raw: list[dict[str, Any]]) -> list[MachineField]: + fields: list[MachineField] = [] + seen: set[str] = set() + for entry in raw: + name = entry.get("name") + if not name or not isinstance(name, str): + raise SchemaError("machine_fields entry missing 'name'") + if name in _RESERVED_MACHINE_NAMES: + raise SchemaError( + f"machine_fields name {name!r} is reserved " + f"(cannot use {sorted(_RESERVED_MACHINE_NAMES)})" + ) + if name in seen: + raise SchemaError(f"duplicate machine_fields name: {name!r}") + seen.add(name) + + searchable = bool(entry.get("searchable", False)) + fields.append(MachineField(name=name, searchable=searchable)) + return fields + + +def _parse_metrics(raw: list[dict[str, Any]]) -> list[Metric]: + metrics: list[Metric] = [] + seen: set[str] = set() + for entry in raw: + name = entry.get("name") + if not name or not isinstance(name, str): + raise SchemaError("metrics entry missing 'name'") + if name in _RESERVED_SAMPLE_NAMES: + raise SchemaError( + f"metric name {name!r} is reserved " + f"(cannot use {sorted(_RESERVED_SAMPLE_NAMES)})" + ) + if name in seen: + raise SchemaError(f"duplicate metric name: {name!r}") + seen.add(name) + + mtype = entry.get("type", "real").lower() + if mtype not in VALID_METRIC_TYPES: + raise SchemaError( + f"metrics[{name!r}] has unknown type {mtype!r}; " + f"valid types: {sorted(VALID_METRIC_TYPES)}" + ) + metrics.append(Metric( + name=name, + type=mtype, + display_name=entry.get("display_name"), + unit=entry.get("unit"), + unit_abbrev=entry.get("unit_abbrev"), + bigger_is_better=bool(entry.get("bigger_is_better", False)), + )) + return metrics + + +def parse_schema(data: dict[str, Any]) -> TestSuiteSchema: + """Parse a raw YAML dict into a :class:`TestSuiteSchema`. + + Raises :class:`SchemaError` on validation failures. + """ + name = data.get("name") + if not name or not isinstance(name, str): + raise SchemaError("schema missing required 'name' field") + + metrics = _parse_metrics(data.get("metrics", [])) + commit_fields = _parse_commit_fields(data.get("commit_fields", [])) + machine_fields = _parse_machine_fields(data.get("machine_fields", [])) + + return TestSuiteSchema( + name=name, + metrics=metrics, + commit_fields=commit_fields, + machine_fields=machine_fields, + ) + + +def load_schema_file(path: str | Path) -> TestSuiteSchema: + """Load and parse a YAML schema file. + + Raises :class:`SchemaError` on validation failures or + :class:`FileNotFoundError` / :class:`yaml.YAMLError` on I/O / parse errors. + """ + with open(path) as f: + data = yaml.safe_load(f) + if not isinstance(data, dict): + raise SchemaError(f"schema file {path} does not contain a YAML mapping") + return parse_schema(data) diff --git a/lnt/server/ui/app.py b/lnt/server/ui/app.py index 82349eb84..989e18370 100644 --- a/lnt/server/ui/app.py +++ b/lnt/server/ui/app.py @@ -145,12 +145,24 @@ def create_with_instance(instance): # Load the application configuration. app.load_config(instance) - # Load the application routes. - app.register_blueprint(lnt.server.ui.views.frontend) - - # Load the flaskRESTful API. - app.api = Api(app) - load_api_resources(app.api) + # Determine the db_version from the default database entry to decide + # which routes to register. When multiple databases exist we use the + # default ('default') entry; fall back to '0.4' if absent. + _default_db_info = instance.config.databases.get('default') + _db_version = getattr(_default_db_info, 'db_version', '0.4') \ + if _default_db_info else '0.4' + + if _db_version != '5.0': + # v4 mode: register the legacy views and flaskRESTful API. + app.register_blueprint(lnt.server.ui.views.frontend) + + # Load the flaskRESTful API. + app.api = Api(app) + load_api_resources(app.api) + else: + # v5-only mode: skip legacy v4 views entirely. + from lnt.server.ui.v5 import v5_frontend + app.register_blueprint(v5_frontend) @app.before_request def set_session(): @@ -181,6 +193,19 @@ def internal_server_error(e): return response return render_template('error.html', message=repr(e)), 500 + # Load the v5 REST API (flask-smorest) AFTER the app-level error + # handlers above so that v5's per-status-code handlers can save + # them as fallbacks (see errors.py). + if _db_version == '5.0': + from lnt.server.api.v5 import create_v5_api + app.v5_api = create_v5_api(app) + + from flask_compress import Compress + Compress(app) + + # Store the db_version on the app for use by request handlers. + app.db_version = _db_version + return app @staticmethod diff --git a/lnt/server/ui/regression_views.py b/lnt/server/ui/regression_views.py index 79df76782..e5d905c1b 100644 --- a/lnt/server/ui/regression_views.py +++ b/lnt/server/ui/regression_views.py @@ -1,5 +1,6 @@ import sqlalchemy import json +import uuid import flask from flask import g from flask import abort @@ -456,6 +457,7 @@ def v4_make_regression(machine_id, test_id, field_index, run_id): machine=run.machine, test=test, field_id=field.id) + f.uuid = str(uuid.uuid4()) session.add(f) # Always update FCs with new values. diff --git a/lnt/server/ui/templates/layout.html b/lnt/server/ui/templates/layout.html index 471a391e8..db853ee4c 100644 --- a/lnt/server/ui/templates/layout.html +++ b/lnt/server/ui/templates/layout.html @@ -3,23 +3,23 @@ - - + - - - - - - - @@ -31,7 +31,7 @@ - + @@ -71,6 +71,9 @@ {% if nosidebar is defined %} #page-content { margin-left:20px; width:100%} {% endif %} + {% if nonav is defined %} + #push, #header { height: 0px; } + {% endif %} /* Lastly, apply responsive CSS fixes as necessary */ @media (max-width: 767px) { #footer { @@ -88,7 +91,7 @@ - + {{old_config.name}}{% for short_name,_ in components %} : {{short_name}}{% endfor %} - {{ self.title() }} @@ -115,11 +118,12 @@ {# Top-Level Content (non-footer) #} <div id="wrap"> {# Page Header #} + {% if nonav is not defined %} <div id="header" class="navbar navbar-fixed-top"> <div class="navbar-inner"> {# LNT Instance Title #} <div id="lnt-instance"> - <a class="brand" href="{{url_for('.index')}}">{{old_config.name}}</a> + <a class="brand" href="{{url_for('lnt.index')}}">{{old_config.name}}</a> </div> {# Database Selector #} <ul class="nav"> @@ -128,7 +132,7 @@ <a href="#" class="dropdown-toggle" data-toggle="dropdown" id="dbselect" >Database <b class="caret"></b></a> <ul class="dropdown-menu"> {% for name in old_config.databases.keys()|sort %} - <li><a href="{{ url_for('.select_db', db=name, path=request.path) }}"> + <li><a href="{{ url_for('lnt.select_db', db=name, path=request.path) }}"> {% if name == g.db_name %} <b>{{ name }}</b> {% else %} @@ -146,7 +150,7 @@ <a href="#" class="dropdown-toggle" data-toggle="dropdown">Suite<b class="caret"></b></a> <ul class="dropdown-menu"> {% for name in request.get_db().testsuite %} - <li><a href="{{db_url_for('.v4_recent_activity', testsuite_name=name)}}">{{name}}</a></li> + <li><a href="{{db_url_for('lnt.v4_recent_activity', testsuite_name=name)}}">{{name}}</a></li> {% endfor %} </ul> </li> @@ -158,24 +162,24 @@ <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown">{{g.testsuite_name}}<b class="caret"></b></a> <ul class="dropdown-menu"> - <li><a href="{{ v4_url_for('.v4_recent_activity') }}">Recent Activity</a></li> - <li><a href="{{ v4_url_for('.v4_global_status') }}">Global Status</a></li> - <li><a href="{{ v4_url_for('.v4_daily_report_overview') }}">Daily Report</a></li> - <li><a href="{{ v4_url_for('.v4_latest_runs_report') }}">Latest Runs Report</a></li> - <li><a href="{{ v4_url_for('.v4_machines') }}">All Machines</a></li> + <li><a href="{{ v4_url_for('lnt.v4_recent_activity') }}">Recent Activity</a></li> + <li><a href="{{ v4_url_for('lnt.v4_global_status') }}">Global Status</a></li> + <li><a href="{{ v4_url_for('lnt.v4_daily_report_overview') }}">Daily Report</a></li> + <li><a href="{{ v4_url_for('lnt.v4_latest_runs_report') }}">Latest Runs Report</a></li> + <li><a href="{{ v4_url_for('lnt.v4_machines') }}">All Machines</a></li> <li class="divider"></li> <li class="disabled"><a href="#">Changes</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=0) }}">Detected</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=1) }}">Staged</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=10) }}">Active</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=20) }}">NTBF</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=21) }}">Ignored</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=23) }}">Verify</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=22) }}">Fixed</a></li> - <li><a href="{{ v4_url_for('.v4_regression_list', state=-1) }}">All</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=0) }}">Detected</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=1) }}">Staged</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=10) }}">Active</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=20) }}">NTBF</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=21) }}">Ignored</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=23) }}">Verify</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=22) }}">Fixed</a></li> + <li><a href="{{ v4_url_for('lnt.v4_regression_list', state=-1) }}">All</a></li> <li class="divider"></li> <li class="disabled"><a href="#">Summary Report</a></li> - {#"{{ v4_url_for('.v4_summary_report') }}"#} + {#"{{ v4_url_for('lnt.v4_summary_report') }}"#} </ul> </li> </ul> @@ -188,7 +192,7 @@ <li class="nav-header">Select baseline:</li> {% for b in baselines %} {% set is_bold = b.id == baseline_id %} - <li><a href="{{ v4_url_for('.v4_set_baseline', id=b.id) }}"> + <li><a href="{{ v4_url_for('lnt.v4_set_baseline', id=b.id) }}"> {% if is_bold %} <b>{{ b.name }}</b> {% else %} @@ -205,9 +209,9 @@ <li class="dropdown"> <a href="#" class="dropdown-toggle" data-toggle="dropdown">System<b class="caret"></b></a> <ul class="dropdown-menu"> - <li><a href="{{ url_for('.rules') }}">Rules</a></li> - <li><a href="{{ url_for('.profile_admin') }}">Profiles</a></li> - <li><a href="{{ url_for('.static', filename='docs/index.html') }}">Documentation</a></li> + <li><a href="{{ url_for('lnt.rules') }}">Rules</a></li> + <li><a href="{{ url_for('lnt.profile_admin') }}">Profiles</a></li> + <li><a href="{{ url_for('lnt.static', filename='docs/index.html') }}">Documentation</a></li> </ul> </li> </ul> @@ -223,6 +227,7 @@ </div> </div> + {% endif %}{# nonav #} {# Include any database log, if present. #} {% if g.db_log is defined %} diff --git a/lnt/server/ui/v5/__init__.py b/lnt/server/ui/v5/__init__.py new file mode 100644 index 000000000..f91a412ef --- /dev/null +++ b/lnt/server/ui/v5/__init__.py @@ -0,0 +1,26 @@ +import flask +from flask import g, request + +from lnt.server.ui.decorators import _make_db_session + +v5_frontend = flask.Blueprint( + "lnt_v5", __name__, + template_folder="templates/", + static_folder="static/", + static_url_path="/v5/static", +) + + +def _setup_testsuite(testsuite_name, db_name=None): + """Shared setup for v5 UI routes: DB session + testsuite resolution. + + Calls ``ensure_fresh`` so that server-side rendering of + ``data-testsuites`` always reflects the latest suites, even when + another worker created or deleted one since this worker last checked. + """ + g.testsuite_name = testsuite_name + _make_db_session(db_name) + request.db.ensure_fresh(request.session) + + +from . import views # noqa: E402, F401 — register routes on the blueprint diff --git a/lnt/server/ui/v5/frontend/package-lock.json b/lnt/server/ui/v5/frontend/package-lock.json new file mode 100644 index 000000000..e8223060e --- /dev/null +++ b/lnt/server/ui/v5/frontend/package-lock.json @@ -0,0 +1,2080 @@ +{ + "name": "lnt-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lnt-frontend", + "devDependencies": { + "jsdom": "^29.0.1", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@asamuzakjp/css-color/-/css-color-5.1.1.tgz", + "integrity": "sha512-iGWN8E45Ws0XWx3D44Q1t6vX2LqhCKcwfmwBYCDsFrYFS6m4q/Ks61L2veETaLv+ckDC6+dTETJoaAAb7VjLiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.1.1", + "@csstools/css-color-parser": "^4.0.2", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.0.4", + "resolved": "https://npm.apple.com/@asamuzakjp/dom-selector/-/dom-selector-7.0.4.tgz", + "integrity": "sha512-jXR6x4AcT3eIrS2fSNAwJpwirOkGcd+E7F7CP3zjdTqz9B/2huHOL8YJZBgekKwLML+u7qB/6P1LXQuMScsx0w==", + "dev": true, + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", + "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.1.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://npm.apple.com/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.2.tgz", + "integrity": "sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@exodus/bytes/-/bytes-1.15.0.tgz", + "integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://npm.apple.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.0.tgz", + "integrity": "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.0.tgz", + "integrity": "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.0.tgz", + "integrity": "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.0.tgz", + "integrity": "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.0.tgz", + "integrity": "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.0", + "resolved": "https://npm.apple.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.0.tgz", + "integrity": "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.0.tgz", + "integrity": "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.0.tgz", + "integrity": "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.0.tgz", + "integrity": "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.0", + "resolved": "https://npm.apple.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.0.tgz", + "integrity": "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.0", + "resolved": "https://npm.apple.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.0.tgz", + "integrity": "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.0.tgz", + "integrity": "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.0.tgz", + "integrity": "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.0.tgz", + "integrity": "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.0.tgz", + "integrity": "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.0.tgz", + "integrity": "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.0.tgz", + "integrity": "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.0.tgz", + "integrity": "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.0.tgz", + "integrity": "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.0.tgz", + "integrity": "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.0.tgz", + "integrity": "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.0.tgz", + "integrity": "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.0.tgz", + "integrity": "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.0", + "resolved": "https://npm.apple.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.0.tgz", + "integrity": "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.0.tgz", + "integrity": "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://npm.apple.com/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://npm.apple.com/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://npm.apple.com/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://npm.apple.com/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://npm.apple.com/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://npm.apple.com/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://npm.apple.com/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://npm.apple.com/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://npm.apple.com/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://npm.apple.com/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://npm.apple.com/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://npm.apple.com/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://npm.apple.com/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://npm.apple.com/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://npm.apple.com/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://npm.apple.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.0.1", + "resolved": "https://npm.apple.com/jsdom/-/jsdom-29.0.1.tgz", + "integrity": "sha512-z6JOK5gRO7aMybVq/y/MlIpKh8JIi68FBKMUtKkK2KH/wMSRlCxQ682d08LB9fYXplyY/UXG8P4XXTScmdjApg==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.0.1", + "@asamuzakjp/dom-selector": "^7.0.3", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.1", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.7", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.24.5", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "11.2.7", + "resolved": "https://npm.apple.com/lru-cache/-/lru-cache-11.2.7.tgz", + "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://npm.apple.com/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://npm.apple.com/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://npm.apple.com/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://npm.apple.com/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://npm.apple.com/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://npm.apple.com/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://npm.apple.com/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://npm.apple.com/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.0", + "resolved": "https://npm.apple.com/rollup/-/rollup-4.60.0.tgz", + "integrity": "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ==", + "dev": true, + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.0", + "@rollup/rollup-android-arm64": "4.60.0", + "@rollup/rollup-darwin-arm64": "4.60.0", + "@rollup/rollup-darwin-x64": "4.60.0", + "@rollup/rollup-freebsd-arm64": "4.60.0", + "@rollup/rollup-freebsd-x64": "4.60.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", + "@rollup/rollup-linux-arm-musleabihf": "4.60.0", + "@rollup/rollup-linux-arm64-gnu": "4.60.0", + "@rollup/rollup-linux-arm64-musl": "4.60.0", + "@rollup/rollup-linux-loong64-gnu": "4.60.0", + "@rollup/rollup-linux-loong64-musl": "4.60.0", + "@rollup/rollup-linux-ppc64-gnu": "4.60.0", + "@rollup/rollup-linux-ppc64-musl": "4.60.0", + "@rollup/rollup-linux-riscv64-gnu": "4.60.0", + "@rollup/rollup-linux-riscv64-musl": "4.60.0", + "@rollup/rollup-linux-s390x-gnu": "4.60.0", + "@rollup/rollup-linux-x64-gnu": "4.60.0", + "@rollup/rollup-linux-x64-musl": "4.60.0", + "@rollup/rollup-openbsd-x64": "4.60.0", + "@rollup/rollup-openharmony-arm64": "4.60.0", + "@rollup/rollup-win32-arm64-msvc": "4.60.0", + "@rollup/rollup-win32-ia32-msvc": "4.60.0", + "@rollup/rollup-win32-x64-gnu": "4.60.0", + "@rollup/rollup-win32-x64-msvc": "4.60.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://npm.apple.com/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://npm.apple.com/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://npm.apple.com/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://npm.apple.com/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.24.6", + "resolved": "https://npm.apple.com/undici/-/undici-7.24.6.tgz", + "integrity": "sha512-Xi4agocCbRzt0yYMZGMA6ApD7gvtUFaxm4ZmeacWI4cZxaF6C+8I8QfofC20NAePiB/IcvZmzkJ7XPa471AEtA==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/vite": { + "version": "6.4.1", + "resolved": "https://npm.apple.com/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "dev": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://npm.apple.com/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://npm.apple.com/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://npm.apple.com/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://npm.apple.com/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://npm.apple.com/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://npm.apple.com/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://artifacts.apple.com/artifactory/api/npm/npm-apple/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://npm.apple.com/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://npm.apple.com/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + } + } +} diff --git a/lnt/server/ui/v5/frontend/package.json b/lnt/server/ui/v5/frontend/package.json new file mode 100644 index 000000000..c49c60b67 --- /dev/null +++ b/lnt/server/ui/v5/frontend/package.json @@ -0,0 +1,15 @@ +{ + "name": "lnt-frontend", + "private": true, + "scripts": { + "build": "vite build", + "test": "vitest run", + "typecheck": "tsc --noEmit" + }, + "devDependencies": { + "jsdom": "^29.0.1", + "typescript": "^5.7.0", + "vite": "^6.0.0", + "vitest": "^3.0.0" + } +} diff --git a/lnt/server/ui/v5/frontend/public/favicon.ico b/lnt/server/ui/v5/frontend/public/favicon.ico new file mode 100644 index 000000000..739611789 Binary files /dev/null and b/lnt/server/ui/v5/frontend/public/favicon.ico differ diff --git a/lnt/server/ui/v5/frontend/src/__tests__/api.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/api.test.ts new file mode 100644 index 000000000..5e0b43064 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/api.test.ts @@ -0,0 +1,1300 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setApiBase, getFields, getCommits, getMachines, getRuns, getSamples, + getMachine, getMachineRuns, deleteMachine, getRun, deleteRun, getCommit, getRunsByCommit, + getFieldChanges, searchCommits, updateCommit, fetchTrends, + fetchOneCursorPage, apiUrl, ApiError, authErrorMessage, + resolveCommits, getTestSuiteInfoCached, _clearSuiteInfoCache, + getProfilesForRun, getProfileMetadata, getProfileFunctions, getProfileFunctionDetail, +} from '../api'; +import type { + CursorPaginated, FieldInfo, MachineInfo, MachineRunInfo, OffsetPaginated, + CommitSummary, CommitDetail, RunInfo, RunDetail, SampleInfo, FieldChangeInfo, + QueryDataPoint, ProfileListItem, ProfileMetadata, ProfileFunctionInfo, ProfileFunctionDetail, +} from '../types'; + +// --------------------------------------------------------------------------- +// Helpers to build mock paginated responses +// --------------------------------------------------------------------------- + +function cursorPage<T>(items: T[], next: string | null = null): CursorPaginated<T> { + return { items, cursor: { next, previous: null } }; +} + +function offsetPage<T>(items: T[], total: number): OffsetPaginated<T> { + return { items, total, cursor: { next: null, previous: null } }; +} + +/** Build a minimal TestSuiteInfo mock response for getFields tests. */ +function suiteInfoResponse(metrics: FieldInfo[] = []) { + return { name: 'nts', schema: { metrics, commit_fields: [], machine_fields: [] } }; +} + +// --------------------------------------------------------------------------- +// Global mocks – fetch, localStorage, window.location +// --------------------------------------------------------------------------- + +let mockFetch: ReturnType<typeof vi.fn>; +let storedToken: string | null; + +function mockResponse(body: unknown, status = 200, statusText = 'OK'): Response { + return { + ok: status >= 200 && status < 300, + status, + statusText, + json: () => Promise.resolve(body), + text: () => Promise.resolve(typeof body === 'string' ? body : JSON.stringify(body)), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => mockResponse(body, status, statusText), + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + } as Response; +} + +beforeEach(() => { + storedToken = null; + + mockFetch = vi.fn(); + vi.stubGlobal('fetch', mockFetch); + + vi.stubGlobal('localStorage', { + getItem: (key: string) => key === 'lnt_v5_token' ? storedToken : null, + setItem: vi.fn(), + removeItem: vi.fn(), + clear: vi.fn(), + length: 0, + key: () => null, + }); + + // api.ts references `window.location.origin` — we need the `window` global + // to exist in Node. stubGlobal('window', ...) creates globalThis.window. + vi.stubGlobal('window', { + location: { origin: 'http://localhost:3000' }, + }); + + // Reset apiBase before each test + setApiBase(''); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + _clearSuiteInfoCache(); +}); + +// =========================================================================== +// setApiBase +// =========================================================================== + +describe('setApiBase', () => { + it('sets base URL used in subsequent requests', async () => { + setApiBase('/lnt'); + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/lnt/api/v5/test-suites/nts'); + }); + + it('strips trailing slash from base', async () => { + setApiBase('/lnt/'); + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/lnt/api/v5/test-suites/nts'); + }); + + it('handles empty string base', async () => { + setApiBase(''); + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/test-suites/nts'); + }); +}); + +// =========================================================================== +// Auth header injection +// =========================================================================== + +describe('auth header injection', () => { + it('includes Bearer token when localStorage has a token', async () => { + storedToken = 'my-secret-token'; + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBe('Bearer my-secret-token'); + expect(headers['Accept']).toBe('application/json'); + }); + + it('omits Authorization header when no token is stored', async () => { + storedToken = null; + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const headers = mockFetch.mock.calls[0][1].headers; + expect(headers['Authorization']).toBeUndefined(); + expect(headers['Accept']).toBe('application/json'); + }); +}); + +// =========================================================================== +// Error formatting +// =========================================================================== + +describe('error formatting', () => { + it('throws an Error with status and body text on non-OK response', async () => { + mockFetch.mockResolvedValueOnce( + mockResponse('{"error":"not found"}', 404, 'Not Found') + ); + + await expect(getFields('nts')).rejects.toThrow('API 404: {"error":"not found"}'); + }); + + it('falls back to statusText when body text is empty', async () => { + const resp = { + ok: false, + status: 500, + statusText: 'Internal Server Error', + text: () => Promise.resolve(''), + json: () => Promise.reject(new Error('no json')), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => resp, + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + } as Response; + + mockFetch.mockResolvedValueOnce(resp); + + await expect(getFields('nts')).rejects.toThrow('API 500: Internal Server Error'); + }); + + it('falls back to statusText when resp.text() rejects', async () => { + const resp = { + ok: false, + status: 502, + statusText: 'Bad Gateway', + text: () => Promise.reject(new Error('stream error')), + json: () => Promise.reject(new Error('no json')), + headers: new Headers(), + redirected: false, + type: 'basic' as ResponseType, + url: '', + clone: () => resp, + body: null, + bodyUsed: false, + arrayBuffer: () => Promise.resolve(new ArrayBuffer(0)), + blob: () => Promise.resolve(new Blob()), + formData: () => Promise.resolve(new FormData()), + bytes: () => Promise.resolve(new Uint8Array()), + } as Response; + + mockFetch.mockResolvedValueOnce(resp); + + await expect(getFields('nts')).rejects.toThrow('API 502: Bad Gateway'); + }); +}); + +// =========================================================================== +// AbortSignal +// =========================================================================== + +describe('AbortSignal support', () => { + it('passes signal to fetch for non-paginated requests', async () => { + const controller = new AbortController(); + const machine: MachineInfo = { name: 'clang-x86', info: {} }; + mockFetch.mockResolvedValueOnce(mockResponse(machine)); + + await getMachine('nts', 'clang-x86', controller.signal); + + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + }); + + it('passes signal to every fetch call in paginated requests', async () => { + const controller = new AbortController(); + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '100', ordinal: 1, tag: null, fields: {} }], 'cursor1'))) + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '200', ordinal: 2, tag: null, fields: {} }]))); + + await getCommits('nts', { signal: controller.signal }); + + expect(mockFetch.mock.calls).toHaveLength(2); + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + expect(mockFetch.mock.calls[1][1].signal).toBe(controller.signal); + }); +}); + +// =========================================================================== +// Cursor-based pagination loop +// =========================================================================== + +describe('cursor-based pagination', () => { + it('fetches a single page when cursor.next is null', async () => { + const commit: CommitSummary = { commit: '100', ordinal: 1, tag: null, fields: {} }; + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([commit]))); + + const result = await getCommits('nts'); + + expect(result).toEqual([commit]); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('fetches multiple pages and concatenates results', async () => { + const c1: CommitSummary = { commit: '100', ordinal: 1, tag: null, fields: {} }; + const c2: CommitSummary = { commit: '200', ordinal: 2, tag: null, fields: {} }; + const c3: CommitSummary = { commit: '300', ordinal: 3, tag: null, fields: {} }; + + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([c1], 'cursor-abc'))) + .mockResolvedValueOnce(mockResponse(cursorPage([c2], 'cursor-def'))) + .mockResolvedValueOnce(mockResponse(cursorPage([c3]))); + + const result = await getCommits('nts'); + + expect(result).toEqual([c1, c2, c3]); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('passes cursor parameter on subsequent pages', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '1', ordinal: 1, tag: null, fields: {} }], 'next-page-cursor'))) + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '2', ordinal: 2, tag: null, fields: {} }]))); + + await getCommits('nts'); + + // First call should have no cursor + const firstUrl = new URL(mockFetch.mock.calls[0][0]); + expect(firstUrl.searchParams.has('cursor')).toBe(false); + expect(firstUrl.searchParams.get('limit')).toBe('10000'); + + // Second call should include the cursor + const secondUrl = new URL(mockFetch.mock.calls[1][0]); + expect(secondUrl.searchParams.get('cursor')).toBe('next-page-cursor'); + expect(secondUrl.searchParams.get('limit')).toBe('10000'); + }); + + it('calls onProgress callback with running total after each page', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '1', ordinal: 1, tag: null, fields: {} }, { commit: '2', ordinal: 2, tag: null, fields: {} }], 'c1'))) + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '3', ordinal: 3, tag: null, fields: {} }]))); + + const onProgress = vi.fn(); + await getCommits('nts', { onProgress }); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, 2); + expect(onProgress).toHaveBeenNthCalledWith(2, 3); + }); + + it('stops paginating when error occurs mid-pagination', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([{ commit: '1', ordinal: 1, tag: null, fields: {} }], 'c1'))) + .mockResolvedValueOnce(mockResponse('server error', 500, 'Internal Server Error')); + + await expect(getCommits('nts')).rejects.toThrow('API 500'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); +}); + +// =========================================================================== +// getFields +// =========================================================================== + +describe('getFields', () => { + it('returns the metrics array from the test-suite schema', async () => { + const fields: FieldInfo[] = [ + { name: 'compile_time', type: 'real', display_name: 'Compile Time', unit: 'seconds', unit_abbrev: 's', bigger_is_better: false }, + { name: 'exec_time', type: 'real', display_name: 'Execution Time', unit: 'seconds', unit_abbrev: 's', bigger_is_better: false }, + ]; + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse(fields))); + + const result = await getFields('nts'); + + expect(result).toEqual(fields); + }); + + it('constructs the correct URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/test-suites/nts'); + }); + + it('encodes test suite name in URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('my test suite'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/test-suites/my%20test%20suite'); + }); +}); + +// =========================================================================== +// getCommits +// =========================================================================== + +describe('getCommits', () => { + it('returns all commits across multiple pages', async () => { + const c1: CommitSummary = { commit: '100', ordinal: 1, tag: null, fields: {} }; + const c2: CommitSummary = { commit: '200', ordinal: 2, tag: null, fields: {} }; + + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([c1], 'c1'))) + .mockResolvedValueOnce(mockResponse(cursorPage([c2]))); + + const result = await getCommits('nts'); + expect(result).toEqual([c1, c2]); + }); + + it('constructs the correct URL with limit=10000', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/commits'); + expect(url.searchParams.get('limit')).toBe('10000'); + }); + + it('passes machine query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts', { machine: 'clang-x86' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('machine')).toBe('clang-x86'); + }); + + it('does not include machine param when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.has('machine')).toBe(false); + }); + + it('passes has_profiles=true query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts', { has_profiles: true }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('has_profiles')).toBe('true'); + }); + + it('passes has_profiles=false query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts', { has_profiles: false }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('has_profiles')).toBe('false'); + }); + + it('does not include has_profiles param when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getCommits('nts', { machine: 'x' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.has('has_profiles')).toBe(false); + }); +}); + +// =========================================================================== +// getMachines +// =========================================================================== + +describe('getMachines', () => { + it('returns items and total from offset-paginated response', async () => { + const machines: MachineInfo[] = [ + { name: 'machine-1', info: { os: 'linux' } }, + { name: 'machine-2', info: { os: 'darwin' } }, + ]; + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage(machines, 42))); + + const result = await getMachines('nts', {}); + + expect(result.items).toEqual(machines); + expect(result.total).toBe(42); + }); + + it('passes search as search query param', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + + await getMachines('nts', { search: 'clang-' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('search')).toBe('clang-'); + }); + + it('passes limit query param', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + + await getMachines('nts', { limit: 10 }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('limit')).toBe('10'); + }); + + it('omits search and limit when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + + await getMachines('nts', {}); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.has('search')).toBe(false); + expect(url.searchParams.has('limit')).toBe(false); + }); + + it('constructs the correct URL path', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + + await getMachines('nts', {}); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines'); + }); + + it('passes offset query param', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + + await getMachines('nts', { offset: 25 }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('offset')).toBe('25'); + }); +}); + +// =========================================================================== +// getRuns +// =========================================================================== + +describe('getRuns', () => { + it('returns all runs across paginated responses', async () => { + const r1: RunInfo = { + uuid: 'aaa-111', + machine: 'machine-1', + commit: '100', + submitted_at: '2025-01-01T00:00:00Z', + run_parameters: {}, + }; + const r2: RunInfo = { + uuid: 'bbb-222', + machine: 'machine-1', + commit: '200', + submitted_at: null, + run_parameters: {}, + }; + + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([r1], 'next-c'))) + .mockResolvedValueOnce(mockResponse(cursorPage([r2]))); + + const result = await getRuns('nts', { machine: 'machine-1' }); + expect(result).toEqual([r1, r2]); + }); + + it('passes machine and commit as query params', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1', commit: 'rev100' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('machine')).toBe('machine-1'); + expect(url.searchParams.get('commit')).toBe('rev100'); + expect(url.searchParams.get('limit')).toBe('10000'); + }); + + it('omits commit when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.has('commit')).toBe(false); + }); + + it('constructs the correct URL path', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs'); + }); + + it('passes has_profiles=true query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1', has_profiles: true }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('has_profiles')).toBe('true'); + }); + + it('passes has_profiles=false query parameter when provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1', has_profiles: false }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('has_profiles')).toBe('false'); + }); + + it('does not include has_profiles param when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.has('has_profiles')).toBe(false); + }); +}); + +// =========================================================================== +// getSamples +// =========================================================================== + +describe('getSamples', () => { + it('returns all samples across paginated responses', async () => { + const s1: SampleInfo = { test: 'test/a', metrics: { compile_time: 1.23 } }; + const s2: SampleInfo = { test: 'test/b', metrics: { compile_time: null } }; + + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage([s1], 'c1'))) + .mockResolvedValueOnce(mockResponse(cursorPage([s2]))); + + const result = await getSamples('nts', 'run-uuid-123'); + expect(result).toEqual([s1, s2]); + }); + + it('constructs URL with encoded run UUID', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getSamples('nts', 'abc-def/special'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs/abc-def%2Fspecial/samples'); + }); + + it('calls onProgress with running total', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse(cursorPage( + [{ test: 'a', metrics: {} }, { test: 'b', metrics: {} }], + 'c1', + ))) + .mockResolvedValueOnce(mockResponse(cursorPage( + [{ test: 'c', metrics: {} }], + ))); + + const onProgress = vi.fn(); + await getSamples('nts', 'run-uuid', undefined, onProgress); + + expect(onProgress).toHaveBeenCalledTimes(2); + expect(onProgress).toHaveBeenNthCalledWith(1, 2); + expect(onProgress).toHaveBeenNthCalledWith(2, 3); + }); + + it('passes signal through paginated requests', async () => { + const controller = new AbortController(); + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getSamples('nts', 'run-uuid', controller.signal); + + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + }); +}); + +// =========================================================================== +// URL construction – apiUrl encodes test suite names +// =========================================================================== + +describe('URL construction', () => { + it('encodes special characters in test suite name', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + + await getFields('nts/special chars'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/test-suites/nts%2Fspecial%20chars'); + }); + + it('uses apiBase prefix for all API functions', async () => { + setApiBase('/myapp'); + + // Test each function constructs URLs with the base + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfoResponse())); + await getFields('nts'); + expect(new URL(mockFetch.mock.calls[0][0]).pathname).toBe('/myapp/api/v5/test-suites/nts'); + + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + await getCommits('nts'); + expect(new URL(mockFetch.mock.calls[1][0]).pathname).toBe('/myapp/api/v5/nts/commits'); + + mockFetch.mockResolvedValueOnce(mockResponse(offsetPage([], 0))); + await getMachines('nts', {}); + expect(new URL(mockFetch.mock.calls[2][0]).pathname).toBe('/myapp/api/v5/nts/machines'); + + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + await getRuns('nts', { machine: 'm' }); + expect(new URL(mockFetch.mock.calls[3][0]).pathname).toBe('/myapp/api/v5/nts/runs'); + + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + await getSamples('nts', 'uuid-1'); + expect(new URL(mockFetch.mock.calls[4][0]).pathname).toBe('/myapp/api/v5/nts/runs/uuid-1/samples'); + }); +}); + +// =========================================================================== +// fetchJson – query parameter handling +// =========================================================================== + +describe('query parameter handling', () => { + it('omits empty string params', async () => { + // getRuns with empty commit should not include it + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await getRuns('nts', { machine: 'machine-1', commit: '' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('machine')).toBe('machine-1'); + // commit is '' and should be excluded by the fetchJson params filtering + // (but actually getRuns conditionally adds commit, so let's test via getMachines) + expect(url.searchParams.has('commit')).toBe(false); + }); +}); + +// =========================================================================== +// Phase 2: New API functions +// =========================================================================== + +describe('getMachine', () => { + it('fetches a single machine by name', async () => { + const machine: MachineInfo = { name: 'clang-x86', info: { os: 'linux' } }; + mockFetch.mockResolvedValueOnce(mockResponse(machine)); + + const result = await getMachine('nts', 'clang-x86'); + + expect(result).toEqual(machine); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines/clang-x86'); + }); + + it('encodes machine name in URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ name: 'a/b', info: {} })); + await getMachine('nts', 'a/b'); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines/a%2Fb'); + }); +}); + +describe('getMachineRuns', () => { + it('fetches runs for a machine with sort and limit', async () => { + const page = cursorPage<MachineRunInfo>( + [{ uuid: 'r1', commit: '100', submitted_at: null }], + 'cursor-2', + ); + mockFetch.mockResolvedValueOnce(mockResponse(page)); + + const result = await getMachineRuns('nts', 'clang-x86', { sort: '-submitted_at', limit: 10 }); + + expect(result.items).toHaveLength(1); + expect(result.cursor.next).toBe('cursor-2'); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines/clang-x86/runs'); + expect(url.searchParams.get('sort')).toBe('-submitted_at'); + expect(url.searchParams.get('limit')).toBe('10'); + }); + + it('passes cursor query param for pagination', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage<MachineRunInfo>([], null))); + + await getMachineRuns('nts', 'clang-x86', { cursor: 'abc123' }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('cursor')).toBe('abc123'); + }); +}); + +describe('deleteMachine', () => { + it('sends DELETE request to correct URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('', 204)); + + await deleteMachine('nts', 'clang-x86'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines/clang-x86'); + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('encodes machine name in URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('', 204)); + + await deleteMachine('nts', 'machine with spaces'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/machines/machine%20with%20spaces'); + }); + + it('sends auth token when set', async () => { + storedToken = 'my-token'; + mockFetch.mockResolvedValueOnce(mockResponse('', 204)); + + await deleteMachine('nts', 'clang-x86'); + + expect(mockFetch.mock.calls[0][1].headers['Authorization']).toBe('Bearer my-token'); + }); + + it('throws ApiError on 403', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403, 'Forbidden')); + + try { + await deleteMachine('nts', 'clang-x86'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).status).toBe(403); + } + }); +}); + +describe('ApiError and authErrorMessage', () => { + it('authErrorMessage returns permission message for 401', () => { + expect(authErrorMessage(new ApiError(401, 'Unauthorized'))).toContain('Permission denied'); + }); + + it('authErrorMessage returns permission message for 403', () => { + expect(authErrorMessage(new ApiError(403, 'Forbidden'))).toContain('Permission denied'); + }); + + it('authErrorMessage returns generic message for other errors', () => { + expect(authErrorMessage(new Error('network failure'))).toContain('network failure'); + }); + + it('authErrorMessage returns generic message for non-auth ApiError', () => { + expect(authErrorMessage(new ApiError(500, 'Server error'))).toContain('Server error'); + }); +}); + +describe('getRun', () => { + it('fetches a single run by UUID', async () => { + const run: RunDetail = { + uuid: 'abc-123', machine: 'm1', commit: '100', + submitted_at: '2025-01-01T00:00:00Z', run_parameters: {}, + }; + mockFetch.mockResolvedValueOnce(mockResponse(run)); + + const result = await getRun('nts', 'abc-123'); + + expect(result.uuid).toBe('abc-123'); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs/abc-123'); + }); +}); + +describe('deleteRun', () => { + it('sends DELETE request to correct URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('', 204)); + + await deleteRun('nts', 'abc-123'); + + expect(mockFetch).toHaveBeenCalledOnce(); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs/abc-123'); + expect(mockFetch.mock.calls[0][1].method).toBe('DELETE'); + }); + + it('throws ApiError on 403', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403, 'Forbidden')); + + try { + await deleteRun('nts', 'abc-123'); + expect.fail('should have thrown'); + } catch (e) { + expect(e).toBeInstanceOf(ApiError); + expect((e as ApiError).status).toBe(403); + } + }); +}); + +describe('getCommit', () => { + it('fetches commit detail with prev/next', async () => { + const commit: CommitDetail = { + commit: '100', ordinal: 1, tag: null, fields: {}, + previous_commit: { commit: '99', ordinal: 0, tag: null, link: '/api/v5/nts/commits/99' }, + next_commit: null, + }; + mockFetch.mockResolvedValueOnce(mockResponse(commit)); + + const result = await getCommit('nts', '100'); + + expect(result.previous_commit).not.toBeNull(); + expect(result.next_commit).toBeNull(); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/commits/100'); + }); +}); + +describe('getRunsByCommit', () => { + it('auto-paginates runs filtered by commit value', async () => { + const run: RunInfo = { + uuid: 'r1', machine: 'm1', commit: '100', + submitted_at: null, run_parameters: {}, + }; + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([run]))); + + const result = await getRunsByCommit('nts', '100'); + + expect(result).toHaveLength(1); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('commit')).toBe('100'); + }); +}); + +describe('getFieldChanges', () => { + it('fetches field changes with limit', async () => { + const fc: FieldChangeInfo = { + uuid: 'fc1', test: 't1', machine: 'm1', metric: 'compile_time', + old_value: 1.0, new_value: 2.0, start_commit: '99', end_commit: '100', + }; + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([fc]))); + + const result = await getFieldChanges('nts', { limit: 1 }); + + expect(result.items).toHaveLength(1); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/field-changes'); + expect(url.searchParams.get('limit')).toBe('1'); + }); +}); + +describe('searchCommits', () => { + it('passes search and limit params', async () => { + const commit: CommitSummary = { commit: '100', ordinal: 1, tag: null, fields: {} }; + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([commit]))); + + const result = await searchCommits('nts', 'release', { limit: 10 }); + + expect(result.items).toHaveLength(1); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/commits'); + expect(url.searchParams.get('search')).toBe('release'); + expect(url.searchParams.get('limit')).toBe('10'); + }); +}); + +describe('updateCommit', () => { + it('sends PATCH with updates in JSON body', async () => { + const commit: CommitDetail = { + commit: '100', ordinal: 1, tag: null, fields: {}, + previous_commit: null, next_commit: null, + }; + mockFetch.mockResolvedValueOnce(mockResponse(commit)); + + const result = await updateCommit('nts', '100', { tag: 'new-tag' }); + + expect(result.commit).toBe('100'); + const [url, opts] = mockFetch.mock.calls[0]; + expect(new URL(url).pathname).toBe('/api/v5/nts/commits/100'); + expect(opts.method).toBe('PATCH'); + expect(JSON.parse(opts.body)).toEqual({ tag: 'new-tag' }); + expect(opts.headers['Content-Type']).toBe('application/json'); + }); + + it('includes auth token when set', async () => { + storedToken = 'my-admin-token'; + mockFetch.mockResolvedValueOnce(mockResponse({ + commit: '100', ordinal: 1, tag: null, fields: {}, previous_commit: null, next_commit: null, + })); + + await updateCommit('nts', '100', { tag: null }); + + const [, opts] = mockFetch.mock.calls[0]; + expect(opts.headers['Authorization']).toBe('Bearer my-admin-token'); + }); + + it('throws on non-ok response', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('Forbidden', 403, 'Forbidden')); + + await expect(updateCommit('nts', '100', { tag: 'x' })).rejects.toThrow('API 403'); + }); +}); + + +// =========================================================================== +// apiUrl +// =========================================================================== + +describe('apiUrl', () => { + it('constructs URL with encoded test suite name', () => { + setApiBase(''); + expect(apiUrl('nts', 'query')).toBe('/api/v5/nts/query'); + }); + + it('includes apiBase prefix', () => { + setApiBase('/lnt'); + expect(apiUrl('nts', 'machines')).toBe('/lnt/api/v5/nts/machines'); + }); + + it('encodes special characters in test suite name', () => { + setApiBase(''); + expect(apiUrl('my suite', 'commits')).toBe('/api/v5/my%20suite/commits'); + }); +}); + +// =========================================================================== +// fetchOneCursorPage +// =========================================================================== + +describe('fetchOneCursorPage', () => { + it('returns items and nextCursor from a single page', async () => { + const pt: QueryDataPoint = { + test: 't1', machine: 'm1', metric: 'exec_time', value: 1.0, + commit: '100', ordinal: 1, tag: null, run_uuid: 'r1', submitted_at: null, + }; + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([pt], 'next-abc'))); + + const result = await fetchOneCursorPage<QueryDataPoint>( + '/api/v5/nts/query', { machine: 'm1', limit: '10000' }, + ); + + expect(result.items).toEqual([pt]); + expect(result.nextCursor).toBe('next-abc'); + }); + + it('returns null nextCursor on last page', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([{ test: 't1' }], null))); + + const result = await fetchOneCursorPage('/api/v5/nts/query', {}); + + expect(result.nextCursor).toBeNull(); + }); + + it('passes signal to fetch', async () => { + const controller = new AbortController(); + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await fetchOneCursorPage('/api/v5/nts/query', {}, controller.signal); + + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + }); + + it('passes limit and cursor as query params', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(cursorPage([]))); + + await fetchOneCursorPage('/api/v5/nts/query', { + machine: 'm1', metric: 'exec_time', sort: '-commit', limit: '10000', cursor: 'abc', + }); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.searchParams.get('machine')).toBe('m1'); + expect(url.searchParams.get('metric')).toBe('exec_time'); + expect(url.searchParams.get('sort')).toBe('-commit'); + expect(url.searchParams.get('limit')).toBe('10000'); + expect(url.searchParams.get('cursor')).toBe('abc'); + }); +}); + +// =========================================================================== +// fetchTrends +// =========================================================================== + +describe('fetchTrends', () => { + it('sends POST with JSON body containing metric, machine list, and last_n', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ + metric: 'exec_time', + items: [{ machine: 'm1', commit: '100', ordinal: 1, submitted_at: '2025-01-01T00:00:00Z', value: 42.0 }], + })); + + const result = await fetchTrends('nts', { metric: 'exec_time', machine: ['m1', 'm2'], lastN: 100 }); + + expect(result).toHaveLength(1); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/trends'); + const init = mockFetch.mock.calls[0][1] as RequestInit; + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.metric).toBe('exec_time'); + expect(body.machine).toEqual(['m1', 'm2']); + expect(body.last_n).toBe(100); + }); + + it('omits machine and last_n when not provided', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ metric: 'exec_time', items: [] })); + + await fetchTrends('nts', { metric: 'exec_time' }); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + const body = JSON.parse(init.body as string); + expect(body.metric).toBe('exec_time'); + expect(body.machine).toBeUndefined(); + expect(body.last_n).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// resolveCommits +// --------------------------------------------------------------------------- + +describe('resolveCommits', () => { + it('sends POST with JSON body to /commits/resolve', async () => { + const response = { results: {}, not_found: ['abc'] }; + mockFetch.mockResolvedValueOnce(mockResponse(response)); + + await resolveCommits('nts', ['abc', 'def']); + + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toContain('/api/v5/nts/commits/resolve'); + expect(init.method).toBe('POST'); + const body = JSON.parse(init.body as string); + expect(body.commits).toEqual(['abc', 'def']); + }); + + it('includes auth token when present', async () => { + storedToken = 'test-token'; + mockFetch.mockResolvedValueOnce(mockResponse({ results: {}, not_found: [] })); + + await resolveCommits('nts', ['abc']); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + expect((init.headers as Record<string, string>)['Authorization']).toBe('Bearer test-token'); + }); + + it('passes AbortSignal through', async () => { + const ctrl = new AbortController(); + mockFetch.mockResolvedValueOnce(mockResponse({ results: {}, not_found: [] })); + + await resolveCommits('nts', ['abc'], ctrl.signal); + + const init = mockFetch.mock.calls[0][1] as RequestInit; + expect(init.signal).toBe(ctrl.signal); + }); + + it('throws ApiError on non-OK response', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ error: 'bad' }, 422, 'Unprocessable Entity')); + + await expect(resolveCommits('nts', ['abc'])).rejects.toThrow(ApiError); + }); +}); + +// --------------------------------------------------------------------------- +// getTestSuiteInfoCached +// --------------------------------------------------------------------------- + +describe('getTestSuiteInfoCached', () => { + const suiteInfo = { name: 'cached-suite', schema: { metrics: [], commit_fields: [], machine_fields: [] } }; + + it('fetches on first call and returns data', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfo)); + + const result = await getTestSuiteInfoCached('cached-suite-1'); + expect(result.name).toBe('cached-suite'); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('returns cached result on second call without new fetch', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfo)); + + const r1 = await getTestSuiteInfoCached('cached-suite-2'); + const r2 = await getTestSuiteInfoCached('cached-suite-2'); + + expect(r1).toBe(r2); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it('evicts rejected promise so retry works', async () => { + mockFetch + .mockResolvedValueOnce(mockResponse('error', 500, 'Internal Server Error')) + .mockResolvedValueOnce(mockResponse(suiteInfo)); + + await expect(getTestSuiteInfoCached('cached-suite-3')).rejects.toThrow(); + // Allow microtask for eviction .catch() to run + await new Promise(r => setTimeout(r, 0)); + + const result = await getTestSuiteInfoCached('cached-suite-3'); + expect(result.name).toBe('cached-suite'); + expect(mockFetch).toHaveBeenCalledTimes(2); + }); + + it('coalesces concurrent calls on one fetch', async () => { + mockFetch.mockResolvedValueOnce(mockResponse(suiteInfo)); + + const [r1, r2] = await Promise.all([ + getTestSuiteInfoCached('cached-suite-4'), + getTestSuiteInfoCached('cached-suite-4'), + ]); + + expect(r1).toBe(r2); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); +}); + +// =========================================================================== +// Profiles +// =========================================================================== + +describe('getProfilesForRun', () => { + it('returns profile list items', async () => { + const items: ProfileListItem[] = [ + { test: 'bench/foo', uuid: 'prof-uuid-1' }, + { test: 'bench/bar', uuid: 'prof-uuid-2' }, + ]; + mockFetch.mockResolvedValueOnce(mockResponse(items)); + + const result = await getProfilesForRun('nts', 'run-uuid-123'); + + expect(result).toEqual(items); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs/run-uuid-123/profiles'); + }); + + it('encodes run UUID in URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse([])); + + await getProfilesForRun('nts', 'abc/special'); + + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/runs/abc%2Fspecial/profiles'); + }); + + it('passes AbortSignal', async () => { + const controller = new AbortController(); + mockFetch.mockResolvedValueOnce(mockResponse([])); + + await getProfilesForRun('nts', 'run-1', controller.signal); + + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + }); +}); + +describe('getProfileMetadata', () => { + it('returns profile metadata', async () => { + const meta: ProfileMetadata = { + uuid: 'prof-1', + test: 'bench/foo', + run_uuid: 'run-1', + counters: { cycles: 5000000, 'branch-misses': 42000 }, + disassembly_format: 'llvm-objdump', + }; + mockFetch.mockResolvedValueOnce(mockResponse(meta)); + + const result = await getProfileMetadata('nts', 'prof-1'); + + expect(result).toEqual(meta); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/profiles/prof-1'); + }); + + it('throws ApiError on 404', async () => { + mockFetch.mockResolvedValueOnce(mockResponse('Not found', 404, 'Not Found')); + + await expect(getProfileMetadata('nts', 'nonexistent')).rejects.toThrow(ApiError); + }); +}); + +describe('getProfileFunctions', () => { + it('returns wrapped function list', async () => { + const fns: ProfileFunctionInfo[] = [ + { name: 'main', counters: { cycles: 80.0 }, length: 42 }, + { name: 'helper', counters: { cycles: 20.0 }, length: 10 }, + ]; + mockFetch.mockResolvedValueOnce(mockResponse({ functions: fns })); + + const result = await getProfileFunctions('nts', 'prof-1'); + + expect(result.functions).toEqual(fns); + expect(result.functions).toHaveLength(2); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/profiles/prof-1/functions'); + }); +}); + +describe('getProfileFunctionDetail', () => { + it('returns function detail with instructions', async () => { + const detail: ProfileFunctionDetail = { + name: 'main', + counters: { cycles: 80.0 }, + disassembly_format: 'llvm-objdump', + instructions: [ + { address: 0x1000, counters: { cycles: 50.0 }, text: 'push rbp' }, + { address: 0x1004, counters: { cycles: 30.0 }, text: 'mov rsp, rbp' }, + ], + }; + mockFetch.mockResolvedValueOnce(mockResponse(detail)); + + const result = await getProfileFunctionDetail('nts', 'prof-1', 'main'); + + expect(result).toEqual(detail); + const url = new URL(mockFetch.mock.calls[0][0]); + expect(url.pathname).toBe('/api/v5/nts/profiles/prof-1/functions/main'); + }); + + it('encodes C++ mangled function names in URL', async () => { + mockFetch.mockResolvedValueOnce(mockResponse({ + name: '_Z3fooIiEvT_', + counters: {}, + disassembly_format: 'raw', + instructions: [], + })); + + await getProfileFunctionDetail('nts', 'prof-1', '_Z3fooIiEvT_'); + + const url = new URL(mockFetch.mock.calls[0][0]); + // encodeURIComponent encodes < > ( ) but not underscores + expect(url.pathname).toContain('_Z3fooIiEvT_'); + }); + + it('encodes special characters in function names', async () => { + const fnName = 'std::vector<int>::push_back(int&&)'; + mockFetch.mockResolvedValueOnce(mockResponse({ + name: fnName, + counters: {}, + disassembly_format: 'raw', + instructions: [], + })); + + await getProfileFunctionDetail('nts', 'prof-1', fnName); + + const url = new URL(mockFetch.mock.calls[0][0]); + // The URL should contain the encoded form — verify it doesn't break URL parsing + expect(url.pathname).toContain(encodeURIComponent(fnName)); + }); + + it('passes AbortSignal', async () => { + const controller = new AbortController(); + mockFetch.mockResolvedValueOnce(mockResponse({ + name: 'fn', counters: {}, disassembly_format: 'raw', instructions: [], + })); + + await getProfileFunctionDetail('nts', 'prof-1', 'fn', controller.signal); + + expect(mockFetch.mock.calls[0][1].signal).toBe(controller.signal); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/chart.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/chart.test.ts new file mode 100644 index 000000000..ce9811c42 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/chart.test.ts @@ -0,0 +1,375 @@ +import { describe, it, expect } from 'vitest'; +import { prepareChartData, prepareShadowChartData, generateChartTicks } from '../chart'; +import type { ComparisonRow } from '../types'; + +/** Helper to build a ComparisonRow with sensible defaults. */ +function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: 100, + valueB: 110, + delta: 10, + deltaPct: 10, + ratio: 1.1, + status: 'improved', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; +} + +describe('prepareChartData', () => { + it('filters out rows where sidePresent !== "both"', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a-only', sidePresent: 'a_only', ratio: 1.1 }), + makeRow({ test: 'b-only', sidePresent: 'b_only', ratio: 1.2 }), + makeRow({ test: 'both-ok', sidePresent: 'both', ratio: 1.05 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toEqual(['both-ok']); + }); + + it('filters out rows where ratio is null or non-positive', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'no-ratio', ratio: null }), + makeRow({ test: 'zero-ratio', ratio: 0 }), + makeRow({ test: 'neg-ratio', ratio: -1 }), + makeRow({ test: 'has-ratio', ratio: 1.05 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toEqual(['has-ratio']); + }); + + it('filters out rows where status is "na"', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'na-status', status: 'na', ratio: 1.1 }), + makeRow({ test: 'ok-status', status: 'improved', ratio: 1.05 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toEqual(['ok-status']); + }); + + it('includes noise rows (visibility is controlled by the caller)', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'noisy', status: 'noise', ratio: 1.001 }), + makeRow({ test: 'improved', status: 'improved', ratio: 1.2 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toContain('noisy'); + expect(result!.sortedTests).toHaveLength(2); + }); + + it('filterTests filters to specific test names', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'alpha', ratio: 1.1 }), + makeRow({ test: 'beta', ratio: 1.2 }), + makeRow({ test: 'gamma', ratio: 0.9 }), + ]; + const filter = new Set(['alpha', 'gamma']); + const result = prepareChartData(rows, filter); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toHaveLength(2); + expect(result!.sortedTests).toContain('alpha'); + expect(result!.sortedTests).toContain('gamma'); + expect(result!.sortedTests).not.toContain('beta'); + }); + + it('sorts by ratio ascending', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'high', ratio: 1.5, status: 'improved' }), + makeRow({ test: 'low', ratio: 0.5, status: 'regressed' }), + makeRow({ test: 'mid', ratio: 1.0, status: 'noise' }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.sortedTests).toEqual(['low', 'mid', 'high']); + }); + + it('returns null when no plottable data', () => { + // All rows filtered out: a_only, null ratio, na status + const rows: ComparisonRow[] = [ + makeRow({ test: 'a-only', sidePresent: 'a_only' }), + makeRow({ test: 'no-ratio', ratio: null }), + makeRow({ test: 'na', status: 'na' }), + ]; + expect(prepareChartData(rows, null)).toBeNull(); + + // Empty input + expect(prepareChartData([], null)).toBeNull(); + }); + + it('assigns correct colors by status', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'imp', status: 'improved', ratio: 1.1 }), + makeRow({ test: 'reg', status: 'regressed', ratio: 0.9 }), + makeRow({ test: 'noi', status: 'noise', ratio: 1.001 }), + makeRow({ test: 'unc', status: 'unchanged', ratio: 1.0 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + + // Map test names to colors for order-independent assertions + const colorByTest = new Map( + result!.sortedTests.map((t, i) => [t, result!.colors[i]]) + ); + expect(colorByTest.get('imp')).toBe('#2ca02c'); // improved = green + expect(colorByTest.get('reg')).toBe('#d62728'); // regressed = red + expect(colorByTest.get('noi')).toBe('#999999'); // noise = grey + expect(colorByTest.get('unc')).toBe('#999999'); // unchanged = grey + }); + + it('computes y values as log2(ratio)', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', ratio: 2.0, status: 'regressed' }), + makeRow({ test: 'b', ratio: 0.5, status: 'improved' }), + makeRow({ test: 'c', ratio: 1.0, status: 'noise' }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + + // Map test names to y values for order-independent assertions + const yByTest = new Map( + result!.sortedTests.map((t, i) => [t, result!.y[i]]) + ); + expect(yByTest.get('a')).toBeCloseTo(1); // log2(2.0) = 1 + expect(yByTest.get('b')).toBeCloseTo(-1); // log2(0.5) = -1 + expect(yByTest.get('c')).toBeCloseTo(0); // log2(1.0) = 0 + }); + + it('produces symmetric y values for equal multiplicative changes', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'fast', ratio: 0.25, status: 'improved' }), // 4× faster + makeRow({ test: 'slow', ratio: 4.0, status: 'regressed' }), // 4× slower + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + + const yByTest = new Map( + result!.sortedTests.map((t, i) => [t, result!.y[i]]) + ); + expect(yByTest.get('fast')).toBeCloseTo(-2); // log2(0.25) = -2 + expect(yByTest.get('slow')).toBeCloseTo(2); // log2(4.0) = 2 + // Symmetric: |y_fast| === |y_slow| + expect(Math.abs(yByTest.get('fast')!)).toBeCloseTo(Math.abs(yByTest.get('slow')!)); + }); + + it('produces correct customdata format', () => { + const rows: ComparisonRow[] = [ + makeRow({ + test: 'mytest', + valueA: 100, + valueB: 120, + deltaPct: 20, + ratio: 1.2, + status: 'improved', + }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.customdata).toHaveLength(1); + + const cd = result!.customdata[0]; + expect(cd[0]).toBe('mytest'); // test name + expect(cd[1]).toBe((100).toPrecision(4)); // valueA + expect(cd[2]).toBe((120).toPrecision(4)); // valueB + expect(cd[3]).toBe('+20.00%'); // deltaPct with sign + expect(cd[4]).toBe((1.2).toFixed(4)); // ratio + }); + + it('customdata handles null values', () => { + const rows: ComparisonRow[] = [ + makeRow({ + test: 'partial', + valueA: null, + valueB: null, + deltaPct: null, + ratio: 1.05, + status: 'noise', + }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + + const cd = result!.customdata[0]; + expect(cd[0]).toBe('partial'); + expect(cd[1]).toBe('N/A'); + expect(cd[2]).toBe('N/A'); + expect(cd[3]).toBe('N/A'); + expect(cd[4]).toBe((1.05).toFixed(4)); + }); + + it('customdata formats negative deltaPct without explicit plus sign', () => { + const rows: ComparisonRow[] = [ + makeRow({ + test: 'regtest', + valueA: 100, + valueB: 80, + deltaPct: -20, + ratio: 0.8, + status: 'regressed', + }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + + const cd = result!.customdata[0]; + expect(cd[3]).toBe('-20.00%'); + }); + + it('x values are sequential indices starting at 0', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', ratio: 1.1 }), + makeRow({ test: 'b', ratio: 1.2 }), + makeRow({ test: 'c', ratio: 0.9 }), + ]; + const result = prepareChartData(rows, null); + expect(result).not.toBeNull(); + expect(result!.x).toEqual([0, 1, 2]); + }); + + it('filterTests combined with noise rows', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'keep', status: 'improved', ratio: 1.2 }), + makeRow({ test: 'noise-keep', status: 'noise', ratio: 1.001 }), + makeRow({ test: 'filter-out', status: 'improved', ratio: 1.1 }), + ]; + const filter = new Set(['keep', 'noise-keep']); + const result = prepareChartData(rows, filter); + expect(result).not.toBeNull(); + // noise-keep is included (caller handles visibility), filter-out is excluded + expect(result!.sortedTests).toHaveLength(2); + expect(result!.sortedTests).toContain('keep'); + expect(result!.sortedTests).toContain('noise-keep'); + }); +}); + +describe('generateChartTicks', () => { + it('always includes 0%', () => { + const { tickvals, ticktext } = generateChartTicks(-1, 1); + expect(tickvals).toContain(0); + expect(ticktext[tickvals.indexOf(0)]).toBe('0%'); + }); + + it('shows fine-grained ticks for small ranges (±5%)', () => { + // All data within ±5%: log₂(1.05) ≈ 0.07, log₂(0.95) ≈ -0.074 + const { ticktext } = generateChartTicks(-0.074, 0.07); + // Should include small percentages like ±1%, ±2%, ±5% + expect(ticktext.some(t => t.includes('1%'))).toBe(true); + expect(ticktext.some(t => t.includes('5%'))).toBe(true); + }); + + it('shows coarser ticks for large ranges', () => { + // Range spanning -13 to +9 (huge: 1/8192× to 512×) + const { tickvals, ticktext } = generateChartTicks(-13, 9); + // Should not exceed 10 ticks + expect(tickvals.length).toBeLessThanOrEqual(10); + // Should still include 0% + expect(ticktext).toContain('0%'); + // Should include large percentages + expect(ticktext.some(t => t.includes('100%') || t.includes('1,000%'))).toBe(true); + }); + + it('tickvals are sorted ascending', () => { + const { tickvals } = generateChartTicks(-2, 2); + for (let i = 1; i < tickvals.length; i++) { + expect(tickvals[i]).toBeGreaterThan(tickvals[i - 1]); + } + }); + + it('positive ticks use + prefix, negative ticks use − prefix', () => { + const { tickvals, ticktext } = generateChartTicks(-0.5, 0.5); + for (let i = 0; i < tickvals.length; i++) { + if (tickvals[i] > 0) expect(ticktext[i]).toMatch(/^\+/); + else if (tickvals[i] < 0) expect(ticktext[i]).toMatch(/^\u2212/); + else expect(ticktext[i]).toBe('0%'); + } + }); +}); + +describe('prepareShadowChartData', () => { + it('sorts shadow rows independently by ratio ascending', () => { + const shadowRows = [ + makeRow({ test: 'gamma', ratio: 1.5 }), + makeRow({ test: 'alpha', ratio: 0.8 }), + makeRow({ test: 'beta', ratio: 1.1 }), + ]; + const result = prepareShadowChartData(shadowRows, null); + expect(result).not.toBeNull(); + expect(result!.x).toEqual([0, 1, 2]); + expect(result!.y[0]).toBeCloseTo(Math.log2(0.8)); + expect(result!.y[1]).toBeCloseTo(Math.log2(1.1)); + expect(result!.y[2]).toBeCloseTo(Math.log2(1.5)); + expect(result!.customdata[0][0]).toBe('alpha'); + expect(result!.customdata[2][0]).toBe('gamma'); + }); + + it('applies filterTests', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0.8 }), + makeRow({ test: 'beta', ratio: 1.1 }), + makeRow({ test: 'gamma', ratio: 1.5 }), + ]; + const result = prepareShadowChartData(shadowRows, new Set(['alpha', 'gamma'])); + expect(result).not.toBeNull(); + expect(result!.x).toEqual([0, 1]); + expect(result!.customdata[0][0]).toBe('alpha'); + expect(result!.customdata[1][0]).toBe('gamma'); + }); + + it('returns null for empty input', () => { + expect(prepareShadowChartData([], null)).toBeNull(); + }); + + it('skips shadow row with ratio null', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: null }), + ]; + expect(prepareShadowChartData(shadowRows, null)).toBeNull(); + }); + + it('skips shadow row with ratio <= 0', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0 }), + makeRow({ test: 'beta', ratio: -1 }), + ]; + expect(prepareShadowChartData(shadowRows, null)).toBeNull(); + }); + + it('skips shadow row with sidePresent !== both', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0.8, sidePresent: 'a_only' }), + ]; + expect(prepareShadowChartData(shadowRows, null)).toBeNull(); + }); + + it('skips shadow row with status na', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0.8, status: 'na' }), + ]; + expect(prepareShadowChartData(shadowRows, null)).toBeNull(); + }); + + it('includes shadow rows with status noise', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0.8, status: 'noise' }), + ]; + const result = prepareShadowChartData(shadowRows, null); + expect(result).not.toBeNull(); + expect(result!.x).toEqual([0]); + }); + + it('customdata has 5 elements (standard format)', () => { + const shadowRows = [ + makeRow({ test: 'alpha', ratio: 0.85, valueA: 100, valueB: 85, deltaPct: -15 }), + ]; + const result = prepareShadowChartData(shadowRows, null); + expect(result).not.toBeNull(); + const cd = result!.customdata[0]; + expect(cd.length).toBe(5); + expect(cd[0]).toBe('alpha'); + expect(cd[4]).toBe('0.8500'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/checkbox-range.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/checkbox-range.test.ts new file mode 100644 index 000000000..e655929e9 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/checkbox-range.test.ts @@ -0,0 +1,99 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { setupCheckboxRange } from '../components/checkbox-range'; + +function createCheckboxList(count: number): { container: HTMLElement; boxes: HTMLInputElement[] } { + const container = document.createElement('div'); + const boxes: HTMLInputElement[] = []; + for (let i = 0; i < count; i++) { + const cb = document.createElement('input'); + cb.type = 'checkbox'; + cb.setAttribute('data-uuid', `uuid-${i}`); + container.append(cb); + boxes.push(cb); + } + return { container, boxes }; +} + +/** Simulate a click with optional shiftKey. JSDOM toggles checkbox checked + * state on dispatchEvent(MouseEvent('click')), so we just dispatch. */ +function clickCheckbox(cb: HTMLInputElement, shiftKey = false): void { + cb.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey })); +} + +describe('setupCheckboxRange', () => { + let container: HTMLElement; + let boxes: HTMLInputElement[]; + let changeCalls: number; + + beforeEach(() => { + ({ container, boxes } = createCheckboxList(5)); + changeCalls = 0; + }); + + it('does nothing on normal click (no shift)', () => { + setupCheckboxRange(container, 'input[type="checkbox"][data-uuid]', () => changeCalls++); + + clickCheckbox(boxes[2]); // checks it + + // Only the clicked checkbox should be checked + expect(boxes.map(b => b.checked)).toEqual([false, false, true, false, false]); + }); + + it('selects range on shift+click', () => { + setupCheckboxRange(container, 'input[type="checkbox"][data-uuid]', () => changeCalls++); + + // Click first + clickCheckbox(boxes[1]); // checks box 1 + + // Shift+click fourth + clickCheckbox(boxes[4], true); // checks box 4, range fills 1..4 + + expect(boxes.map(b => b.checked)).toEqual([false, true, true, true, true]); + expect(changeCalls).toBeGreaterThan(0); + }); + + it('deselects range on shift+click with unchecked', () => { + // Check all first + boxes.forEach(b => { b.checked = true; }); + + setupCheckboxRange(container, 'input[type="checkbox"][data-uuid]', () => changeCalls++); + + // Click to uncheck box 1 + clickCheckbox(boxes[1]); // unchecks + + // Shift+click to uncheck box 3 + clickCheckbox(boxes[3], true); // unchecks, range fills 1..3 + + expect(boxes.map(b => b.checked)).toEqual([true, false, false, false, true]); + }); + + it('handles shift+click after DOM reorder', () => { + setupCheckboxRange(container, 'input[type="checkbox"][data-uuid]', () => changeCalls++); + + // Click box 0 + clickCheckbox(boxes[0]); // checks it + + // Reorder: reverse the checkboxes in DOM + container.replaceChildren(...[...boxes].reverse()); + + // Now boxes[0] is last in DOM order (index 4), boxes[4] is first (index 0) + // Shift+click boxes[4] (first in DOM) + clickCheckbox(boxes[4], true); // checks box 4, range fills DOM indices 0..4 + + expect(boxes.every(b => b.checked)).toBe(true); + }); + + it('destroy removes the event listener', () => { + const handle = setupCheckboxRange(container, 'input[type="checkbox"][data-uuid]', () => changeCalls++); + + handle.destroy(); + + clickCheckbox(boxes[1]); // checks + clickCheckbox(boxes[3], true); // checks, but no range since listener is gone + + // Range should NOT have been applied + expect(boxes.map(b => b.checked)).toEqual([false, true, false, true, false]); + expect(changeCalls).toBe(0); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/combobox-base.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/combobox-base.test.ts new file mode 100644 index 000000000..dd8e95905 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/combobox-base.test.ts @@ -0,0 +1,479 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { createCombobox, type ComboboxItem, type ComboboxOptions } from '../components/combobox'; + +const ITEMS: ComboboxItem[] = [ + { value: 'alpha', display: 'Alpha' }, + { value: 'beta', display: 'Beta' }, + { value: 'gamma', display: 'Gamma' }, +]; + +function makeOpts(overrides?: Partial<ComboboxOptions>): ComboboxOptions { + return { + id: 'test', + placeholder: 'Search...', + getItems: (filter) => { + if (!filter.trim()) return ITEMS; + return ITEMS.filter(i => i.display.toLowerCase().includes(filter.toLowerCase())); + }, + onSelect: vi.fn(), + ...overrides, + }; +} + +beforeEach(() => { + document.body.innerHTML = ''; +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('createCombobox', () => { + // --- DOM structure & ARIA --- + + describe('DOM structure & ARIA', () => { + it('renders wrapper with role=combobox', () => { + const handle = createCombobox(makeOpts()); + expect(handle.element.getAttribute('role')).toBe('combobox'); + expect(handle.element.getAttribute('aria-expanded')).toBe('false'); + expect(handle.element.getAttribute('aria-haspopup')).toBe('listbox'); + }); + + it('renders input with role=searchbox', () => { + const handle = createCombobox(makeOpts()); + expect(handle.input.getAttribute('role')).toBe('searchbox'); + expect(handle.input.getAttribute('aria-autocomplete')).toBe('list'); + }); + + it('input aria-controls matches dropdown id', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(handle.input.getAttribute('aria-controls')).toBe(dropdown.id); + }); + + it('dropdown has role=listbox', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.getAttribute('role')).toBe('listbox'); + }); + + it('dropdown items have role=option', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + for (const li of items) { + expect(li.getAttribute('role')).toBe('option'); + } + }); + + it('sets placeholder on input', () => { + const handle = createCombobox(makeOpts({ placeholder: 'Custom placeholder' })); + expect(handle.input.placeholder).toBe('Custom placeholder'); + }); + + it('sets initial value on input', () => { + const handle = createCombobox(makeOpts({ initialValue: 'Alpha' })); + expect(handle.input.value).toBe('Alpha'); + }); + + it('aria-expanded becomes true when dropdown opens', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + expect(handle.element.getAttribute('aria-expanded')).toBe('true'); + }); + }); + + // --- Dropdown display --- + + describe('Dropdown display', () => { + it('shows all items on focus', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(3); + }); + + it('filters items on input', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = 'alp'; + handle.input.dispatchEvent(new Event('input')); + const items = handle.element.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('Alpha'); + }); + + it('caps items at maxItems', () => { + const manyItems = Array.from({ length: 150 }, (_, i) => ({ + value: String(i), + display: `Item ${i}`, + })); + const handle = createCombobox(makeOpts({ + getItems: () => manyItems, + maxItems: 50, + })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(50); + }); + + it('defaults maxItems to 100', () => { + const manyItems = Array.from({ length: 150 }, (_, i) => ({ + value: String(i), + display: `Item ${i}`, + })); + const handle = createCombobox(makeOpts({ getItems: () => manyItems })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(100); + }); + }); + + // --- Selection --- + + describe('Selection', () => { + it('calls onSelect with item on click', () => { + const onSelect = vi.fn(); + const handle = createCombobox(makeOpts({ onSelect })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const item = handle.element.querySelector('li.combobox-item') as HTMLElement; + item.click(); + expect(onSelect).toHaveBeenCalledWith(ITEMS[0]); + }); + + it('sets input value on click', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const item = handle.element.querySelector('li.combobox-item') as HTMLElement; + item.click(); + expect(handle.input.value).toBe('Alpha'); + }); + + it('closes dropdown on click', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const item = handle.element.querySelector('li.combobox-item') as HTMLElement; + item.click(); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + expect(handle.element.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + // --- Keyboard --- + + describe('Keyboard', () => { + it('ArrowDown from input focuses first item', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const firstItem = handle.element.querySelector('li.combobox-item') as HTMLElement; + const focusSpy = vi.spyOn(firstItem, 'focus'); + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('ArrowDown/ArrowUp within dropdown moves focus', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + const spy1 = vi.spyOn(items[1] as HTMLElement, 'focus'); + const spy0 = vi.spyOn(items[0] as HTMLElement, 'focus'); + (items[0] as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(spy1).toHaveBeenCalled(); + (items[1] as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + expect(spy0).toHaveBeenCalled(); + }); + + it('ArrowUp from first item returns focus to input', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const firstItem = handle.element.querySelector('li.combobox-item') as HTMLElement; + const inputSpy = vi.spyOn(handle.input, 'focus'); + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + expect(inputSpy).toHaveBeenCalled(); + }); + + it('Enter on dropdown item selects it', () => { + const onSelect = vi.fn(); + const handle = createCombobox(makeOpts({ onSelect })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const firstItem = handle.element.querySelector('li.combobox-item') as HTMLElement; + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onSelect).toHaveBeenCalledWith(ITEMS[0]); + }); + + it('Escape closes dropdown from input', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('Escape from dropdown item closes and returns focus to input', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const firstItem = handle.element.querySelector('li.combobox-item') as HTMLElement; + const inputSpy = vi.spyOn(handle.input, 'focus'); + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(inputSpy).toHaveBeenCalled(); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('keeps dropdown open when ArrowDown moves focus to item', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + const firstItem = dropdown.querySelector('li.combobox-item') as HTMLElement; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + handle.input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + expect(dropdown.classList.contains('open')).toBe(true); + }); + }); + + // --- onEnter --- + + describe('onEnter', () => { + it('calls onEnter on Enter key in input', () => { + const onEnter = vi.fn().mockReturnValue(true); + const handle = createCombobox(makeOpts({ onEnter })); + document.body.append(handle.element); + handle.input.value = 'Alpha'; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onEnter).toHaveBeenCalledWith('Alpha'); + }); + + it('closes dropdown when onEnter returns true', () => { + const onEnter = vi.fn().mockReturnValue(true); + const handle = createCombobox(makeOpts({ onEnter })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + handle.input.value = 'Alpha'; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + const dropdown = handle.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('shows invalid halo when onEnter returns false', () => { + const onEnter = vi.fn().mockReturnValue(false); + const handle = createCombobox(makeOpts({ onEnter })); + document.body.append(handle.element); + handle.input.value = 'bad'; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(handle.input.classList.contains('combobox-invalid')).toBe(true); + }); + + it('does nothing on Enter when input is empty', () => { + const onEnter = vi.fn().mockReturnValue(true); + const handle = createCombobox(makeOpts({ onEnter })); + document.body.append(handle.element); + handle.input.value = ''; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onEnter).not.toHaveBeenCalled(); + }); + + it('does nothing on Enter when no onEnter callback', () => { + const onSelect = vi.fn(); + const handle = createCombobox(makeOpts({ onSelect })); + document.body.append(handle.element); + handle.input.value = 'Alpha'; + handle.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).not.toHaveBeenCalled(); + }); + }); + + // --- Validation halo --- + + describe('Validation halo', () => { + it('shows combobox-invalid when no items match', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = 'zzz-no-match'; + handle.input.dispatchEvent(new Event('input')); + expect(handle.input.classList.contains('combobox-invalid')).toBe(true); + }); + + it('removes combobox-invalid when items match again', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = 'zzz'; + handle.input.dispatchEvent(new Event('input')); + expect(handle.input.classList.contains('combobox-invalid')).toBe(true); + handle.input.value = 'alp'; + handle.input.dispatchEvent(new Event('input')); + expect(handle.input.classList.contains('combobox-invalid')).toBe(false); + }); + + it('no combobox-invalid when input is empty', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = ''; + handle.input.dispatchEvent(new Event('input')); + expect(handle.input.classList.contains('combobox-invalid')).toBe(false); + }); + + it('removes combobox-invalid on item click', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.classList.add('combobox-invalid'); + handle.input.dispatchEvent(new Event('focus')); + const item = handle.element.querySelector('li.combobox-item') as HTMLElement; + item.click(); + expect(handle.input.classList.contains('combobox-invalid')).toBe(false); + }); + }); + + // --- Status --- + + describe('Status', () => { + it('shows status message instead of items', () => { + const handle = createCombobox(makeOpts({ + getStatus: () => ({ text: 'Loading...' }), + })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('Loading...'); + expect(items[0].classList.contains('combobox-status')).toBe(true); + }); + + it('shows error status with error class', () => { + const handle = createCombobox(makeOpts({ + getStatus: () => ({ text: 'Failed to load', isError: true }), + })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li'); + expect(items[0].classList.contains('combobox-status-error')).toBe(true); + }); + + it('shows items when getStatus returns null', () => { + const handle = createCombobox(makeOpts({ + getStatus: () => null, + })); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const items = handle.element.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(3); + }); + + it('removes combobox-invalid when status is showing', () => { + const handle = createCombobox(makeOpts({ + getStatus: () => ({ text: 'Loading...' }), + })); + document.body.append(handle.element); + handle.input.classList.add('combobox-invalid'); + handle.input.dispatchEvent(new Event('focus')); + expect(handle.input.classList.contains('combobox-invalid')).toBe(false); + }); + }); + + // --- Dismiss --- + + describe('Dismiss', () => { + it('closes dropdown on blur', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + expect(handle.element.querySelector('.combobox-dropdown.open')).toBeTruthy(); + handle.input.dispatchEvent(new Event('blur')); + expect(handle.element.querySelector('.combobox-dropdown.open')).toBeNull(); + }); + + it('keeps dropdown open when focus moves within wrapper', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + const firstItem = handle.element.querySelector('li.combobox-item') as HTMLElement; + handle.input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + expect(handle.element.querySelector('.combobox-dropdown.open')).toBeTruthy(); + }); + + it('outside click closes dropdown', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.dispatchEvent(new Event('focus')); + expect(handle.element.getAttribute('aria-expanded')).toBe('true'); + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(handle.element.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + // --- onClear --- + + describe('onClear', () => { + it('calls onClear on change with empty input', () => { + const onClear = vi.fn(); + const handle = createCombobox(makeOpts({ onClear })); + document.body.append(handle.element); + handle.input.value = ''; + handle.input.dispatchEvent(new Event('change')); + expect(onClear).toHaveBeenCalled(); + }); + + it('does not call onClear when input has text', () => { + const onClear = vi.fn(); + const handle = createCombobox(makeOpts({ onClear })); + document.body.append(handle.element); + handle.input.value = 'something'; + handle.input.dispatchEvent(new Event('change')); + expect(onClear).not.toHaveBeenCalled(); + }); + + it('does not throw when onClear is not provided', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = ''; + expect(() => handle.input.dispatchEvent(new Event('change'))).not.toThrow(); + }); + }); + + // --- Lifecycle --- + + describe('Lifecycle', () => { + it('setValue updates the input value', () => { + const handle = createCombobox(makeOpts()); + handle.setValue('New value'); + expect(handle.input.value).toBe('New value'); + }); + + it('clear resets input and halo', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + handle.input.value = 'something'; + handle.input.classList.add('combobox-invalid'); + handle.clear(); + expect(handle.input.value).toBe(''); + expect(handle.input.classList.contains('combobox-invalid')).toBe(false); + }); + + it('destroy removes document click listener', () => { + const handle = createCombobox(makeOpts()); + document.body.append(handle.element); + const spy = vi.spyOn(document, 'removeEventListener'); + handle.destroy(); + expect(spy).toHaveBeenCalledWith('click', expect.any(Function)); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/commit-combobox.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/commit-combobox.test.ts new file mode 100644 index 000000000..e417a639f --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/commit-combobox.test.ts @@ -0,0 +1,564 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { createCommitPicker } from '../components/commit-combobox'; + +const COMMIT_VALUES = ['100', '101', '102', '200']; + +beforeEach(() => { + vi.clearAllMocks(); + document.body.innerHTML = ''; +}); + +describe('createCommitPicker', () => { + it('renders a combobox wrapper with input and dropdown', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + expect(picker.element.getAttribute('role')).toBe('combobox'); + expect(picker.element.querySelector('input')).toBeTruthy(); + expect(picker.element.querySelector('ul')).toBeTruthy(); + + picker.element.remove(); + }); + + it('shows all commits on focus', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items).toHaveLength(4); + + picker.element.remove(); + }); + + it('displays values in dropdown items', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + const texts = Array.from(items).map(li => li.textContent); + expect(texts).toContain('100'); + expect(texts).toContain('101'); + expect(texts).toContain('102'); + expect(texts).toContain('200'); + + picker.element.remove(); + }); + + it('filters by commit value', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.value = '10'; + picker.input.dispatchEvent(new Event('input')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items).toHaveLength(3); // 100, 101, 102 + expect(Array.from(items).map(li => li.textContent)).not.toContain('200'); + + picker.element.remove(); + }); + + it('filters by commit value prefix', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.value = '200'; + picker.input.dispatchEvent(new Event('input')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('200'); + + picker.element.remove(); + }); + + it('calls onSelect with commit value on click', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + (items[0] as HTMLElement).click(); // "100" + + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('sets input value on selection', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + const items = picker.element.querySelectorAll('.combobox-item'); + (items[0] as HTMLElement).click(); + + expect(picker.input.value).toBe('100'); + + picker.element.remove(); + }); + + it('keeps dropdown open when ArrowDown moves focus to an item', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + const dropdown = picker.element.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + const firstItem = dropdown.querySelector('li.combobox-item') as HTMLElement; + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + picker.input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + + expect(dropdown.classList.contains('open')).toBe(true); + + picker.element.remove(); + }); + + it('selects item via ArrowDown then Enter', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + const dropdown = picker.element.querySelector('ul') as HTMLElement; + + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + const firstItem = dropdown.querySelector('li.combobox-item') as HTMLElement; + picker.input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('accepts value on change event', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '100'; + picker.input.dispatchEvent(new Event('change')); + + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('sets initial value', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + initialValue: '100', + onSelect: () => {}, + }); + document.body.append(picker.element); + + expect(picker.input.value).toBe('100'); + + picker.element.remove(); + }); + + it('sets initial value for any commit', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + initialValue: '101', + onSelect: () => {}, + }); + + expect(picker.input.value).toBe('101'); + }); + + it('limits dropdown to 100 items', () => { + const values = Array.from({ length: 150 }, (_, i) => String(i)); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items).toHaveLength(100); + + picker.element.remove(); + }); + + it('closes dropdown on blur', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + expect(picker.element.querySelector('.combobox-dropdown.open')).toBeTruthy(); + + picker.input.dispatchEvent(new Event('blur')); + expect(picker.element.querySelector('.combobox-dropdown.open')).toBeNull(); + + picker.element.remove(); + }); + + it('uses custom placeholder', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + placeholder: 'Custom placeholder', + onSelect: () => {}, + }); + + expect(picker.input.placeholder).toBe('Custom placeholder'); + }); + + // --- Validation tests --- + + it('shows combobox-invalid on input when no commits match', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.value = 'zzz-no-match'; + picker.input.dispatchEvent(new Event('input')); + + expect(picker.input.classList.contains('combobox-invalid')).toBe(true); + + picker.element.remove(); + }); + + it('removes combobox-invalid on input when commits match', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.value = 'zzz'; + picker.input.dispatchEvent(new Event('input')); + expect(picker.input.classList.contains('combobox-invalid')).toBe(true); + + picker.input.value = '10'; + picker.input.dispatchEvent(new Event('input')); + expect(picker.input.classList.contains('combobox-invalid')).toBe(false); + + picker.element.remove(); + }); + + it('no combobox-invalid when input is empty', () => { + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.value = ''; + picker.input.dispatchEvent(new Event('input')); + + expect(picker.input.classList.contains('combobox-invalid')).toBe(false); + + picker.element.remove(); + }); + + it('does not call onSelect on change when combobox-invalid', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = 'zzz-invalid'; + picker.input.dispatchEvent(new Event('input')); // triggers invalid + picker.input.dispatchEvent(new Event('change')); + + expect(onSelect).not.toHaveBeenCalled(); + + picker.element.remove(); + }); + + it('calls onSelect on Enter when input is valid', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '100'; + picker.input.dispatchEvent(new Event('input')); // populate dropdown + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('does not call onSelect on Enter when input is invalid', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = 'zzz-invalid'; + picker.input.dispatchEvent(new Event('input')); // triggers invalid + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(onSelect).not.toHaveBeenCalled(); + + picker.element.remove(); + }); + + it('rejects partial match on Enter (exact-match required)', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + // "10" substring-matches "100", "101", "102" but is not an exact match + picker.input.value = '10'; + picker.input.dispatchEvent(new Event('input')); + expect(picker.input.classList.contains('combobox-invalid')).toBe(false); // suggestions exist + + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).not.toHaveBeenCalled(); + expect(picker.input.classList.contains('combobox-invalid')).toBe(true); + + picker.element.remove(); + }); + + it('rejects partial match on change (exact-match required)', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '10'; + picker.input.dispatchEvent(new Event('input')); + picker.input.dispatchEvent(new Event('change')); + expect(onSelect).not.toHaveBeenCalled(); + expect(picker.input.classList.contains('combobox-invalid')).toBe(true); + + picker.element.remove(); + }); + + it('accepts exact match on Enter', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '100'; + picker.input.dispatchEvent(new Event('input')); + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('accepts exact match on change', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '100'; + picker.input.dispatchEvent(new Event('input')); + picker.input.dispatchEvent(new Event('change')); + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); + + it('accepts exact match on Enter via display value', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'test', + getCommitData: () => ({ values: COMMIT_VALUES }), + onSelect, + }); + document.body.append(picker.element); + + picker.input.value = '100'; + picker.input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).toHaveBeenCalledWith('100'); + + picker.element.remove(); + }); +}); + +// --------------------------------------------------------------------------- +// createCommitPicker with displayMap +// --------------------------------------------------------------------------- + +describe('createCommitPicker with displayMap', () => { + const DISPLAY_MAP = new Map([['abc123', 'v1.0'], ['def456', 'v2.0']]); + const COMMIT_VALUES_DM = ['abc123', 'def456', 'ghi789']; + + it('shows display values in dropdown items', () => { + const picker = createCommitPicker({ + id: 'display-test', + getCommitData: () => ({ values: COMMIT_VALUES_DM, displayMap: DISPLAY_MAP }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + const input = picker.input; + input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items[0].textContent).toBe('v1.0'); + expect(items[1].textContent).toBe('v2.0'); + expect(items[2].textContent).toBe('ghi789'); // no mapping, raw string + + picker.element.remove(); + }); + + it('filters by display value', () => { + const picker = createCommitPicker({ + id: 'filter-test', + getCommitData: () => ({ values: COMMIT_VALUES_DM, displayMap: DISPLAY_MAP }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + const input = picker.input; + input.value = 'v1'; + input.dispatchEvent(new Event('input')); + + const items = picker.element.querySelectorAll('.combobox-item'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toBe('v1.0'); + + picker.element.remove(); + }); + + it('calls onSelect with raw commit string', () => { + const onSelect = vi.fn(); + const picker = createCommitPicker({ + id: 'select-test', + getCommitData: () => ({ values: COMMIT_VALUES_DM, displayMap: DISPLAY_MAP }), + onSelect, + }); + document.body.append(picker.element); + + const input = picker.input; + input.dispatchEvent(new Event('focus')); + + const items = picker.element.querySelectorAll('.combobox-item'); + (items[0] as HTMLElement).click(); + + expect(onSelect).toHaveBeenCalledWith('abc123'); // raw string, not 'v1.0' + + picker.element.remove(); + }); + + it('sets display value in input after click selection', () => { + const picker = createCommitPicker({ + id: 'click-display-test', + getCommitData: () => ({ values: COMMIT_VALUES_DM, displayMap: DISPLAY_MAP }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.input.dispatchEvent(new Event('focus')); + const items = picker.element.querySelectorAll('.combobox-item'); + (items[0] as HTMLElement).click(); + + expect(picker.input.value).toBe('v1.0'); + + picker.element.remove(); + }); + + it('setValue resolves display value from current displayMap', () => { + let displayMap: Map<string, string> | undefined; + const picker = createCommitPicker({ + id: 'setvalue-test', + getCommitData: () => ({ values: ['abc123'], displayMap }), + onSelect: () => {}, + }); + document.body.append(picker.element); + + picker.setValue('abc123'); + expect(picker.input.value).toBe('abc123'); + + displayMap = new Map([['abc123', 'v1.0 (tag)']]); + picker.setValue('abc123'); + expect(picker.input.value).toBe('v1.0 (tag)'); + + picker.element.remove(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/commit-search.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/commit-search.test.ts new file mode 100644 index 000000000..3834d52d9 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/commit-search.test.ts @@ -0,0 +1,221 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock modules before importing the component +vi.mock('../api', () => ({ + searchCommits: vi.fn(), +})); +vi.mock('../router', () => ({ + navigate: vi.fn(), +})); + +import { renderCommitSearch } from '../components/commit-search'; +import { searchCommits } from '../api'; +import { navigate } from '../router'; + +const mockSearch = searchCommits as ReturnType<typeof vi.fn>; +const mockNavigate = navigate as ReturnType<typeof vi.fn>; + +beforeEach(() => { + vi.useFakeTimers(); + mockSearch.mockReset(); + mockNavigate.mockReset(); + document.body.innerHTML = ''; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +function cursorPage(items: Array<{ commit: string; ordinal: number | null; fields: Record<string, string> }>) { + return { items, cursor: { next: null, previous: null } }; +} + +describe('renderCommitSearch', () => { + it('renders an input and dropdown into the container', () => { + const container = document.createElement('div'); + renderCommitSearch(container, { testsuite: 'nts' }); + + expect(container.querySelector('input.commit-search-input')).not.toBeNull(); + expect(container.querySelector('ul.commit-search-dropdown')).not.toBeNull(); + }); + + it('calls searchCommits after debounce on input', async () => { + mockSearch.mockResolvedValue(cursorPage([])); + + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'release'; + input.dispatchEvent(new Event('input')); + + // Before debounce fires + expect(mockSearch).not.toHaveBeenCalled(); + + // After debounce (300ms) + await vi.advanceTimersByTimeAsync(300); + + expect(mockSearch).toHaveBeenCalledWith('nts', 'release', { limit: 10 }, expect.anything()); + }); + + it('shows dropdown with results including secondary info', async () => { + mockSearch.mockResolvedValue(cursorPage([ + { commit: 'abc123', ordinal: 1, fields: {} }, + { commit: 'def456', ordinal: null, fields: {} }, + ])); + + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'rel'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + const items = dropdown.querySelectorAll('li'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toContain('abc123'); + expect(items[0].textContent).not.toContain('#1'); // ordinal not shown + expect(items[1].textContent).toContain('def456'); + }); + + it('navigates on dropdown item click', async () => { + mockSearch.mockResolvedValue(cursorPage([ + { commit: 'abc123', ordinal: 1, fields: {} }, + ])); + + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'rel'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + const item = container.querySelector('li') as HTMLElement; + item.click(); + + expect(mockNavigate).toHaveBeenCalledWith('/commits/abc123'); + expect(input.value).toBe(''); + }); + + it('calls onSelect instead of navigate when provided', async () => { + mockSearch.mockResolvedValue(cursorPage([ + { commit: 'abc123', ordinal: null, fields: {} }, + ])); + + const onSelect = vi.fn(); + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts', onSelect }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'abc'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + const item = container.querySelector('li') as HTMLElement; + item.click(); + + expect(onSelect).toHaveBeenCalledWith('abc123'); + expect(mockNavigate).not.toHaveBeenCalled(); + // Input shows the selected value (not cleared) + expect(input.value).toBe('abc123'); + }); + + it('clears input via clear() method', () => { + const onSelect = vi.fn(); + const container = document.createElement('div'); + document.body.append(container); + const handle = renderCommitSearch(container, { testsuite: 'nts', onSelect }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'some-commit'; + + handle.clear(); + expect(input.value).toBe(''); + }); + + it('navigates to typed value on Enter', () => { + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'exact-hash'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + expect(mockNavigate).toHaveBeenCalledWith('/commits/exact-hash'); + }); + + it('closes dropdown on Escape', async () => { + mockSearch.mockResolvedValue(cursorPage([ + { commit: 'abc', ordinal: null, fields: {} }, + ])); + + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'abc'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('hides dropdown when input is empty', async () => { + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + expect(mockSearch).not.toHaveBeenCalled(); + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('hides dropdown when API returns no results', async () => { + mockSearch.mockResolvedValue(cursorPage([])); + + const container = document.createElement('div'); + document.body.append(container); + renderCommitSearch(container, { testsuite: 'nts' }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'nothing'; + input.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(300); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('destroy() removes document click listener', () => { + const container = document.createElement('div'); + document.body.append(container); + const handle = renderCommitSearch(container, { testsuite: 'nts' }); + + const spy = vi.spyOn(document, 'removeEventListener'); + handle.destroy(); + + expect(spy).toHaveBeenCalledWith('click', expect.any(Function)); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/comparison.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/comparison.test.ts new file mode 100644 index 000000000..02463b408 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/comparison.test.ts @@ -0,0 +1,984 @@ +import { describe, it, expect } from 'vitest'; +import { + aggregateSamplesWithinRun, + aggregateAcrossRuns, + groupSamplesByTest, + aggregateGrouped, + welchTTest, + computeComparison, + computeGeomean, + countRunsPerTest, +} from '../comparison'; +import type { SampleInfo, ComparisonRow, NoiseConfig } from '../types'; + +// --------------------------------------------------------------------------- +// Helper to build a NoiseConfig with only the Delta % knob enabled at a +// given threshold, matching the previous single-number API. +// --------------------------------------------------------------------------- +function pctOnly(threshold: number): NoiseConfig { + return { + pct: { enabled: true, value: threshold }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; +} + +function allDisabled(): NoiseConfig { + return { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; +} + +// --------------------------------------------------------------------------- +// groupSamplesByTest +// --------------------------------------------------------------------------- + +describe('groupSamplesByTest', () => { + it('pools samples across multiple runs for the same test', () => { + const run1: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 10 } }, + { test: 'foo', metrics: { exec_time: 20 } }, + ]; + const run2: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 30 } }, + ]; + const result = groupSamplesByTest([run1, run2], 'exec_time'); + expect(result.get('foo')).toEqual([10, 20, 30]); + }); + + it('skips null metric values', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 10 } }, + { test: 'foo', metrics: { exec_time: null } }, + { test: 'foo', metrics: { exec_time: 30 } }, + ]; + const result = groupSamplesByTest([samples], 'exec_time'); + expect(result.get('foo')).toEqual([10, 30]); + }); + + it('skips samples missing the metric entirely', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { other: 10 } }, + ]; + const result = groupSamplesByTest([samples], 'exec_time'); + expect(result.size).toBe(0); + }); + + it('returns empty map for empty input', () => { + expect(groupSamplesByTest([], 'exec_time').size).toBe(0); + expect(groupSamplesByTest([[]], 'exec_time').size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// aggregateGrouped +// --------------------------------------------------------------------------- + +describe('aggregateGrouped', () => { + it('aggregates grouped values with the specified function', () => { + const grouped = new Map([ + ['foo', [10, 20, 30]], + ['bar', [5]], + ]); + const result = aggregateGrouped(grouped, 'median'); + expect(result.get('foo')).toBe(20); + expect(result.get('bar')).toBe(5); + }); + + it('skips empty arrays', () => { + const grouped = new Map([['foo', []]]); + expect(aggregateGrouped(grouped, 'mean').size).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// aggregateSamplesWithinRun (preserved behavior) +// --------------------------------------------------------------------------- + +describe('aggregateSamplesWithinRun', () => { + it('groups by test name and applies median', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 10 } }, + { test: 'foo', metrics: { exec_time: 20 } }, + { test: 'foo', metrics: { exec_time: 30 } }, + { test: 'bar', metrics: { exec_time: 5 } }, + ]; + const result = aggregateSamplesWithinRun(samples, 'exec_time', 'median'); + expect(result.get('foo')).toBe(20); + expect(result.get('bar')).toBe(5); + }); + + it('skips null metric values', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 10 } }, + { test: 'foo', metrics: { exec_time: null } }, + { test: 'foo', metrics: { exec_time: 30 } }, + ]; + const result = aggregateSamplesWithinRun(samples, 'exec_time', 'mean'); + expect(result.get('foo')).toBe(20); // mean(10, 30) = 20 + }); + + it('skips samples missing the metric entirely', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { other_metric: 10 } }, + ]; + const result = aggregateSamplesWithinRun(samples, 'exec_time', 'median'); + expect(result.size).toBe(0); + }); + + it('returns empty map for empty samples', () => { + const result = aggregateSamplesWithinRun([], 'exec_time', 'median'); + expect(result.size).toBe(0); + }); + + it('uses the specified aggregation function', () => { + const samples: SampleInfo[] = [ + { test: 'foo', metrics: { exec_time: 10 } }, + { test: 'foo', metrics: { exec_time: 20 } }, + { test: 'foo', metrics: { exec_time: 30 } }, + ]; + expect(aggregateSamplesWithinRun(samples, 'exec_time', 'min').get('foo')).toBe(10); + expect(aggregateSamplesWithinRun(samples, 'exec_time', 'max').get('foo')).toBe(30); + expect(aggregateSamplesWithinRun(samples, 'exec_time', 'mean').get('foo')).toBe(20); + }); +}); + +// --------------------------------------------------------------------------- +// aggregateAcrossRuns (preserved behavior) +// --------------------------------------------------------------------------- + +describe('aggregateAcrossRuns', () => { + it('returns empty map for empty array', () => { + expect(aggregateAcrossRuns([], 'median').size).toBe(0); + }); + + it('returns the single map directly', () => { + const m = new Map([['foo', 10], ['bar', 20]]); + const result = aggregateAcrossRuns([m], 'median'); + expect(result).toEqual(m); + }); + + it('aggregates across multiple maps with overlapping tests', () => { + const m1 = new Map([['foo', 10], ['bar', 20]]); + const m2 = new Map([['foo', 30], ['bar', 40]]); + const result = aggregateAcrossRuns([m1, m2], 'mean'); + expect(result.get('foo')).toBe(20); // mean(10, 30) + expect(result.get('bar')).toBe(30); // mean(20, 40) + }); + + it('handles disjoint tests across maps', () => { + const m1 = new Map([['foo', 10]]); + const m2 = new Map([['bar', 20]]); + const result = aggregateAcrossRuns([m1, m2], 'median'); + expect(result.get('foo')).toBe(10); + expect(result.get('bar')).toBe(20); + }); + + it('handles partial overlap', () => { + const m1 = new Map([['foo', 10], ['bar', 20]]); + const m2 = new Map([['bar', 40], ['baz', 50]]); + const result = aggregateAcrossRuns([m1, m2], 'mean'); + expect(result.get('foo')).toBe(10); // only in m1 + expect(result.get('bar')).toBe(30); // mean(20, 40) + expect(result.get('baz')).toBe(50); // only in m2 + }); +}); + +// --------------------------------------------------------------------------- +// welchTTest +// --------------------------------------------------------------------------- + +describe('welchTTest', () => { + it('returns small p-value for clearly different groups (reference: scipy)', () => { + // scipy.stats.ttest_ind([1,2,3,4,5], [6,7,8,9,10]) → p ≈ 0.000430 + const p = welchTTest([1, 2, 3, 4, 5], [6, 7, 8, 9, 10]); + expect(p).not.toBeNull(); + expect(p!).toBeCloseTo(0.000430, 3); + }); + + it('returns p-value close to 1 for nearly identical groups', () => { + const p = welchTTest([10, 10.01, 9.99], [10.005, 9.995, 10]); + expect(p).not.toBeNull(); + expect(p!).toBeGreaterThan(0.5); + }); + + it('returns null for n=1 per side', () => { + expect(welchTTest([5], [10])).toBeNull(); + }); + + it('returns null for n=0 on one side', () => { + expect(welchTTest([], [1, 2, 3])).toBeNull(); + expect(welchTTest([1, 2, 3], [])).toBeNull(); + }); + + it('works with n=2 per side (minimum valid)', () => { + const p = welchTTest([1, 2], [100, 200]); + expect(p).not.toBeNull(); + expect(typeof p).toBe('number'); + expect(p!).toBeGreaterThan(0); + expect(p!).toBeLessThan(1); + }); + + it('works with highly unequal sample sizes', () => { + const a = [1, 2]; + const b = Array.from({ length: 30 }, (_, i) => 50 + i); + const p = welchTTest(a, b); + expect(p).not.toBeNull(); + expect(p!).toBeLessThan(0.001); + }); + + it('returns null for zero variance on both sides with equal means', () => { + expect(welchTTest([5, 5, 5], [5, 5, 5])).toBeNull(); + }); + + it('returns 0 for zero variance on both sides with different means', () => { + expect(welchTTest([5, 5, 5], [10, 10, 10])).toBe(0); + }); + + it('works with zero variance on one side only', () => { + const p = welchTTest([5, 5, 5], [3, 7, 5]); + expect(p).not.toBeNull(); + expect(typeof p).toBe('number'); + }); + + it('works with negative values', () => { + // Means are far apart relative to sample size but high variance + const p = welchTTest([-10, -20, -30], [-100, -200, -300]); + expect(p).not.toBeNull(); + expect(typeof p).toBe('number'); + expect(p!).toBeGreaterThan(0); + expect(p!).toBeLessThan(1); + }); + + it('returns correct p-value for large samples (reference: scipy)', () => { + // scipy.stats.ttest_ind(range(50), range(50, 100)) → p ≈ 4.15e-27 + const a = Array.from({ length: 50 }, (_, i) => i); + const b = Array.from({ length: 50 }, (_, i) => 50 + i); + const p = welchTTest(a, b); + expect(p).not.toBeNull(); + expect(p!).toBeLessThan(1e-20); + }); +}); + +// --------------------------------------------------------------------------- +// computeComparison — basic behavior (adapted from single-threshold tests) +// --------------------------------------------------------------------------- + +describe('computeComparison', () => { + it('marks improvement when bigger_is_better=true and B > A', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 120]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows).toHaveLength(1); + expect(rows[0].test).toBe('foo'); + expect(rows[0].status).toBe('improved'); + expect(rows[0].delta).toBe(20); + expect(rows[0].deltaPct).toBeCloseTo(20); + expect(rows[0].ratio).toBeCloseTo(1.2); + expect(rows[0].sidePresent).toBe('both'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('marks regression when bigger_is_better=true and B < A', () => { + const mapA = new Map([['foo', 120]]); + const mapB = new Map([['foo', 100]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('regressed'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('flips direction when bigger_is_better=false', () => { + const mapA = new Map([['foo', 120]]); + const mapB = new Map([['foo', 100]]); + const rows = computeComparison(mapA, mapB, false, pctOnly(1)); + expect(rows[0].status).toBe('improved'); + + const mapA2 = new Map([['foo', 100]]); + const mapB2 = new Map([['foo', 120]]); + const rows2 = computeComparison(mapA2, mapB2, false, pctOnly(1)); + expect(rows2[0].status).toBe('regressed'); + }); + + it('marks noise when delta % is within threshold', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 100.5]]); // 0.5% change, threshold is 1% + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('noise'); + expect(rows[0].noiseReasons).toHaveLength(1); + expect(rows[0].noiseReasons[0].knob).toBe('pct'); + }); + + it('handles zero baseline (valueA=0)', () => { + const mapA = new Map([['foo', 0]]); + const mapB = new Map([['foo', 5]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('na'); + expect(rows[0].delta).toBe(5); + expect(rows[0].deltaPct).toBeNull(); + expect(rows[0].ratio).toBeNull(); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('handles both values zero', () => { + const mapA = new Map([['foo', 0]]); + const mapB = new Map([['foo', 0]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('na'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('handles negative baseline with Math.abs in deltaPct', () => { + const mapA = new Map([['foo', -10]]); + const mapB = new Map([['foo', -5]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].delta).toBe(5); + expect(rows[0].deltaPct).toBeCloseTo(50); + expect(rows[0].ratio).toBeCloseTo(0.5); + expect(rows[0].status).toBe('improved'); + }); + + it('marks missing when test only in side A', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map<string, number>(); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('missing'); + expect(rows[0].sidePresent).toBe('a_only'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('marks missing when test only in side B', () => { + const mapA = new Map<string, number>(); + const mapB = new Map([['foo', 100]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(1)); + expect(rows[0].status).toBe('missing'); + expect(rows[0].sidePresent).toBe('b_only'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('returns empty for empty maps', () => { + const rows = computeComparison(new Map(), new Map(), true, pctOnly(1)); + expect(rows).toHaveLength(0); + }); +}); + +// --------------------------------------------------------------------------- +// computeComparison — noise boundary edge cases (pct knob only) +// --------------------------------------------------------------------------- + +describe('computeComparison — noise boundary edge cases', () => { + it('classifies exactly-at-threshold change as signal (strict <)', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 105]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows[0].deltaPct).toBeCloseTo(5); + expect(rows[0].status).toBe('improved'); + }); + + it('classifies exactly-at-threshold negative change as signal', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 95]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows[0].deltaPct).toBeCloseTo(-5); + expect(rows[0].status).toBe('regressed'); + }); + + it('classifies just-above-threshold change as improved (bigger_is_better=true)', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 105.01]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows[0].deltaPct).toBeGreaterThan(5); + expect(rows[0].status).toBe('improved'); + }); + + it('classifies just-above-threshold negative change as regressed (bigger_is_better=true)', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 94.99]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows[0].deltaPct).toBeLessThan(-5); + expect(rows[0].status).toBe('regressed'); + }); + + it('classifies just-below-threshold change as noise', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 104.99]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows[0].deltaPct).toBeCloseTo(4.99); + expect(rows[0].status).toBe('noise'); + }); + + it('with noiseThreshold=0, tiny positive change is improved', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 100.001]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(0)); + expect(rows[0].deltaPct).toBeGreaterThan(0); + expect(rows[0].status).toBe('improved'); + }); + + it('with noiseThreshold=0, tiny negative change is regressed', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 99.999]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(0)); + expect(rows[0].deltaPct).toBeLessThan(0); + expect(rows[0].status).toBe('regressed'); + }); + + it('with noiseThreshold=0, delta=0 is unchanged (strict <)', () => { + const mapA = new Map([['foo', 42]]); + const mapB = new Map([['foo', 42]]); + const rows = computeComparison(mapA, mapB, true, pctOnly(0)); + expect(rows[0].delta).toBe(0); + expect(rows[0].deltaPct).toBe(0); + expect(rows[0].status).toBe('unchanged'); + }); + + it('delta=0 is noise when pct knob is enabled with threshold > 0', () => { + const mapA = new Map([['foo', 50]]); + const mapB = new Map([['foo', 50]]); + + const rowsLarge = computeComparison(mapA, mapB, true, pctOnly(10)); + expect(rowsLarge[0].status).toBe('noise'); + + const rowsZero = computeComparison(mapA, mapB, false, pctOnly(0)); + expect(rowsZero[0].status).toBe('unchanged'); + + const rowsTiny = computeComparison(mapA, mapB, true, pctOnly(0.001)); + expect(rowsTiny[0].status).toBe('noise'); + }); + + it('with noiseThreshold=0 and bigger_is_better=false, tiny changes are classified correctly', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 99.999]]); + const rows = computeComparison(mapA, mapB, false, pctOnly(0)); + expect(rows[0].status).toBe('improved'); + + const mapA2 = new Map([['foo', 100]]); + const mapB2 = new Map([['foo', 100.001]]); + const rows2 = computeComparison(mapA2, mapB2, false, pctOnly(0)); + expect(rows2[0].status).toBe('regressed'); + }); +}); + +// --------------------------------------------------------------------------- +// computeComparison — multi-knob noise classification +// --------------------------------------------------------------------------- + +describe('computeComparison — multi-knob noise', () => { + it('p-value knob marks as noise when p-value exceeds alpha', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: true, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; + const mapA = new Map([['foo', 10]]); + const mapB = new Map([['foo', 10.01]]); + // Raw samples with high variance → p-value will be large + const rawA = new Map([['foo', [8, 9, 10, 11, 12]]]); + const rawB = new Map([['foo', [8.01, 9.01, 10.01, 11.01, 12.01]]]); + const rows = computeComparison(mapA, mapB, true, config, rawA, rawB); + expect(rows[0].status).toBe('noise'); + expect(rows[0].noiseReasons).toHaveLength(1); + expect(rows[0].noiseReasons[0].knob).toBe('pval'); + }); + + it('p-value knob passes when p-value is below alpha', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: true, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; + const mapA = new Map([['foo', 3]]); + const mapB = new Map([['foo', 8]]); + // Raw samples clearly different → p-value will be small + const rawA = new Map([['foo', [1, 2, 3, 4, 5]]]); + const rawB = new Map([['foo', [6, 7, 8, 9, 10]]]); + const rows = computeComparison(mapA, mapB, true, config, rawA, rawB); + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('p-value knob is skipped when fewer than 2 samples per side', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: true, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 200]]); + const rawA = new Map([['foo', [100]]]); + const rawB = new Map([['foo', [200]]]); + const rows = computeComparison(mapA, mapB, true, config, rawA, rawB); + // p-value skipped, no other knobs → not noise + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('p-value knob is skipped when raw samples not provided', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: true, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 200]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('absolute floor knob marks noise when values below floor', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 10 }, + }; + const mapA = new Map([['foo', 5]]); + const mapB = new Map([['foo', 8]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('noise'); + expect(rows[0].noiseReasons).toHaveLength(1); + expect(rows[0].noiseReasons[0].knob).toBe('floor'); + }); + + it('absolute floor knob passes when any value above floor', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 10 }, + }; + const mapA = new Map([['foo', 5]]); + const mapB = new Map([['foo', 500]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('floor knob works correctly with negative values', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 1 }, + }; + const mapA = new Map([['foo', -0.5]]); + const mapB = new Map([['foo', -0.3]]); + const rows = computeComparison(mapA, mapB, true, config); + // max(|-0.5|, |-0.3|) = 0.5 < 1 → noise + expect(rows[0].status).toBe('noise'); + expect(rows[0].noiseReasons[0].knob).toBe('floor'); + }); + + it('floor knob with value=0 never fires (max abs cannot be < 0)', () => { + const config: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 0 }, + }; + const mapA = new Map([['foo', 0.001]]); + const mapB = new Map([['foo', 0.002]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('multiple knobs can fire: noise if ANY fires', () => { + const config: NoiseConfig = { + pct: { enabled: true, value: 50 }, // 50% threshold → will fire for small delta + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 1000 }, // floor at 1000 → will fire for small values + }; + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 110]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('noise'); + expect(rows[0].noiseReasons).toHaveLength(2); + const knobs = rows[0].noiseReasons.map(r => r.knob); + expect(knobs).toContain('pct'); + expect(knobs).toContain('floor'); + }); + + it('all three knobs fire simultaneously', () => { + const config: NoiseConfig = { + pct: { enabled: true, value: 50 }, + pval: { enabled: true, value: 0.01 }, // low alpha, but samples nearly identical → high p-value + floor: { enabled: true, value: 1000 }, + }; + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 110]]); + // Nearly identical samples → p-value will be high (above 0.01) + const rawA = new Map([['foo', [99, 100, 101, 100, 100]]]); + const rawB = new Map([['foo', [109, 110, 111, 110, 110]]]); + const rows = computeComparison(mapA, mapB, true, config, rawA, rawB); + expect(rows[0].status).toBe('noise'); + // pct fires (10% < 50%), floor fires (max(100,110) < 1000) + // pval: means are 100 vs 110 with low variance → p-value should be small + // So pval may or may not fire. Let's just verify at least pct and floor. + expect(rows[0].noiseReasons.length).toBeGreaterThanOrEqual(2); + const knobs = rows[0].noiseReasons.map(r => r.knob); + expect(knobs).toContain('pct'); + expect(knobs).toContain('floor'); + }); + + it('all knobs disabled: small delta is improved/regressed, not noise', () => { + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 100.5]]); + const rows = computeComparison(mapA, mapB, true, allDisabled()); + expect(rows[0].status).toBe('improved'); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('all knobs disabled: delta=0 is unchanged', () => { + const mapA = new Map([['foo', 42]]); + const mapB = new Map([['foo', 42]]); + const rows = computeComparison(mapA, mapB, true, allDisabled()); + expect(rows[0].status).toBe('unchanged'); + expect(rows[0].delta).toBe(0); + expect(rows[0].noiseReasons).toEqual([]); + }); + + it('noiseReasons message contains threshold values', () => { + const config: NoiseConfig = { + pct: { enabled: true, value: 5 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: true, value: 200 }, + }; + const mapA = new Map([['foo', 100]]); + const mapB = new Map([['foo', 103]]); + const rows = computeComparison(mapA, mapB, true, config); + expect(rows[0].status).toBe('noise'); + + const pctReason = rows[0].noiseReasons.find(r => r.knob === 'pct'); + expect(pctReason).toBeDefined(); + expect(pctReason!.message).toContain('5%'); + expect(pctReason!.message).toContain('threshold'); + + const floorReason = rows[0].noiseReasons.find(r => r.knob === 'floor'); + expect(floorReason).toBeDefined(); + expect(floorReason!.message).toContain('200'); + expect(floorReason!.message).toContain('floor'); + }); +}); + +// --------------------------------------------------------------------------- +// computeComparison — multi-test mixed status +// --------------------------------------------------------------------------- + +describe('computeComparison — multi-test mixed status', () => { + it('produces all status types in a single call and returns correct fields per row', () => { + const mapA = new Map<string, number>([ + ['test-improved', 100], + ['test-regressed', 100], + ['test-noise', 100], + ['test-a-only', 200], + ]); + + const mapB = new Map<string, number>([ + ['test-improved', 120], + ['test-regressed', 70], + ['test-noise', 103], + ['test-b-only', 300], + ]); + + const rows = computeComparison(mapA, mapB, true, pctOnly(5)); + expect(rows).toHaveLength(5); + + const byTest = new Map(rows.map(r => [r.test, r])); + + const improved = byTest.get('test-improved')!; + expect(improved.status).toBe('improved'); + expect(improved.valueA).toBe(100); + expect(improved.valueB).toBe(120); + expect(improved.delta).toBe(20); + expect(improved.deltaPct).toBeCloseTo(20); + expect(improved.ratio).toBeCloseTo(1.2); + expect(improved.sidePresent).toBe('both'); + expect(improved.noiseReasons).toEqual([]); + + const regressed = byTest.get('test-regressed')!; + expect(regressed.status).toBe('regressed'); + expect(regressed.noiseReasons).toEqual([]); + + const noise = byTest.get('test-noise')!; + expect(noise.status).toBe('noise'); + expect(noise.noiseReasons).toHaveLength(1); + expect(noise.noiseReasons[0].knob).toBe('pct'); + + const aOnly = byTest.get('test-a-only')!; + expect(aOnly.status).toBe('missing'); + expect(aOnly.sidePresent).toBe('a_only'); + expect(aOnly.noiseReasons).toEqual([]); + + const bOnly = byTest.get('test-b-only')!; + expect(bOnly.status).toBe('missing'); + expect(bOnly.sidePresent).toBe('b_only'); + expect(bOnly.noiseReasons).toEqual([]); + }); +}); + +// --------------------------------------------------------------------------- +// computeGeomean (unchanged behavior) +// --------------------------------------------------------------------------- + +describe('computeGeomean', () => { + function makeRow(overrides: Partial<ComparisonRow>): ComparisonRow { + return { + test: 'test', + valueA: 100, + valueB: 120, + delta: 20, + deltaPct: 20, + ratio: 1.2, + status: 'improved', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; + } + + it('returns null for empty rows', () => { + expect(computeGeomean([])).toBeNull(); + }); + + it('returns null when all rows are missing', () => { + const rows = [ + makeRow({ sidePresent: 'a_only', ratio: null, valueB: null, status: 'missing' }), + makeRow({ sidePresent: 'b_only', ratio: null, valueA: null, status: 'missing' }), + ]; + expect(computeGeomean(rows)).toBeNull(); + }); + + it('returns null when all rows are na', () => { + const rows = [makeRow({ status: 'na', ratio: 1.5 })]; + expect(computeGeomean(rows)).toBeNull(); + }); + + it('computes correct ratio geomean for known values', () => { + const rows = [ + makeRow({ valueA: 50, valueB: 100, ratio: 2 }), + makeRow({ valueA: 10, valueB: 80, ratio: 8 }), + ]; + const result = computeGeomean(rows)!; + expect(result).not.toBeNull(); + expect(result.ratioGeomean).toBeCloseTo(4); + }); + + it('computes geomeanA and geomeanB from absolute values', () => { + const rows = [ + makeRow({ valueA: 100, valueB: 200, ratio: 2 }), + makeRow({ valueA: 400, valueB: 800, ratio: 2 }), + ]; + const result = computeGeomean(rows)!; + expect(result.geomeanA).toBeCloseTo(200); + expect(result.geomeanB).toBeCloseTo(400); + expect(result.delta).toBeCloseTo(200); + expect(result.deltaPct).toBeCloseTo(100); + }); + + it('ignores rows with null ratio', () => { + const rows = [ + makeRow({ valueA: 50, valueB: 100, ratio: 2 }), + makeRow({ ratio: null, status: 'improved' }), + makeRow({ valueA: 10, valueB: 80, ratio: 8 }), + ]; + const result = computeGeomean(rows)!; + expect(result.ratioGeomean).toBeCloseTo(4); + }); + + it('ignores a_only and b_only rows', () => { + const rows = [ + makeRow({ valueA: 50, valueB: 100, ratio: 2 }), + makeRow({ sidePresent: 'a_only', ratio: null, valueB: null, status: 'missing' }), + makeRow({ valueA: 10, valueB: 80, ratio: 8 }), + ]; + const result = computeGeomean(rows)!; + expect(result.ratioGeomean).toBeCloseTo(4); + }); + + it('single row: geomean equals the values', () => { + const rows = [makeRow({ valueA: 100, valueB: 150, ratio: 1.5 })]; + const result = computeGeomean(rows)!; + expect(result.geomeanA).toBeCloseTo(100); + expect(result.geomeanB).toBeCloseTo(150); + expect(result.ratioGeomean).toBeCloseTo(1.5); + }); + + it('all ratios = 1.0: ratio geomean = 1.0', () => { + const rows = [ + makeRow({ valueA: 100, valueB: 100, ratio: 1.0, status: 'noise' }), + makeRow({ valueA: 200, valueB: 200, ratio: 1.0, status: 'noise' }), + makeRow({ valueA: 50, valueB: 50, ratio: 1.0, status: 'noise' }), + ]; + const result = computeGeomean(rows)!; + expect(result.ratioGeomean).toBeCloseTo(1.0); + expect(result.delta).toBeCloseTo(0); + }); + + it('returns null when valueA is zero (status=na is filtered out)', () => { + const rows = [makeRow({ valueA: 0, valueB: 10, ratio: null, status: 'na' })]; + expect(computeGeomean(rows)).toBeNull(); + }); + + it('produces valid geomean for negative values', () => { + const rows = [ + makeRow({ valueA: -10, valueB: -20, ratio: 2, status: 'improved' }), + makeRow({ valueA: -40, valueB: -80, ratio: 2, status: 'improved' }), + ]; + const result = computeGeomean(rows)!; + expect(result).not.toBeNull(); + expect(result.geomeanA).toBeCloseTo(20); + expect(result.geomeanB).toBeCloseTo(40); + expect(result.ratioGeomean).toBeCloseTo(2); + expect(Number.isNaN(result.geomeanA)).toBe(false); + expect(Number.isNaN(result.geomeanB)).toBe(false); + }); + + it('excludes rows with zero values', () => { + const rows = [ + makeRow({ valueA: 100, valueB: 200, ratio: 2, status: 'improved' }), + makeRow({ valueA: 50, valueB: 0, ratio: 0, status: 'improved' }), + ]; + const result = computeGeomean(rows)!; + expect(result).not.toBeNull(); + expect(result.geomeanA).toBeCloseTo(100); + expect(result.geomeanB).toBeCloseTo(200); + expect(result.ratioGeomean).toBeCloseTo(2); + expect(Number.isFinite(result.geomeanA)).toBe(true); + expect(Number.isFinite(result.geomeanB)).toBe(true); + }); + + it('returns null when all rows have zero values', () => { + const rows = [ + makeRow({ valueA: 0, valueB: 0, ratio: null, status: 'na' }), + makeRow({ valueA: 0, valueB: 5, ratio: null, status: 'na' }), + ]; + expect(computeGeomean(rows)).toBeNull(); + }); + + it('handles mixed positive and negative values correctly', () => { + const rows = [ + makeRow({ valueA: 100, valueB: 200, ratio: 2, status: 'improved' }), + makeRow({ valueA: -50, valueB: -25, ratio: 0.5, status: 'regressed' }), + ]; + const result = computeGeomean(rows)!; + expect(result).not.toBeNull(); + expect(Number.isNaN(result.geomeanA)).toBe(false); + expect(Number.isNaN(result.geomeanB)).toBe(false); + expect(Number.isFinite(result.geomeanA)).toBe(true); + expect(Number.isFinite(result.geomeanB)).toBe(true); + expect(result.geomeanA).toBeCloseTo(Math.sqrt(100 * 50)); + expect(result.geomeanB).toBeCloseTo(Math.sqrt(200 * 25)); + expect(result.ratioGeomean).toBeCloseTo(1); + }); +}); + +// --------------------------------------------------------------------------- +// countRunsPerTest +// --------------------------------------------------------------------------- + +describe('countRunsPerTest', () => { + it('counts runs per test across multiple maps', () => { + const run1 = new Map([['foo', 1.0], ['bar', 2.0]]); + const run2 = new Map([['foo', 1.5], ['baz', 3.0]]); + const run3 = new Map([['foo', 1.2], ['bar', 2.1], ['baz', 3.1]]); + + const counts = countRunsPerTest([run1, run2, run3]); + expect(counts.get('foo')).toBe(3); + expect(counts.get('bar')).toBe(2); + expect(counts.get('baz')).toBe(2); + }); + + it('returns empty map for empty input', () => { + expect(countRunsPerTest([]).size).toBe(0); + }); + + it('handles single run', () => { + const run1 = new Map([['foo', 1.0], ['bar', 2.0]]); + const counts = countRunsPerTest([run1]); + expect(counts.get('foo')).toBe(1); + expect(counts.get('bar')).toBe(1); + }); +}); + +// --------------------------------------------------------------------------- +// computeComparison — sample/run counts +// --------------------------------------------------------------------------- + +describe('computeComparison — sample and run counts', () => { + const noNoise: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: false, value: 0 }, + }; + + it('populates samplesA/samplesB from rawA/rawB lengths', () => { + const mapA = new Map([['foo', 10]]); + const mapB = new Map([['foo', 12]]); + const rawA = new Map([['foo', [10, 10, 10]]]); + const rawB = new Map([['foo', [12, 12]]]); + const rcA = new Map([['foo', 2]]); + const rcB = new Map([['foo', 1]]); + + const rows = computeComparison(mapA, mapB, false, noNoise, rawA, rawB, rcA, rcB); + expect(rows[0].samplesA).toBe(3); + expect(rows[0].samplesB).toBe(2); + expect(rows[0].runsA).toBe(2); + expect(rows[0].runsB).toBe(1); + }); + + it('returns undefined counts when optional params omitted', () => { + const mapA = new Map([['foo', 10]]); + const mapB = new Map([['foo', 12]]); + + const rows = computeComparison(mapA, mapB, false, noNoise); + expect(rows[0].samplesA).toBeUndefined(); + expect(rows[0].samplesB).toBeUndefined(); + expect(rows[0].runsA).toBeUndefined(); + expect(rows[0].runsB).toBeUndefined(); + }); + + it('a_only row has side A counts, side B undefined', () => { + const mapA = new Map([['foo', 10]]); + const mapB = new Map<string, number>(); + const rawA = new Map([['foo', [10, 10]]]); + const rawB = new Map<string, number[]>(); + const rcA = new Map([['foo', 1]]); + const rcB = new Map<string, number>(); + + const rows = computeComparison(mapA, mapB, false, noNoise, rawA, rawB, rcA, rcB); + expect(rows[0].samplesA).toBe(2); + expect(rows[0].runsA).toBe(1); + expect(rows[0].samplesB).toBeUndefined(); + expect(rows[0].runsB).toBeUndefined(); + }); + + it('asymmetric run counts between sides', () => { + const mapA = new Map([['foo', 10]]); + const mapB = new Map([['foo', 12]]); + const rawA = new Map([['foo', [10, 10, 10, 10, 10, 10]]]); + const rawB = new Map([['foo', [12, 12]]]); + const rcA = new Map([['foo', 3]]); + const rcB = new Map([['foo', 1]]); + + const rows = computeComparison(mapA, mapB, false, noNoise, rawA, rawB, rcA, rcB); + expect(rows[0].samplesA).toBe(6); + expect(rows[0].runsA).toBe(3); + expect(rows[0].samplesB).toBe(2); + expect(rows[0].runsB).toBe(1); + }); + + it('populates counts on zero-baseline (na) rows', () => { + const mapA = new Map([['foo', 0]]); + const mapB = new Map([['foo', 5]]); + const rawA = new Map([['foo', [0, 0]]]); + const rawB = new Map([['foo', [5]]]); + const rcA = new Map([['foo', 2]]); + const rcB = new Map([['foo', 1]]); + + const rows = computeComparison(mapA, mapB, false, noNoise, rawA, rawB, rcA, rcB); + expect(rows[0].status).toBe('na'); + expect(rows[0].samplesA).toBe(2); + expect(rows[0].runsA).toBe(2); + expect(rows[0].samplesB).toBe(1); + expect(rows[0].runsB).toBe(1); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/components/comparison-summary.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/components/comparison-summary.test.ts new file mode 100644 index 000000000..2732f7f32 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/components/comparison-summary.test.ts @@ -0,0 +1,389 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { computeSummaryCounts, renderSummaryBar } from '../../components/comparison-summary'; +import type { ComparisonRow } from '../../types'; + +function makeRow(overrides: Partial<ComparisonRow>): ComparisonRow { + return { + test: 'test', + valueA: 100, + valueB: 120, + delta: 20, + deltaPct: 20, + ratio: 1.2, + status: 'improved', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// computeSummaryCounts +// --------------------------------------------------------------------------- + +describe('computeSummaryCounts', () => { + it('counts each status category correctly', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'improved' }), + makeRow({ test: 'c', status: 'regressed' }), + makeRow({ test: 'd', status: 'noise' }), + makeRow({ test: 'e', status: 'unchanged' }), + makeRow({ test: 'f', status: 'missing', sidePresent: 'a_only' }), + makeRow({ test: 'g', status: 'missing', sidePresent: 'b_only' }), + makeRow({ test: 'h', status: 'na' }), + ]; + const counts = computeSummaryCounts(rows, '', null); + expect(counts.improved).toBe(2); + expect(counts.regressed).toBe(1); + expect(counts.noise).toBe(1); + expect(counts.unchanged).toBe(1); + expect(counts.onlyInA).toBe(1); + expect(counts.onlyInB).toBe(1); + expect(counts.na).toBe(1); + expect(counts.total).toBe(8); + }); + + it('maps missing + a_only to onlyInA and missing + b_only to onlyInB', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'x', status: 'missing', sidePresent: 'a_only' }), + makeRow({ test: 'y', status: 'missing', sidePresent: 'a_only' }), + makeRow({ test: 'z', status: 'missing', sidePresent: 'b_only' }), + ]; + const counts = computeSummaryCounts(rows, '', null); + expect(counts.onlyInA).toBe(2); + expect(counts.onlyInB).toBe(1); + expect(counts.total).toBe(3); + }); + + it('counts unchanged status correctly', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', status: 'unchanged', delta: 0 }), + makeRow({ test: 'b', status: 'unchanged', delta: 0 }), + ]; + const counts = computeSummaryCounts(rows, '', null); + expect(counts.unchanged).toBe(2); + expect(counts.total).toBe(2); + }); + + it('applies text filter (case-insensitive substring)', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'foo-bar', status: 'improved' }), + makeRow({ test: 'FOO-baz', status: 'regressed' }), + makeRow({ test: 'other', status: 'improved' }), + ]; + const counts = computeSummaryCounts(rows, 'foo', null); + expect(counts.improved).toBe(1); + expect(counts.regressed).toBe(1); + expect(counts.total).toBe(2); + }); + + it('applies zoom filter', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'regressed' }), + makeRow({ test: 'c', status: 'noise' }), + ]; + const counts = computeSummaryCounts(rows, '', new Set(['a', 'c'])); + expect(counts.improved).toBe(1); + expect(counts.noise).toBe(1); + expect(counts.regressed).toBe(0); + expect(counts.total).toBe(2); + }); + + it('applies text + zoom filter as intersection', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'foo-a', status: 'improved' }), + makeRow({ test: 'foo-b', status: 'regressed' }), + makeRow({ test: 'bar-c', status: 'noise' }), + ]; + const counts = computeSummaryCounts(rows, 'foo', new Set(['foo-a', 'bar-c'])); + expect(counts.improved).toBe(1); + expect(counts.total).toBe(1); + }); + + it('returns all zeros for empty rows', () => { + const counts = computeSummaryCounts([], '', null); + expect(counts.total).toBe(0); + expect(counts.improved).toBe(0); + expect(counts.regressed).toBe(0); + expect(counts.noise).toBe(0); + expect(counts.unchanged).toBe(0); + expect(counts.onlyInA).toBe(0); + expect(counts.onlyInB).toBe(0); + expect(counts.na).toBe(0); + }); + + it('returns all zeros when text filter matches nothing', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'foo', status: 'improved' }), + ]; + const counts = computeSummaryCounts(rows, 'zzz', null); + expect(counts.total).toBe(0); + }); + + it('returns all zeros when zoom filter has no intersection', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'foo', status: 'improved' }), + ]; + const counts = computeSummaryCounts(rows, '', new Set(['bar'])); + expect(counts.total).toBe(0); + }); + + it('handles all rows with same status', () => { + const rows: ComparisonRow[] = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'improved' }), + makeRow({ test: 'c', status: 'improved' }), + ]; + const counts = computeSummaryCounts(rows, '', null); + expect(counts.improved).toBe(3); + expect(counts.total).toBe(3); + expect(counts.regressed).toBe(0); + }); +}); + +// --------------------------------------------------------------------------- +// renderSummaryBar +// --------------------------------------------------------------------------- + +describe('renderSummaryBar', () => { + let container: HTMLElement; + + beforeEach(() => { + container = document.createElement('div'); + }); + + it('renders 7 summary items', () => { + const counts = { + improved: 10, regressed: 5, noise: 20, unchanged: 2, + onlyInA: 1, onlyInB: 3, na: 1, total: 42, + }; + renderSummaryBar(container, counts); + const items = container.querySelectorAll('.summary-item'); + expect(items).toHaveLength(7); + }); + + it('renders correct labels', () => { + const counts = { + improved: 1, regressed: 1, noise: 1, unchanged: 1, + onlyInA: 1, onlyInB: 1, na: 1, total: 7, + }; + renderSummaryBar(container, counts); + const labels = Array.from(container.querySelectorAll('.summary-label')).map( + el => el.textContent, + ); + expect(labels).toEqual([ + 'Improved', 'Regressed', 'Noise', 'Unchanged', + 'Only in A', 'Only in B', 'N/A', + ]); + }); + + it('renders correct dot colors', () => { + const counts = { + improved: 1, regressed: 1, noise: 1, unchanged: 1, + onlyInA: 1, onlyInB: 1, na: 1, total: 7, + }; + renderSummaryBar(container, counts); + const dots = Array.from(container.querySelectorAll('.summary-dot')) as HTMLElement[]; + expect(dots[0].style.backgroundColor).toBe('rgb(44, 160, 44)'); // #2ca02c + expect(dots[1].style.backgroundColor).toBe('rgb(214, 39, 40)'); // #d62728 + expect(dots[2].style.backgroundColor).toBe('rgb(153, 153, 153)'); // #999999 + expect(dots[3].style.backgroundColor).toBe('rgb(153, 153, 153)'); // #999999 + expect(dots[4].style.backgroundColor).toBe('rgb(136, 136, 136)'); // #888888 + expect(dots[5].style.backgroundColor).toBe('rgb(136, 136, 136)'); // #888888 + expect(dots[6].style.backgroundColor).toBe('rgb(136, 136, 136)'); // #888888 + }); + + it('renders correct count and percentage text', () => { + const counts = { + improved: 10, regressed: 5, noise: 20, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 35, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 35 + expect(countTexts[0]).toBe('10 (28.6%)'); // improved + expect(countTexts[1]).toBe('5 (14.3%)'); // regressed + expect(countTexts[2]).toBe('20 (57.1%)'); // noise + expect(countTexts[3]).toBe('0 (0%)'); // unchanged + expect(countTexts[4]).toBe('0'); // onlyInA + expect(countTexts[5]).toBe('0'); // onlyInB + expect(countTexts[6]).toBe('0'); // na + }); + + it('formats percentages with one decimal place', () => { + const counts = { + improved: 2, regressed: 1, noise: 0, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 3, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 3 + expect(countTexts[0]).toBe('2 (66.7%)'); + expect(countTexts[1]).toBe('1 (33.3%)'); + }); + + it('applies summary-item-zero class to zero-count categories', () => { + const counts = { + improved: 5, regressed: 0, noise: 3, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 8, + }; + renderSummaryBar(container, counts); + const items = container.querySelectorAll('.summary-item'); + expect(items[0].classList.contains('summary-item-zero')).toBe(false); // improved=5 + expect(items[1].classList.contains('summary-item-zero')).toBe(true); // regressed=0 + expect(items[2].classList.contains('summary-item-zero')).toBe(false); // noise=3 + expect(items[3].classList.contains('summary-item-zero')).toBe(true); // unchanged=0 + }); + + it('renders nothing when total is 0', () => { + const counts = { + improved: 0, regressed: 0, noise: 0, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 0, + }; + renderSummaryBar(container, counts); + expect(container.children).toHaveLength(0); + }); + + it('clears previous content on re-render', () => { + const counts1 = { + improved: 5, regressed: 0, noise: 0, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 5, + }; + const counts2 = { + improved: 0, regressed: 3, noise: 0, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 3, + }; + renderSummaryBar(container, counts1); + expect(container.querySelectorAll('.comparison-summary')).toHaveLength(1); + + renderSummaryBar(container, counts2); + expect(container.querySelectorAll('.comparison-summary')).toHaveLength(1); + + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + expect(countTexts[0]).toBe('0 (0%)'); // improved now 0 + expect(countTexts[1]).toBe('3 (100%)'); // regressed now 3 + expect(countTexts[4]).toBe('0'); // onlyInA + expect(countTexts[5]).toBe('0'); // onlyInB + expect(countTexts[6]).toBe('0'); // na + }); + + it('uses comparable denominator, not total', () => { + const counts = { + improved: 3, regressed: 1, noise: 2, unchanged: 4, + onlyInA: 5, onlyInB: 5, na: 10, total: 30, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 10, not 30 + expect(countTexts[0]).toBe('3 (30%)'); // improved + expect(countTexts[1]).toBe('1 (10%)'); // regressed + expect(countTexts[2]).toBe('2 (20%)'); // noise + expect(countTexts[3]).toBe('4 (40%)'); // unchanged + expect(countTexts[4]).toBe('5'); // onlyInA + expect(countTexts[5]).toBe('5'); // onlyInB + expect(countTexts[6]).toBe('10'); // na + }); + + it('shows no percentage when comparableTotal is 0', () => { + const counts = { + improved: 0, regressed: 0, noise: 0, unchanged: 0, + onlyInA: 3, onlyInB: 2, na: 5, total: 10, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 0 — no division by zero + expect(countTexts[0]).toBe('0'); // improved + expect(countTexts[1]).toBe('0'); // regressed + expect(countTexts[2]).toBe('0'); // noise + expect(countTexts[3]).toBe('0'); // unchanged + expect(countTexts[4]).toBe('3'); // onlyInA + expect(countTexts[5]).toBe('2'); // onlyInB + expect(countTexts[6]).toBe('5'); // na + }); + + it('drops .0 for whole-number percentages', () => { + const counts = { + improved: 1, regressed: 1, noise: 1, unchanged: 1, + onlyInA: 0, onlyInB: 0, na: 0, total: 4, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 4; 25.0% → displayed as "25%" + expect(countTexts[0]).toBe('1 (25%)'); + expect(countTexts[1]).toBe('1 (25%)'); + expect(countTexts[2]).toBe('1 (25%)'); + expect(countTexts[3]).toBe('1 (25%)'); + }); + + it('shows 1 decimal place for non-whole percentages', () => { + const counts = { + improved: 1, regressed: 1, noise: 1, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 3, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + // comparableTotal = 3 + expect(countTexts[0]).toBe('1 (33.3%)'); + expect(countTexts[1]).toBe('1 (33.3%)'); + expect(countTexts[2]).toBe('1 (33.3%)'); + expect(countTexts[3]).toBe('0 (0%)'); + }); + + it('shows tooltip on comparable percentage spans', () => { + const counts = { + improved: 3, regressed: 1, noise: 0, unchanged: 0, + onlyInA: 2, onlyInB: 0, na: 1, total: 7, + }; + renderSummaryBar(container, counts); + const spans = Array.from(container.querySelectorAll('.summary-count')) as HTMLElement[]; + const expectedTooltip = 'Percentage of comparable tests (excludes Only in A, Only in B, N/A)'; + // comparable categories get tooltip (even zero-count ones when comparable > 0) + expect(spans[0].getAttribute('title')).toBe(expectedTooltip); // improved + expect(spans[1].getAttribute('title')).toBe(expectedTooltip); // regressed + expect(spans[2].getAttribute('title')).toBe(expectedTooltip); // noise (count=0) + expect(spans[3].getAttribute('title')).toBe(expectedTooltip); // unchanged (count=0) + // non-comparable categories have no tooltip + expect(spans[4].getAttribute('title')).toBeNull(); // onlyInA + expect(spans[5].getAttribute('title')).toBeNull(); // onlyInB + expect(spans[6].getAttribute('title')).toBeNull(); // na + }); + + it('zero-count comparable category still shows percentage and tooltip', () => { + const counts = { + improved: 0, regressed: 0, noise: 7, unchanged: 0, + onlyInA: 3, onlyInB: 0, na: 0, total: 10, + }; + renderSummaryBar(container, counts); + const countTexts = Array.from(container.querySelectorAll('.summary-count')).map( + el => el.textContent, + ); + const spans = Array.from(container.querySelectorAll('.summary-count')) as HTMLElement[]; + // comparable > 0 but specific category has count=0 — still shows percentage and tooltip + expect(countTexts[0]).toBe('0 (0%)'); // improved + expect(countTexts[1]).toBe('0 (0%)'); // regressed + expect(countTexts[2]).toBe('7 (100%)'); // noise + expect(countTexts[3]).toBe('0 (0%)'); // unchanged + expect(countTexts[4]).toBe('3'); // onlyInA + // tooltip present on all comparable categories, including zero-count + expect(spans[0].getAttribute('title')).toBeTruthy(); + expect(spans[3].getAttribute('title')).toBeTruthy(); + expect(spans[4].getAttribute('title')).toBeNull(); + }); +}); \ No newline at end of file diff --git a/lnt/server/ui/v5/frontend/src/__tests__/components/profile-stats.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/components/profile-stats.test.ts new file mode 100644 index 000000000..957c47f44 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/components/profile-stats.test.ts @@ -0,0 +1,145 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderProfileStats } from '../../components/profile-stats'; +import { realisticMetadataA, realisticMetadataB } from '../fixtures/profile-fixtures'; + +let container: HTMLElement; + +beforeEach(() => { + container = document.createElement('div'); +}); + +describe('renderProfileStats — single profile mode', () => { + it('renders counter names and values', () => { + renderProfileStats(container, { cycles: 5000000, 'branch-misses': 42000 }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + // Sorted alphabetically + expect(rows[0].querySelector('td')?.textContent).toBe('branch-misses'); + expect(rows[1].querySelector('td')?.textContent).toBe('cycles'); + }); + + it('renders "No counters available." for empty counters', () => { + renderProfileStats(container, {}); + + expect(container.querySelector('.no-results')?.textContent).toBe('No counters available.'); + expect(container.querySelector('table')).toBeNull(); + }); + + it('has Counter and Value headers', () => { + renderProfileStats(container, { cycles: 100 }); + + const ths = container.querySelectorAll('thead th'); + expect(ths).toHaveLength(2); + expect(ths[0].textContent).toBe('Counter'); + expect(ths[1].textContent).toBe('Value'); + }); + + it('destroy() is callable without error', () => { + const { destroy } = renderProfileStats(container, { cycles: 100 }); + expect(() => destroy()).not.toThrow(); + }); +}); + +describe('renderProfileStats — comparison mode', () => { + it('renders value A, value B, and delta %', () => { + renderProfileStats(container, { cycles: 1000 }, { cycles: 1200 }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(1); + + const cells = rows[0].querySelectorAll('td'); + expect(cells[0].textContent).toBe('cycles'); + expect(cells[1].textContent).toBe('1,000'); // value A + expect(cells[2].textContent).toBe('1,200'); // value B + expect(cells[3].textContent).toContain('+20.0%'); // delta + }); + + it('has 4-column header', () => { + renderProfileStats(container, { cycles: 100 }, { cycles: 200 }); + + const ths = container.querySelectorAll('thead th'); + expect(ths).toHaveLength(4); + expect(ths[0].textContent).toBe('Counter'); + expect(ths[1].textContent).toBe('A'); + expect(ths[2].textContent).toBe('B'); + expect(ths[3].textContent).toBe('Delta'); + }); + + it('colors improvement (lower is better) green', () => { + renderProfileStats(container, { cycles: 1000 }, { cycles: 800 }); + + const delta = container.querySelector('.profile-stats-improved'); + expect(delta).toBeTruthy(); + expect(delta?.textContent).toContain('-20.0%'); + }); + + it('colors regression red', () => { + renderProfileStats(container, { cycles: 1000 }, { cycles: 1500 }); + + const delta = container.querySelector('.profile-stats-regressed'); + expect(delta).toBeTruthy(); + expect(delta?.textContent).toContain('+50.0%'); + }); + + it('handles A=0 gracefully (shows --)', () => { + renderProfileStats(container, { cycles: 0 }, { cycles: 100 }); + + const deltaCell = container.querySelector('.profile-stats-delta'); + expect(deltaCell?.textContent).toBe('--'); + }); + + it('handles mismatched counter names (shows union)', () => { + renderProfileStats(container, { cycles: 100 }, { 'branch-misses': 50 }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + const names = Array.from(rows).map(r => r.querySelector('td')?.textContent); + expect(names).toContain('branch-misses'); + expect(names).toContain('cycles'); + }); + + it('shows delta bar with capped width', () => { + renderProfileStats(container, { cycles: 100 }, { cycles: 350 }); + + const bar = container.querySelector('.profile-stats-bar') as HTMLElement; + expect(bar).toBeTruthy(); + // 250% delta capped to 100% width + expect(bar.style.width).toBe('100%'); + }); + + it('destroy() is callable without error', () => { + const { destroy } = renderProfileStats(container, { a: 1 }, { a: 2 }); + expect(() => destroy()).not.toThrow(); + }); +}); + +describe('renderProfileStats — realistic data', () => { + let container: HTMLElement; + beforeEach(() => { container = document.createElement('div'); }); + + it('renders realistic 4-counter A/B comparison with mixed results', () => { + renderProfileStats(container, realisticMetadataA.counters, realisticMetadataB.counters); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(4); + + // All 4 counter names present (sorted alphabetically) + const names = Array.from(rows).map(r => r.querySelector('td')?.textContent); + expect(names).toEqual(['branch-misses', 'cache-misses', 'cycles', 'instructions']); + + // branch-misses improved (18742 -> 15903 = -15.1%) + const bmRow = Array.from(rows).find(r => r.querySelector('td')?.textContent === 'branch-misses'); + expect(bmRow?.querySelector('.profile-stats-improved')).toBeTruthy(); + + // cycles regressed (4523891 -> 4891204 = +8.1%) + const cyclesRow = Array.from(rows).find(r => r.querySelector('td')?.textContent === 'cycles'); + expect(cyclesRow?.querySelector('.profile-stats-regressed')).toBeTruthy(); + + // instructions unchanged (same value → delta = 0%) + const instrRow = Array.from(rows).find(r => r.querySelector('td')?.textContent === 'instructions'); + const instrDelta = instrRow?.querySelector('.profile-stats-delta'); + expect(instrDelta?.textContent).toContain('+0.0%'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/components/profile-viewer.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/components/profile-viewer.test.ts new file mode 100644 index 000000000..a09ccf380 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/components/profile-viewer.test.ts @@ -0,0 +1,264 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { renderProfileViewer } from '../../components/profile-viewer'; +import { realisticFunctionDetail } from '../fixtures/profile-fixtures'; +import type { ProfileFunctionDetail } from '../../types'; + +let container: HTMLElement; + +const sampleDetail: ProfileFunctionDetail = { + name: 'main', + counters: { cycles: 80.0 }, + disassembly_format: 'raw', + instructions: [ + { address: 0x1000, counters: { cycles: 50.0, 'branch-misses': 5.0 }, text: 'push rbp' }, + { address: 0x1004, counters: { cycles: 30.0, 'branch-misses': 3.0 }, text: 'mov rsp, rbp' }, + { address: 0x1008, counters: { cycles: 20.0, 'branch-misses': 2.0 }, text: 'ret' }, + ], +}; + +beforeEach(() => { + container = document.createElement('div'); +}); + +describe('renderProfileViewer — basic rendering', () => { + it('renders a table with 3 columns', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const ths = container.querySelectorAll('thead th'); + expect(ths).toHaveLength(3); + expect(ths[0].textContent).toBe('cycles'); + expect(ths[1].textContent).toBe('Address'); + expect(ths[2].textContent).toBe('Instruction'); + }); + + it('renders one row per instruction', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + }); + + it('renders hex addresses', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const dataCells = container.querySelectorAll('tbody .profile-disasm-addr'); + expect(dataCells[0].textContent).toBe('0x1000'); + expect(dataCells[1].textContent).toBe('0x1004'); + expect(dataCells[2].textContent).toBe('0x1008'); + }); + + it('renders instruction text', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const textCells = container.querySelectorAll('tbody .profile-disasm-text'); + expect(textCells[0].textContent).toBe('push rbp'); + expect(textCells[1].textContent).toBe('mov rsp, rbp'); + expect(textCells[2].textContent).toBe('ret'); + }); +}); + +describe('renderProfileViewer — heat-map', () => { + it('applies background color to counter value cells', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat') as NodeListOf<HTMLElement>; + expect(heatCells).toHaveLength(3); + // All should have a background-color set + for (const cell of heatCells) { + expect(cell.style.backgroundColor).toBeTruthy(); + } + }); + + it('uses white for zero-value instructions', () => { + const detail: ProfileFunctionDetail = { + name: 'fn', + counters: {}, + disassembly_format: 'raw', + instructions: [ + { address: 0, counters: { cycles: 0 }, text: 'nop' }, + { address: 1, counters: { cycles: 100 }, text: 'add' }, + ], + }; + renderProfileViewer(container, detail, { counter: 'cycles', displayMode: 'absolute' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat') as NodeListOf<HTMLElement>; + // The zero-value cell should be white (255,255,255) + expect(heatCells[0].style.backgroundColor).toBe('rgb(255, 255, 255)'); + }); +}); + +describe('renderProfileViewer — display modes', () => { + it('relative mode shows percentages', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat'); + // Total = 50 + 30 + 20 = 100. First = 50/100 * 100 = 50.0% + expect(heatCells[0].textContent).toBe('50.0%'); + expect(heatCells[1].textContent).toBe('30.0%'); + expect(heatCells[2].textContent).toBe('20.0%'); + }); + + it('absolute mode shows raw values', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'absolute' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat'); + expect(heatCells[0].textContent).toBe('50.0'); + expect(heatCells[1].textContent).toBe('30.0'); + expect(heatCells[2].textContent).toBe('20.0'); + }); + + it('cumulative mode shows running sum', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'cumulative' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat'); + expect(heatCells[0].textContent).toBe('50.0'); + expect(heatCells[1].textContent).toBe('80.0'); // 50 + 30 + expect(heatCells[2].textContent).toBe('100.0'); // 50 + 30 + 20 + }); +}); + +describe('renderProfileViewer — row cap', () => { + it('caps at 500 rows for large functions', () => { + const instructions = Array.from({ length: 600 }, (_, i) => ({ + address: i, + counters: { cycles: 1.0 }, + text: `inst_${i}`, + })); + const detail: ProfileFunctionDetail = { + name: 'big_fn', + counters: {}, + disassembly_format: 'raw', + instructions, + }; + + renderProfileViewer(container, detail, { counter: 'cycles', displayMode: 'absolute' }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(500); + + const capMsg = container.querySelector('.profile-row-cap'); + expect(capMsg).toBeTruthy(); + expect(capMsg?.textContent).toContain('Showing 500 of 600'); + }); + + it('"Show all" button renders all rows', () => { + const instructions = Array.from({ length: 600 }, (_, i) => ({ + address: i, + counters: { cycles: 1.0 }, + text: `inst_${i}`, + })); + const detail: ProfileFunctionDetail = { + name: 'big_fn', + counters: {}, + disassembly_format: 'raw', + instructions, + }; + + renderProfileViewer(container, detail, { counter: 'cycles', displayMode: 'absolute' }); + + const btn = container.querySelector('.profile-row-cap button') as HTMLButtonElement; + expect(btn).toBeTruthy(); + btn.click(); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(600); + expect(container.querySelector('.profile-row-cap')).toBeNull(); + }); + + it('does not show cap for small functions', () => { + renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'absolute' }); + + expect(container.querySelector('.profile-row-cap')).toBeNull(); + }); +}); + +describe('renderProfileViewer — edge cases', () => { + it('renders empty table for no instructions', () => { + const detail: ProfileFunctionDetail = { + name: 'empty_fn', + counters: {}, + disassembly_format: 'raw', + instructions: [], + }; + + renderProfileViewer(container, detail, { counter: 'cycles', displayMode: 'relative' }); + + expect(container.querySelector('.no-results')?.textContent).toBe('No instructions.'); + expect(container.querySelector('table')).toBeNull(); + }); + + it('single instruction renders correctly', () => { + const detail: ProfileFunctionDetail = { + name: 'one', + counters: {}, + disassembly_format: 'raw', + instructions: [{ address: 42, counters: { cycles: 100 }, text: 'ret' }], + }; + + renderProfileViewer(container, detail, { counter: 'cycles', displayMode: 'relative' }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(1); + // 100/100 * 100 = 100.0% + expect(rows[0].querySelector('.profile-disasm-heat')?.textContent).toBe('100.0%'); + }); + + it('missing counter shows 0', () => { + renderProfileViewer(container, sampleDetail, { counter: 'nonexistent', displayMode: 'absolute' }); + + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat'); + expect(heatCells[0].textContent).toBe('0.0'); + }); + + it('destroy() is callable without error', () => { + const { destroy } = renderProfileViewer(container, sampleDetail, { counter: 'cycles', displayMode: 'relative' }); + expect(() => destroy()).not.toThrow(); + }); +}); + +describe('renderProfileViewer — realistic data', () => { + let container: HTMLElement; + beforeEach(() => { container = document.createElement('div'); }); + + it('renders realistic x86-64 function with 48 instructions', () => { + renderProfileViewer(container, realisticFunctionDetail, { counter: 'cycles', displayMode: 'relative' }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(48); + + // The call instruction at 0x40102d has the highest cycles (18.2 out of ~34.2 total). + // In relative mode, this is ~53.2% — the hottest instruction. + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat') as NodeListOf<HTMLElement>; + // Find the cell with the highest percentage value + let maxPctCell: HTMLElement | null = null; + let maxPct = 0; + for (const cell of heatCells) { + const pct = parseFloat(cell.textContent || '0'); + if (pct > maxPct) { maxPct = pct; maxPctCell = cell; } + } + expect(maxPctCell).toBeTruthy(); + expect(maxPct).toBeGreaterThan(40); // should be ~53% + expect(maxPctCell!.style.backgroundColor).not.toBe('rgb(255, 255, 255)'); + + // Address column has realistic hex addresses + const addrCells = container.querySelectorAll('tbody .profile-disasm-addr'); + expect(addrCells[0].textContent).toBe('0x401000'); + expect(addrCells[addrCells.length - 1].textContent).toBe('0x4010b4'); + + // Instruction text has real x86-64 mnemonics + const textCells = container.querySelectorAll('tbody .profile-disasm-text'); + expect(textCells[0].textContent).toBe('push rbp'); + expect(textCells[1].textContent).toBe('mov rbp, rsp'); + }); + + it('renders all 4 counters correctly in absolute mode', () => { + renderProfileViewer(container, realisticFunctionDetail, { counter: 'cache-misses', displayMode: 'absolute' }); + + // The hottest cache-miss instruction is the call at 0x40102d with 22.1 + const heatCells = container.querySelectorAll('tbody .profile-disasm-heat'); + const values = Array.from(heatCells).map(c => parseFloat(c.textContent || '0')); + const max = Math.max(...values); + expect(max).toBeCloseTo(22.1, 0); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/csvExport.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/csvExport.test.ts new file mode 100644 index 000000000..3cbb68c94 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/csvExport.test.ts @@ -0,0 +1,74 @@ +import { describe, it, expect } from 'vitest'; +import { buildCsv } from '../csvExport'; +import type { ComparisonRow } from '../types'; +import type { GeomeanResult } from '../comparison'; + +function row(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, + status: 'regressed', sidePresent: 'both', noiseReasons: [], + ...overrides, + }; +} + +describe('buildCsv', () => { + it('produces correct header row', () => { + const csv = buildCsv([], null); + expect(csv).toBe('Test,Value A,Value B,Delta,Delta %,Ratio,Status'); + }); + + it('includes geomean row when provided', () => { + const geomean: GeomeanResult = { + geomeanA: 100, geomeanB: 105, delta: 5, deltaPct: 5, ratioGeomean: 1.05, + }; + const lines = buildCsv([], geomean).split('\n'); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('Geomean'); + expect(lines[1]).toContain('1.0500'); + }); + + it('omits geomean row when null', () => { + const lines = buildCsv([row({ test: 'foo' })], null).split('\n'); + expect(lines).toHaveLength(2); + expect(lines[1]).toMatch(/^foo,/); + }); + + it('formats data rows using format functions', () => { + const lines = buildCsv([ + row({ test: 'bench/algo', valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, status: 'regressed' }), + ], null).split('\n'); + expect(lines[1]).toBe('bench/algo,100.0,110.0,10.00,+10.00%,1.1000,regressed'); + }); + + it('renders null values as N/A', () => { + const lines = buildCsv([ + row({ test: 'x', valueA: 0, valueB: null, delta: null, deltaPct: null, ratio: null, status: 'na' }), + ], null).split('\n'); + expect(lines[1]).toBe('x,0,N/A,N/A,N/A,N/A,na'); + }); + + it('quotes fields containing commas', () => { + const lines = buildCsv([row({ test: 'std::map<int, int>::find' })], null).split('\n'); + expect(lines[1]).toMatch(/^"std::map<int, int>::find"/); + }); + + it('quotes and escapes fields containing double quotes', () => { + const lines = buildCsv([row({ test: 'test "quoted" name' })], null).split('\n'); + expect(lines[1]).toMatch(/^"test ""quoted"" name"/); + }); + + it('quotes fields containing newlines', () => { + const csv = buildCsv([row({ test: 'line1\nline2' })], null); + // Can't split on \n since the field itself contains one. + // The quoted field should appear after the header line. + expect(csv).toContain('"line1\nline2"'); + }); + + it('preserves row ordering', () => { + const rows = [row({ test: 'b' }), row({ test: 'a' }), row({ test: 'c' })]; + const lines = buildCsv(rows, null).split('\n'); + expect(lines[1]).toMatch(/^b,/); + expect(lines[2]).toMatch(/^a,/); + expect(lines[3]).toMatch(/^c,/); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/data-table.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/data-table.test.ts new file mode 100644 index 000000000..6a8bdc3d4 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/data-table.test.ts @@ -0,0 +1,176 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { renderDataTable } from '../components/data-table'; +import type { Column } from '../components/data-table'; + +interface TestRow { + name: string; + value: number; +} + +const columns: Column<TestRow>[] = [ + { key: 'name', label: 'Name' }, + { key: 'value', label: 'Value', cellClass: 'col-num', + sortValue: (r) => r.value }, +]; + +const rows: TestRow[] = [ + { name: 'alpha', value: 3 }, + { name: 'beta', value: 1 }, + { name: 'gamma', value: 2 }, +]; + +describe('renderDataTable', () => { + it('renders a table with header and rows', () => { + const container = document.createElement('div'); + renderDataTable(container, { columns, rows }); + + const table = container.querySelector('table'); + expect(table).not.toBeNull(); + + const ths = table!.querySelectorAll('th'); + expect(ths).toHaveLength(2); + expect(ths[0].textContent).toContain('Name'); + expect(ths[1].textContent).toContain('Value'); + + const trs = table!.querySelectorAll('tbody tr'); + expect(trs).toHaveLength(3); + }); + + it('shows empty message when no rows', () => { + const container = document.createElement('div'); + renderDataTable(container, { + columns, + rows: [], + emptyMessage: 'Nothing here.', + }); + + const td = container.querySelector('td.no-results'); + expect(td).not.toBeNull(); + expect(td!.textContent).toBe('Nothing here.'); + }); + + it('sorts by column when header clicked', () => { + const container = document.createElement('div'); + renderDataTable(container, { columns, rows, sortKey: 'value', sortDir: 'asc' }); + + // Initial sort: value ascending → beta(1), gamma(2), alpha(3) + let cells = container.querySelectorAll('tbody tr td:first-child'); + expect(cells[0].textContent).toBe('beta'); + expect(cells[2].textContent).toBe('alpha'); + + // Click Value header to toggle to descending + const valueHeader = container.querySelectorAll('th')[1]; + valueHeader.click(); + + cells = container.querySelectorAll('tbody tr td:first-child'); + expect(cells[0].textContent).toBe('alpha'); + expect(cells[2].textContent).toBe('beta'); + }); + + it('applies cellClass to data cells', () => { + const container = document.createElement('div'); + renderDataTable(container, { columns, rows: rows.slice(0, 1) }); + + const valueTd = container.querySelector('tbody tr td:nth-child(2)'); + expect(valueTd!.className).toBe('col-num'); + }); + + it('calls onRowClick when a row is clicked', () => { + const clicked: TestRow[] = []; + const container = document.createElement('div'); + renderDataTable(container, { + columns, rows: rows.slice(0, 1), + onRowClick: (row) => clicked.push(row), + }); + + const tr = container.querySelector('tbody tr') as HTMLElement; + tr.click(); + expect(clicked).toHaveLength(1); + expect(clicked[0].name).toBe('alpha'); + }); + + it('renders custom Node from render callback', () => { + const container = document.createElement('div'); + const cols: Column<TestRow>[] = [ + { key: 'name', label: 'Name', + render: (r) => { + const span = document.createElement('span'); + span.className = 'custom'; + span.textContent = r.name.toUpperCase(); + return span; + } }, + ]; + renderDataTable(container, { columns: cols, rows: rows.slice(0, 1) }); + + const span = container.querySelector('tbody td span.custom'); + expect(span).not.toBeNull(); + expect(span!.textContent).toBe('ALPHA'); + }); + + it('does not add sortable class or click handler when sortable is false', () => { + const container = document.createElement('div'); + const cols: Column<TestRow>[] = [ + { key: 'name', label: 'Name', sortable: false }, + { key: 'value', label: 'Value', sortValue: (r) => r.value }, + ]; + renderDataTable(container, { columns: cols, rows, sortKey: 'value', sortDir: 'asc' }); + + const nameHeader = container.querySelectorAll('th')[0]; + expect(nameHeader.classList.contains('sortable')).toBe(false); + + // Click the non-sortable header — sort order should not change + const cellsBefore = container.querySelectorAll('tbody tr td:first-child'); + const firstBefore = cellsBefore[0].textContent; + nameHeader.click(); + const cellsAfter = container.querySelectorAll('tbody tr td:first-child'); + expect(cellsAfter[0].textContent).toBe(firstBefore); + }); + + it('applies rowClass to each row', () => { + const container = document.createElement('div'); + renderDataTable(container, { + columns, rows: rows.slice(0, 2), + rowClass: (r) => r.value > 2 ? 'highlight' : '', + }); + + const trs = container.querySelectorAll('tbody tr'); + expect(trs[0].className).toBe('highlight'); // alpha, value=3 + expect(trs[1].className).toBe(''); // beta, value=1 + }); + + it('resets sort direction to asc when clicking a different column', () => { + const container = document.createElement('div'); + renderDataTable(container, { columns, rows, sortKey: 'value', sortDir: 'desc' }); + + // Currently sorted by value desc: alpha(3), gamma(2), beta(1) + let cells = container.querySelectorAll('tbody tr td:first-child'); + expect(cells[0].textContent).toBe('alpha'); + + // Click Name header — should sort by name ascending + const nameHeader = container.querySelectorAll('th')[0]; + nameHeader.click(); + + cells = container.querySelectorAll('tbody tr td:first-child'); + expect(cells[0].textContent).toBe('alpha'); + expect(cells[1].textContent).toBe('beta'); + expect(cells[2].textContent).toBe('gamma'); + }); + + it('uses headerRender for custom header content', () => { + const container = document.createElement('div'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + const cols: Column<TestRow>[] = [ + { key: 'select', label: '', sortable: false, + headerRender: () => checkbox }, + { key: 'name', label: 'Name' }, + ]; + renderDataTable(container, { columns: cols, rows: rows.slice(0, 1) }); + + const th = container.querySelectorAll('th')[0]; + expect(th.querySelector('input[type="checkbox"]')).toBe(checkbox); + // Second header should still use label text + expect(container.querySelectorAll('th')[1].textContent).toContain('Name'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/fixtures/profile-fixtures.ts b/lnt/server/ui/v5/frontend/src/__tests__/fixtures/profile-fixtures.ts new file mode 100644 index 000000000..860774ac0 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/fixtures/profile-fixtures.ts @@ -0,0 +1,140 @@ +// Realistic profile test fixtures. +// +// These provide production-scale mock data for profile tests: C++ mangled +// function names, realistic x86-64 disassembly, skewed counter distributions, +// and multi-counter profiles. Use alongside (not replacing) the minimal +// fixtures that test edge cases. + +import type { + ProfileListItem, ProfileMetadata, ProfileFunctionInfo, + ProfileFunctionDetail, ProfileInstruction, +} from '../../types'; + +// --------------------------------------------------------------------------- +// Function list (~15 functions with realistic counter skew) +// --------------------------------------------------------------------------- + +export const realisticFunctions: ProfileFunctionInfo[] = [ + { name: '_ZN5llvm12SelectionDAG15computeKnownBitsENS_7SDValueERKNS_3APEE', counters: { cycles: 34.2, 'branch-misses': 28.1, 'cache-misses': 41.3, instructions: 31.7 }, length: 187 }, + { name: '_ZN5llvm16InstCombinerImpl7visitOrERNS_14BinaryOperatorE', counters: { cycles: 14.8, 'branch-misses': 12.3, 'cache-misses': 8.9, instructions: 15.1 }, length: 124 }, + { name: '_ZN5llvm15ScalarEvolution14getSCEVAtScopeENS_4SCEVEPKNS_4LoopE', counters: { cycles: 11.2, 'branch-misses': 15.7, 'cache-misses': 6.2, instructions: 10.8 }, length: 93 }, + { name: '_ZN5llvm12MemorySSA18buildMemorySSAForERNS_10BasicBlockE', counters: { cycles: 8.4, 'branch-misses': 7.2, 'cache-misses': 12.1, instructions: 7.9 }, length: 68 }, + { name: '_ZNSt6vectorIiSaIiEE9push_backEOi', counters: { cycles: 6.1, 'branch-misses': 3.8, 'cache-misses': 9.4, instructions: 5.3 }, length: 42 }, + { name: '_ZN5llvm14DominatorTreeBase10recalculateERNS_8FunctionE', counters: { cycles: 5.3, 'branch-misses': 6.9, 'cache-misses': 3.7, instructions: 5.8 }, length: 56 }, + { name: '_ZN5llvm12LiveInterval10MergeValueERNS_8VNInfoE', counters: { cycles: 4.7, 'branch-misses': 4.1, 'cache-misses': 2.8, instructions: 4.2 }, length: 38 }, + { name: '_ZN5llvm6MCExpr11evaluateAsERNS_7MCValueERKNS_11MCAsmLayoutEPKNS_11MCFixupE', counters: { cycles: 3.9, 'branch-misses': 5.2, 'cache-misses': 1.9, instructions: 4.1 }, length: 31 }, + { name: '_ZN5llvm17RegisterCoalescer14joinCopyInstsERKNS_12CoalescerPairERNS_14LiveIntervalsE', counters: { cycles: 3.1, 'branch-misses': 4.3, 'cache-misses': 2.1, instructions: 3.4 }, length: 47 }, + { name: 'main', counters: { cycles: 2.4, 'branch-misses': 1.8, 'cache-misses': 1.2, instructions: 2.6 }, length: 23 }, + { name: '_ZN5llvm6object11ELFFileBase14createSectionsEv', counters: { cycles: 1.8, 'branch-misses': 2.4, 'cache-misses': 3.1, instructions: 1.9 }, length: 35 }, + { name: '__libc_start_main', counters: { cycles: 1.2, 'branch-misses': 0.8, 'cache-misses': 0.4, instructions: 1.1 }, length: 18 }, + { name: '_ZN5llvm12DenseMapBaseINS_8DenseMapIPKNS_5ValueENS_14SmallPtrSetImplIS4_EELj4ENS_12DenseMapInfoIS4_EENS_6detail12DenseMapPairIS4_S6_EEEES4_S6_S8_SB_E4findERKS4_', counters: { cycles: 0.9, 'branch-misses': 1.1, 'cache-misses': 1.8, instructions: 0.8 }, length: 12 }, + { name: '_start', counters: { cycles: 0.4, 'branch-misses': 0.2, 'cache-misses': 0.1, instructions: 0.3 }, length: 5 }, + { name: '_ZN5llvm9StringRef5splitEc', counters: { cycles: 0.3, 'branch-misses': 0.1, 'cache-misses': 0.0, instructions: 0.2 }, length: 8 }, +]; + +// --------------------------------------------------------------------------- +// Function detail for the hottest function (~50 instructions, x86-64) +// --------------------------------------------------------------------------- + +function makeInstruction(addr: number, cycles: number, branchMisses: number, cacheMisses: number, instr: number, text: string): ProfileInstruction { + return { address: addr, counters: { cycles, 'branch-misses': branchMisses, 'cache-misses': cacheMisses, instructions: instr }, text }; +} + +export const realisticFunctionDetail: ProfileFunctionDetail = { + name: '_ZN5llvm12SelectionDAG15computeKnownBitsENS_7SDValueERKNS_3APEE', + counters: { cycles: 34.2, 'branch-misses': 28.1, 'cache-misses': 41.3, instructions: 31.7 }, + disassembly_format: 'llvm-objdump', + instructions: [ + makeInstruction(0x401000, 0.1, 0.0, 0.0, 0.1, 'push rbp'), + makeInstruction(0x401001, 0.1, 0.0, 0.0, 0.1, 'mov rbp, rsp'), + makeInstruction(0x401004, 0.1, 0.0, 0.0, 0.1, 'sub rsp, 0x30'), + makeInstruction(0x401008, 0.2, 0.0, 0.3, 0.2, 'mov qword ptr [rbp - 0x8], rdi'), + makeInstruction(0x40100c, 0.1, 0.0, 0.0, 0.1, 'mov dword ptr [rbp - 0xc], esi'), + makeInstruction(0x40100f, 0.1, 0.0, 0.0, 0.1, 'mov qword ptr [rbp - 0x18], rdx'), + makeInstruction(0x401013, 0.3, 0.1, 0.5, 0.3, 'mov rax, qword ptr [rbp - 0x8]'), + makeInstruction(0x401017, 0.2, 0.0, 0.4, 0.2, 'mov ecx, dword ptr [rax + 0x4]'), + makeInstruction(0x40101a, 0.8, 1.2, 0.1, 0.5, 'cmp ecx, 0x20'), + makeInstruction(0x40101d, 0.6, 2.1, 0.0, 0.4, 'jge 0x4010a0'), + makeInstruction(0x401023, 0.2, 0.0, 0.3, 0.2, 'mov rdx, qword ptr [rbp - 0x18]'), + makeInstruction(0x401027, 0.1, 0.0, 0.0, 0.1, 'mov esi, dword ptr [rdx]'), + makeInstruction(0x401029, 0.3, 0.1, 0.6, 0.3, 'mov rdi, qword ptr [rax + 0x10]'), + makeInstruction(0x40102d, 18.2, 8.3, 22.1, 16.4, 'call _ZN5llvm3APInt12intersectWithERKS0_'), + makeInstruction(0x401032, 0.4, 0.0, 1.2, 0.4, 'mov qword ptr [rbp - 0x20], rax'), + makeInstruction(0x401036, 0.2, 0.0, 0.3, 0.2, 'mov ecx, dword ptr [rbp - 0xc]'), + makeInstruction(0x401039, 0.5, 0.8, 0.0, 0.3, 'test ecx, ecx'), + makeInstruction(0x40103b, 0.3, 1.4, 0.0, 0.2, 'je 0x401070'), + makeInstruction(0x401041, 0.1, 0.0, 0.0, 0.1, 'mov rdi, qword ptr [rbp - 0x20]'), + makeInstruction(0x401045, 0.2, 0.0, 0.4, 0.2, 'mov rsi, qword ptr [rbp - 0x18]'), + makeInstruction(0x401049, 5.1, 3.2, 4.8, 4.7, 'call _ZN5llvm3APInt8setBitEj'), + makeInstruction(0x40104e, 0.3, 0.0, 0.8, 0.3, 'mov rax, qword ptr [rbp - 0x20]'), + makeInstruction(0x401052, 0.1, 0.0, 0.0, 0.1, 'mov ecx, dword ptr [rax + 0x8]'), + makeInstruction(0x401055, 0.4, 0.6, 0.0, 0.3, 'cmp ecx, dword ptr [rbp - 0xc]'), + makeInstruction(0x401058, 0.2, 0.9, 0.0, 0.2, 'jne 0x401080'), + makeInstruction(0x40105e, 0.1, 0.0, 0.0, 0.1, 'mov rdi, qword ptr [rbp - 0x8]'), + makeInstruction(0x401062, 0.1, 0.0, 0.2, 0.1, 'mov esi, dword ptr [rbp - 0xc]'), + makeInstruction(0x401065, 0.2, 0.0, 0.3, 0.2, 'mov rdx, qword ptr [rbp - 0x20]'), + makeInstruction(0x401069, 0.1, 0.0, 0.0, 0.1, 'add rsp, 0x30'), + makeInstruction(0x40106d, 0.1, 0.0, 0.0, 0.1, 'pop rbp'), + makeInstruction(0x40106e, 0.1, 0.1, 0.0, 0.1, 'ret'), + // Second basic block (branch target from je 0x401070) + makeInstruction(0x401070, 0.2, 0.0, 0.3, 0.2, 'mov rdi, qword ptr [rbp - 0x8]'), + makeInstruction(0x401074, 0.1, 0.0, 0.2, 0.1, 'mov rsi, qword ptr [rbp - 0x20]'), + makeInstruction(0x401078, 1.8, 1.1, 2.4, 1.6, 'call _ZN5llvm3APInt10clearAllBitsEv'), + makeInstruction(0x40107d, 0.1, 0.1, 0.0, 0.1, 'jmp 0x401069'), + // Third basic block (branch target from jne 0x401080) + makeInstruction(0x401080, 0.2, 0.0, 0.4, 0.2, 'mov rdi, qword ptr [rbp - 0x8]'), + makeInstruction(0x401084, 0.1, 0.0, 0.0, 0.1, 'mov esi, 0x1'), + makeInstruction(0x401089, 0.1, 0.0, 0.2, 0.1, 'mov rdx, qword ptr [rbp - 0x20]'), + makeInstruction(0x40108d, 0.8, 0.4, 1.2, 0.7, 'call _ZN5llvm13KnownBits12makeConstantERKNS_3APIntE'), + makeInstruction(0x401092, 0.1, 0.0, 0.0, 0.1, 'mov qword ptr [rbp - 0x28], rax'), + makeInstruction(0x401096, 0.2, 0.0, 0.3, 0.2, 'mov rdi, qword ptr [rbp - 0x28]'), + makeInstruction(0x40109a, 0.1, 0.1, 0.0, 0.1, 'jmp 0x401069'), + // Fourth basic block (branch target from jge 0x4010a0) + makeInstruction(0x4010a0, 0.3, 0.0, 0.5, 0.3, 'mov rdi, qword ptr [rbp - 0x8]'), + makeInstruction(0x4010a4, 0.1, 0.0, 0.0, 0.1, 'mov esi, dword ptr [rbp - 0xc]'), + makeInstruction(0x4010a7, 0.2, 0.0, 0.3, 0.2, 'mov rdx, qword ptr [rbp - 0x18]'), + makeInstruction(0x4010ab, 0.4, 0.2, 0.8, 0.4, 'call _ZN5llvm12SelectionDAG21computeKnownBitsImplENS_7SDValueERKNS_3APEEj'), + makeInstruction(0x4010b0, 0.2, 0.0, 0.4, 0.2, 'mov qword ptr [rbp - 0x20], rax'), + makeInstruction(0x4010b4, 0.1, 0.1, 0.0, 0.1, 'jmp 0x401069'), + ], +}; + +// --------------------------------------------------------------------------- +// Profile metadata (top-level counters) +// --------------------------------------------------------------------------- + +export const realisticMetadataA: ProfileMetadata = { + uuid: 'a1b2c3d4-e5f6-7890-abcd-ef0123456789', + test: 'SingleSource/Benchmarks/Dhrystone/dry', + run_uuid: 'run-aaa-111', + counters: { + cycles: 4523891, + 'branch-misses': 18742, + 'cache-misses': 3201, + instructions: 12847623, + }, + disassembly_format: 'llvm-objdump', +}; + +export const realisticMetadataB: ProfileMetadata = { + uuid: 'f1e2d3c4-b5a6-0987-fedc-ba9876543210', + test: 'SingleSource/Benchmarks/Dhrystone/dry', + run_uuid: 'run-bbb-222', + counters: { + cycles: 4891204, // +8.1% regression + 'branch-misses': 15903, // -15.1% improvement + 'cache-misses': 3198, // -0.1% unchanged (noise) + instructions: 12847623, // identical (same code, different branch behavior) + }, + disassembly_format: 'llvm-objdump', +}; + +// --------------------------------------------------------------------------- +// Profile listing items +// --------------------------------------------------------------------------- + +export const realisticProfileList: ProfileListItem[] = [ + { test: 'SingleSource/Benchmarks/Dhrystone/dry', uuid: 'a1b2c3d4-e5f6-7890-abcd-ef0123456789' }, + { test: 'SingleSource/Benchmarks/Stanford/Towers', uuid: 'b2c3d4e5-f6a7-8901-bcde-f01234567890' }, + { test: 'MultiSource/Benchmarks/Olden/bh/bh', uuid: 'c3d4e5f6-a7b8-9012-cdef-012345678901' }, +]; diff --git a/lnt/server/ui/v5/frontend/src/__tests__/machine-combobox.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/machine-combobox.test.ts new file mode 100644 index 000000000..76342d8a8 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/machine-combobox.test.ts @@ -0,0 +1,349 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../api', () => ({ + getMachines: vi.fn(), +})); + +import { renderMachineCombobox } from '../components/machine-combobox'; +import { getMachines } from '../api'; + +const mockGetMachines = getMachines as ReturnType<typeof vi.fn>; + +const MACHINES = [ + { name: 'clang-x86', info: {} }, + { name: 'clang-arm', info: {} }, + { name: 'gcc-x86', info: {} }, +]; + +beforeEach(() => { + vi.useFakeTimers(); + mockGetMachines.mockReset(); + document.body.innerHTML = ''; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** Create the combobox and resolve the initial machine list fetch. */ +async function createAndLoad( + items: Array<{ name: string; info: Record<string, unknown> }>, + opts?: Partial<Parameters<typeof renderMachineCombobox>[1]>, +): Promise<{ container: HTMLElement; input: HTMLInputElement; handle: ReturnType<typeof renderMachineCombobox> }> { + mockGetMachines.mockResolvedValue({ items, total: items.length }); + const container = document.createElement('div'); + document.body.append(container); + const handle = renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn(), ...opts }); + // Resolve the initial fetch + await vi.advanceTimersByTimeAsync(0); + const input = container.querySelector('input') as HTMLInputElement; + return { container, input, handle }; +} + +describe('renderMachineCombobox', () => { + it('renders an input into the container', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + expect(container.querySelector('input.combobox-input')).not.toBeNull(); + }); + + it('sets initial value on the input', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + renderMachineCombobox(container, { testsuite: 'nts', initialValue: 'clang-x86', onSelect: vi.fn() }); + const input = container.querySelector('input') as HTMLInputElement; + expect(input.value).toBe('clang-x86'); + }); + + it('fetches full machine list once on creation', async () => { + mockGetMachines.mockResolvedValue({ items: MACHINES, total: 3 }); + const container = document.createElement('div'); + document.body.append(container); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + expect(mockGetMachines).toHaveBeenCalledTimes(1); + expect(mockGetMachines).toHaveBeenCalledWith('nts', { limit: 500 }, expect.anything()); + }); + + it('does not fetch when testsuite is empty and disables input', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + renderMachineCombobox(container, { testsuite: '', onSelect: vi.fn() }); + + expect(mockGetMachines).not.toHaveBeenCalled(); + const input = container.querySelector('input') as HTMLInputElement; + expect(input.disabled).toBe(true); + expect(input.placeholder).toBe('Select a suite first'); + }); + + it('does not make additional API calls on keystroke', async () => { + const { input } = await createAndLoad(MACHINES); + + mockGetMachines.mockClear(); + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + input.value = 'gcc'; + input.dispatchEvent(new Event('input')); + + expect(mockGetMachines).not.toHaveBeenCalled(); + }); + + it('shows "Loading machines..." before fetch resolves', () => { + mockGetMachines.mockReturnValue(new Promise(() => {})); // never resolves + const container = document.createElement('div'); + document.body.append(container); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + const input = container.querySelector('input') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('Loading machines...'); + }); + + it('filters locally by case-insensitive substring', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.value = 'x86'; + input.dispatchEvent(new Event('input')); + + const items = container.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(2); + expect(items[0].textContent).toBe('clang-x86'); + expect(items[1].textContent).toBe('gcc-x86'); + }); + + it('shows all machines when input is empty', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.value = ''; + input.dispatchEvent(new Event('input')); + + const items = container.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(3); + }); + + it('shows all machines without cap', async () => { + const manyMachines = Array.from({ length: 50 }, (_, i) => ({ + name: `machine-${String(i).padStart(2, '0')}`, + info: {}, + })); + const { container, input } = await createAndLoad(manyMachines); + + input.value = 'machine'; + input.dispatchEvent(new Event('input')); + + const items = container.querySelectorAll('li.combobox-item'); + expect(items).toHaveLength(50); + }); + + it('calls onSelect on dropdown item click', async () => { + const onSelect = vi.fn(); + const { container, input } = await createAndLoad(MACHINES, { onSelect }); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + const item = container.querySelector('li.combobox-item') as HTMLElement; + item.click(); + expect(onSelect).toHaveBeenCalledWith('clang-x86'); + expect(input.value).toBe('clang-x86'); + }); + + it('calls onSelect on Enter when dropdown has items', async () => { + const onSelect = vi.fn(); + const { input } = await createAndLoad(MACHINES, { onSelect }); + + input.value = 'clang-x86'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).toHaveBeenCalledWith('clang-x86'); + }); + + it('keeps dropdown open when ArrowDown moves focus to an item', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + // ArrowDown moves focus to the first item — blur fires on input + const firstItem = dropdown.querySelector('li.combobox-item') as HTMLElement; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + // Simulate the blur with relatedTarget pointing to the dropdown item + input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + + // Dropdown should stay open + expect(dropdown.classList.contains('open')).toBe(true); + }); + + it('selects item via ArrowDown then Enter', async () => { + const onSelect = vi.fn(); + const { container, input } = await createAndLoad(MACHINES, { onSelect }); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + // ArrowDown to first item + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + const firstItem = container.querySelector('li.combobox-item') as HTMLElement; + input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + + // Enter on the focused item + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(onSelect).toHaveBeenCalledWith('clang-x86'); + }); + + it('does not call onSelect on Enter when dropdown is empty', async () => { + const onSelect = vi.fn(); + const { input } = await createAndLoad(MACHINES, { onSelect }); + + input.value = 'nonexistent'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(onSelect).not.toHaveBeenCalled(); + }); + + it('closes dropdown on Escape', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('closes dropdown on blur', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + input.dispatchEvent(new Event('blur')); + expect(dropdown.classList.contains('open')).toBe(false); + }); + + it('getValue returns the selected value', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + const handle = renderMachineCombobox(container, { + testsuite: 'nts', initialValue: 'test-machine', onSelect: vi.fn(), + }); + expect(handle.getValue()).toBe('test-machine'); + }); + + it('destroy removes document click listener and aborts fetch', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + const handle = renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + const spy = vi.spyOn(document, 'removeEventListener'); + handle.destroy(); + expect(spy).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + // --- Validation tests --- + + it('shows combobox-invalid when no machines match', async () => { + const { input } = await createAndLoad(MACHINES); + + input.value = 'nonexistent'; + input.dispatchEvent(new Event('input')); + expect(input.classList.contains('combobox-invalid')).toBe(true); + }); + + it('removes combobox-invalid when machines match again', async () => { + const { input } = await createAndLoad(MACHINES); + + input.value = 'nonexistent'; + input.dispatchEvent(new Event('input')); + expect(input.classList.contains('combobox-invalid')).toBe(true); + + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + expect(input.classList.contains('combobox-invalid')).toBe(false); + }); + + it('adds combobox-invalid on Enter when dropdown is empty', async () => { + const { input } = await createAndLoad(MACHINES); + + input.value = 'nonexistent'; + input.dispatchEvent(new Event('input')); + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(input.classList.contains('combobox-invalid')).toBe(true); + }); + + it('removes combobox-invalid on dropdown item click', async () => { + const { container, input } = await createAndLoad(MACHINES); + + input.classList.add('combobox-invalid'); + input.value = 'clang'; + input.dispatchEvent(new Event('input')); + + const item = container.querySelector('li.combobox-item') as HTMLElement; + item.click(); + expect(input.classList.contains('combobox-invalid')).toBe(false); + }); + + it('removes combobox-invalid on clear()', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + const handle = renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + const input = container.querySelector('input') as HTMLInputElement; + + input.classList.add('combobox-invalid'); + handle.clear(); + expect(input.classList.contains('combobox-invalid')).toBe(false); + }); + + // --- onClear tests --- + + it('calls onClear when input is cleared on change', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const onClear = vi.fn(); + const container = document.createElement('div'); + document.body.append(container); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn(), onClear }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = ''; + input.dispatchEvent(new Event('change')); + expect(onClear).toHaveBeenCalled(); + }); + + it('does not call onClear when onClear is not provided', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const container = document.createElement('div'); + document.body.append(container); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = ''; + expect(() => input.dispatchEvent(new Event('change'))).not.toThrow(); + }); + + it('does not call onClear when input has text on change', () => { + mockGetMachines.mockResolvedValue({ items: [], total: 0 }); + const onClear = vi.fn(); + const container = document.createElement('div'); + document.body.append(container); + renderMachineCombobox(container, { testsuite: 'nts', onSelect: vi.fn(), onClear }); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = 'some-machine'; + input.dispatchEvent(new Event('change')); + expect(onClear).not.toHaveBeenCalled(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/metric-selector.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/metric-selector.test.ts new file mode 100644 index 000000000..4108bce7e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/metric-selector.test.ts @@ -0,0 +1,117 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { renderMetricSelector, filterMetricFields, METRIC_TYPES } from '../components/metric-selector'; +import type { FieldInfo } from '../types'; + +function makeField(name: string, type: string = METRIC_TYPES.REAL, displayName: string | null = null): FieldInfo { + return { name, type, display_name: displayName, unit: null, unit_abbrev: null, bigger_is_better: null }; +} + +describe('filterMetricFields', () => { + it('returns only real-typed fields', () => { + const fields = [ + makeField('status', METRIC_TYPES.STATUS), + makeField('compile_time', METRIC_TYPES.REAL), + makeField('exec_time', METRIC_TYPES.REAL), + ]; + const result = filterMetricFields(fields); + expect(result).toHaveLength(2); + expect(result.map(f => f.name)).toEqual(['compile_time', 'exec_time']); + }); + + it('returns empty array when no real fields', () => { + const result = filterMetricFields([makeField('status', METRIC_TYPES.STATUS)]); + expect(result).toHaveLength(0); + }); +}); + +describe('renderMetricSelector', () => { + it('returns empty string and renders nothing when fields array is empty', () => { + const container = document.createElement('div'); + const result = renderMetricSelector(container, [], vi.fn()); + + expect(result).toBe(''); + expect(container.querySelector('select')).toBeNull(); + }); + + it('renders all fields passed to it', () => { + const container = document.createElement('div'); + renderMetricSelector(container, [ + makeField('compile_time'), + makeField('exec_time'), + ], vi.fn()); + + const options = container.querySelectorAll('option'); + expect(options).toHaveLength(2); + }); + + it('returns the first field name as initial metric', () => { + const container = document.createElement('div'); + const result = renderMetricSelector(container, [ + makeField('compile_time'), + makeField('exec_time'), + ], vi.fn()); + + expect(result).toBe('compile_time'); + }); + + it('uses display_name when available', () => { + const container = document.createElement('div'); + renderMetricSelector(container, [ + makeField('ct', METRIC_TYPES.REAL, 'Compile Time'), + ], vi.fn()); + + const option = container.querySelector('option'); + expect(option!.textContent).toBe('Compile Time'); + expect(option!.getAttribute('value')).toBe('ct'); + }); + + it('falls back to name when display_name is null', () => { + const container = document.createElement('div'); + renderMetricSelector(container, [ + makeField('exec_time'), + ], vi.fn()); + + const option = container.querySelector('option'); + expect(option!.textContent).toBe('exec_time'); + }); + + it('calls onChange with selected metric on change', () => { + const onChange = vi.fn(); + const container = document.createElement('div'); + renderMetricSelector(container, [ + makeField('compile_time'), + makeField('exec_time'), + ], onChange); + + const select = container.querySelector('select') as HTMLSelectElement; + select.value = 'exec_time'; + select.dispatchEvent(new Event('change')); + + expect(onChange).toHaveBeenCalledWith('exec_time'); + }); + + it('shows placeholder option when placeholder: true', () => { + const container = document.createElement('div'); + const result = renderMetricSelector(container, [ + makeField('compile_time'), + makeField('exec_time'), + ], vi.fn(), undefined, { placeholder: true }); + + const options = container.querySelectorAll('option'); + expect(options).toHaveLength(3); + expect(options[0].textContent).toBe('-- Select metric --'); + expect(options[0].getAttribute('value')).toBe(''); + expect(result).toBe(''); + }); + + it('selects initialValue even with placeholder', () => { + const container = document.createElement('div'); + const result = renderMetricSelector(container, [ + makeField('compile_time'), + makeField('exec_time'), + ], vi.fn(), 'exec_time', { placeholder: true }); + + expect(result).toBe('exec_time'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/nav.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/nav.test.ts new file mode 100644 index 000000000..8e8016b4b --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/nav.test.ts @@ -0,0 +1,347 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the router's navigate before importing nav +vi.mock('../router', () => ({ + navigate: vi.fn(), +})); + +import { renderNav, updateActiveNavLink } from '../components/nav'; +import { navigate } from '../router'; + +beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + // Provide localStorage stub + const store: Record<string, string> = {}; + vi.stubGlobal('localStorage', { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, val: string) => { store[key] = val; }, + removeItem: (key: string) => { delete store[key]; }, + }); +}); + +// --- Suite-agnostic context (no testsuite) --- + +describe('renderNav (suite-agnostic context)', () => { + const config = { + testsuite: '', + urlBase: '', + }; + + it('renders left-side nav links: Test Suites, Graph, Compare, Profiles', () => { + const nav = renderNav(config); + const links = nav.querySelectorAll('.v5-nav-links .v5-nav-link[data-path]'); + const labels = Array.from(links).map(l => l.textContent); + expect(labels).toEqual(['Test Suites', 'Graph', 'Compare', 'Profiles']); + }); + + it('renders API link with target="_blank"', () => { + const nav = renderNav(config); + const apiLink = Array.from(nav.querySelectorAll('.v5-nav-link')) + .find(l => l.textContent === 'API') as HTMLAnchorElement; + expect(apiLink).toBeTruthy(); + expect(apiLink.getAttribute('target')).toBe('_blank'); + expect(apiLink.getAttribute('href')).toBe('/api/v5/openapi/swagger-ui'); + }); + + it('renders right-side links: Admin, Settings', () => { + const nav = renderNav(config); + const rightLinks = nav.querySelectorAll('.v5-nav-right .v5-nav-link'); + const labels = Array.from(rightLinks).map(l => l.textContent); + expect(labels).toEqual(['Admin', 'Settings']); + }); + + it('renders the LNT brand', () => { + const nav = renderNav(config); + const brand = nav.querySelector('.v5-nav-brand'); + expect(brand?.textContent).toBe('LNT'); + }); + + it('does not render a suite selector dropdown', () => { + const nav = renderNav(config); + const select = nav.querySelector('select'); + expect(select).toBeNull(); + }); + + it('brand href is /v5/', () => { + const nav = renderNav(config); + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + expect(brand.getAttribute('href')).toBe('/v5/'); + }); + + it('clicking brand calls navigate("/")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + brand.click(); + expect(navigate).toHaveBeenCalledWith('/'); + }); + + it('clicking Test Suites calls navigate("/test-suites")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Test Suites') as HTMLAnchorElement; + link.click(); + expect(navigate).toHaveBeenCalledWith('/test-suites'); + }); + + it('clicking Graph calls navigate("/graph")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Graph') as HTMLAnchorElement; + link.click(); + expect(navigate).toHaveBeenCalledWith('/graph'); + }); + + it('clicking Compare calls navigate("/compare")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Compare') as HTMLAnchorElement; + link.click(); + expect(navigate).toHaveBeenCalledWith('/compare'); + }); + + it('clicking Profiles calls navigate("/profiles")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Profiles') as HTMLAnchorElement; + link.click(); + expect(navigate).toHaveBeenCalledWith('/profiles'); + }); + + it('clicking Admin calls navigate("/admin")', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Admin') as HTMLAnchorElement; + link.click(); + expect(navigate).toHaveBeenCalledWith('/admin'); + }); + + it('Settings link renders', () => { + const nav = renderNav(config); + const settingsLink = Array.from(nav.querySelectorAll('.v5-nav-link')) + .find(l => l.textContent === 'Settings'); + expect(settingsLink).toBeTruthy(); + }); + + it('Cmd+Click on nav link does not call navigate()', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Graph') as HTMLAnchorElement; + link.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('Ctrl+Click on nav link does not call navigate()', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Graph') as HTMLAnchorElement; + link.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('Cmd+Click on brand does not call navigate()', () => { + const nav = renderNav(config); + document.body.append(nav); + + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + brand.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('nav links include urlBase when set', () => { + const configWithBase = { ...config, urlBase: '/lnt' }; + const nav = renderNav(configWithBase); + const links = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) as HTMLAnchorElement[]; + const hrefs = links.map(l => ({ label: l.textContent, href: l.getAttribute('href') })); + expect(hrefs).toContainEqual({ label: 'Graph', href: '/lnt/v5/graph' }); + expect(hrefs).toContainEqual({ label: 'Admin', href: '/lnt/v5/admin' }); + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + expect(brand.getAttribute('href')).toBe('/lnt/v5/'); + }); +}); + +// --- Suite-scoped context (testsuite: 'nts') --- + +describe('renderNav (suite-scoped context)', () => { + const config = { + testsuite: 'nts', + urlBase: '', + }; + + it('renders same links as agnostic context (navbar looks identical)', () => { + const nav = renderNav(config); + const leftLinks = nav.querySelectorAll('.v5-nav-links .v5-nav-link[data-path]'); + const leftLabels = Array.from(leftLinks).map(l => l.textContent); + expect(leftLabels).toEqual(['Test Suites', 'Graph', 'Compare', 'Profiles']); + + const rightLinks = nav.querySelectorAll('.v5-nav-right .v5-nav-link'); + const rightLabels = Array.from(rightLinks).map(l => l.textContent); + expect(rightLabels).toEqual(['Admin', 'Settings']); + }); + + it('brand href is /v5/ (not suite-scoped)', () => { + const nav = renderNav(config); + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + expect(brand.getAttribute('href')).toBe('/v5/'); + }); + + it('clicking brand does NOT call navigate() (full-page nav)', () => { + const nav = renderNav(config); + document.body.append(nav); + + const brand = nav.querySelector('.v5-nav-brand') as HTMLAnchorElement; + brand.click(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('clicking Test Suites does NOT call navigate() (full-page nav)', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Test Suites') as HTMLAnchorElement; + link.click(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('Graph link href includes ?suite=nts', () => { + const nav = renderNav(config); + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Graph') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('/v5/graph?suite=nts'); + }); + + it('Compare link href includes ?suite_a=nts', () => { + const nav = renderNav(config); + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Compare') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('/v5/compare?suite_a=nts'); + }); + + it('Profiles link href includes ?suite_a=nts', () => { + const nav = renderNav(config); + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Profiles') as HTMLAnchorElement; + expect(link.getAttribute('href')).toBe('/v5/profiles?suite_a=nts'); + }); + + it('clicking Admin does NOT call navigate() (full-page nav)', () => { + const nav = renderNav(config); + document.body.append(nav); + + const link = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')) + .find(l => l.textContent === 'Admin') as HTMLAnchorElement; + link.click(); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('no nav link calls navigate() in suite-scoped context', () => { + const nav = renderNav(config); + document.body.append(nav); + + const links = Array.from(nav.querySelectorAll('.v5-nav-link[data-path]')); + for (const link of links) { + (link as HTMLElement).click(); + } + expect(navigate).not.toHaveBeenCalled(); + }); +}); + +// --- updateActiveNavLink --- + +describe('updateActiveNavLink', () => { + const config = { + testsuite: '', + urlBase: '', + }; + + it('highlights Test Suites for /test-suites path', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/test-suites'); + + const link = document.querySelector('[data-path="/test-suites"]'); + expect(link?.classList.contains('v5-nav-link-active')).toBe(true); + }); + + it('highlights Graph for /graph path', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/graph'); + + const link = document.querySelector('[data-path="/graph"]'); + expect(link?.classList.contains('v5-nav-link-active')).toBe(true); + }); + + it('highlights Compare for /compare path', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/compare'); + + const link = document.querySelector('[data-path="/compare"]'); + expect(link?.classList.contains('v5-nav-link-active')).toBe(true); + }); + + it('highlights Profiles for /profiles path', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/profiles'); + + const link = document.querySelector('[data-path="/profiles"]'); + expect(link?.classList.contains('v5-nav-link-active')).toBe(true); + }); + + it('highlights Admin for /admin path', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/admin'); + + const link = document.querySelector('[data-path="/admin"]'); + expect(link?.classList.contains('v5-nav-link-active')).toBe(true); + }); + + it('no link highlighted for root path /', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/'); + + const activeLinks = document.querySelectorAll('.v5-nav-link-active'); + expect(activeLinks).toHaveLength(0); + }); + + it('clears previous highlight when path changes', () => { + const nav = renderNav(config); + document.body.append(nav); + + updateActiveNavLink('/graph'); + updateActiveNavLink('/admin'); + + const graphLink = document.querySelector('[data-path="/graph"]'); + const adminLink = document.querySelector('[data-path="/admin"]'); + expect(graphLink?.classList.contains('v5-nav-link-active')).toBe(false); + expect(adminLink?.classList.contains('v5-nav-link-active')).toBe(true); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/admin.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/admin.test.ts new file mode 100644 index 000000000..ef1dcecf6 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/admin.test.ts @@ -0,0 +1,261 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getApiKeys: vi.fn(), + createApiKey: vi.fn(), + revokeApiKey: vi.fn(), + getTestSuiteInfo: vi.fn(), + createTestSuite: vi.fn(), + deleteTestSuite: vi.fn(), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { getApiKeys, createApiKey, revokeApiKey, getTestSuiteInfo, createTestSuite, deleteTestSuite, ApiError } from '../../api'; +import { adminPage } from '../../pages/admin'; +import type { APIKeyItem, TestSuiteInfo } from '../../types'; + +const mockKeys: APIKeyItem[] = [ + { + prefix: 'abc123', + name: 'Test Key', + scope: 'admin', + created_at: '2026-01-01T00:00:00Z', + last_used_at: null, + is_active: true, + }, + { + prefix: 'def456', + name: 'Revoked Key', + scope: 'read', + created_at: '2025-06-01T00:00:00Z', + last_used_at: '2025-12-01T00:00:00Z', + is_active: false, + }, +]; + +const mockSuiteInfo: TestSuiteInfo = { + name: 'nts', + schema: { + metrics: [ + { name: 'exec_time', type: 'real', display_name: 'Execution Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, + ], + commit_fields: [{ name: 'rev', type: 'text' }], + machine_fields: [{ name: 'hostname', type: 'text' }], + }, +}; + +describe('adminPage', () => { + let container: HTMLElement; + let appRoot: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + + // Set up the #v5-app element with data-testsuites for the admin page to read + appRoot = document.createElement('div'); + appRoot.id = 'v5-app'; + appRoot.setAttribute('data-testsuites', JSON.stringify(['nts', 'test-suite-2'])); + document.body.append(appRoot); + + (getApiKeys as ReturnType<typeof vi.fn>).mockResolvedValue(mockKeys); + (getTestSuiteInfo as ReturnType<typeof vi.fn>).mockResolvedValue(mockSuiteInfo); + (createApiKey as ReturnType<typeof vi.fn>).mockResolvedValue({ + key: 'raw-token-value', + prefix: 'new123', + scope: 'read', + }); + (revokeApiKey as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); + (createTestSuite as ReturnType<typeof vi.fn>).mockResolvedValue(mockSuiteInfo); + (deleteTestSuite as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); + }); + + afterEach(() => { + appRoot.remove(); + }); + + it('renders tab bar with API Keys, Test Suites, and Create Suite tabs', () => { + adminPage.mount(container, { testsuite: '' }); + + const tabs = container.querySelectorAll('.v5-tab'); + expect(tabs).toHaveLength(3); + expect(tabs[0].textContent).toBe('API Keys'); + expect(tabs[1].textContent).toBe('Test Suites'); + expect(tabs[2].textContent).toBe('Create Suite'); + }); + + it('loads and displays API keys table', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(getApiKeys).toHaveBeenCalled(); + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBeGreaterThanOrEqual(2); + }); + }); + + it('shows auth error for 401/403', async () => { + (getApiKeys as ReturnType<typeof vi.fn>).mockRejectedValue(new ApiError(403, 'API 403: forbidden')); + + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + const error = container.querySelector('.error-banner'); + expect(error).toBeTruthy(); + expect(error!.textContent).toContain('Permission denied'); + }); + }); + + it('shows create key form', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-create-form')).toBeTruthy(); + expect(container.querySelector('.admin-input')).toBeTruthy(); + }); + }); + + it('Test Suites tab shows suite selector and loads first suite', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-create-form')).toBeTruthy(); + }); + + // Click Test Suites tab + const tabs = container.querySelectorAll('.v5-tab'); + (tabs[1] as HTMLElement).click(); + + await vi.waitFor(() => { + // Should have a suite selector with both suites + const select = container.querySelector('.admin-select') as HTMLSelectElement; + expect(select).toBeTruthy(); + expect(select.options).toHaveLength(2); + expect(select.options[0].value).toBe('nts'); + expect(select.options[1].value).toBe('test-suite-2'); + // Should have loaded the first suite + expect(getTestSuiteInfo).toHaveBeenCalledWith('nts', expect.any(AbortSignal)); + expect(container.textContent).toContain('Test Suite: nts'); + }); + }); + + it('Test Suites tab shows metrics and field tables', async () => { + adminPage.mount(container, { testsuite: '' }); + + // Wait for API Keys tab to load first + await vi.waitFor(() => { + expect(container.querySelector('.admin-create-form')).toBeTruthy(); + }); + + // Switch to Test Suites tab + const tabs = container.querySelectorAll('.v5-tab'); + (tabs[1] as HTMLElement).click(); + + await vi.waitFor(() => { + expect(container.textContent).toContain('Metrics'); + expect(container.textContent).toContain('Execution Time'); + expect(container.textContent).toContain('Commit Fields'); + expect(container.textContent).toContain('rev'); + expect(container.textContent).toContain('Machine Fields'); + expect(container.textContent).toContain('hostname'); + }); + }); + + it('revoke button only shown for active keys', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + const buttons = container.querySelectorAll('.admin-btn-danger'); + expect(buttons).toHaveLength(1); + }); + }); + + it('Create Suite tab shows name input and JSON textarea', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.v5-tab')).toHaveLength(3); + }); + + const tabs = container.querySelectorAll('.v5-tab'); + (tabs[2] as HTMLElement).click(); + + const inputs = container.querySelectorAll('.admin-input'); + expect(inputs.length).toBeGreaterThanOrEqual(1); + expect(container.querySelector('.admin-textarea')).toBeTruthy(); + }); + + it('Test Suites tab shows delete button with confirmation', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-create-form')).toBeTruthy(); + }); + + const tabs = container.querySelectorAll('.v5-tab'); + (tabs[1] as HTMLElement).click(); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-delete-section')).toBeTruthy(); + }); + + // Click delete toggle to show confirmation + const deleteToggle = container.querySelector('.admin-delete-section > .admin-btn-danger') as HTMLButtonElement; + deleteToggle.click(); + + // Confirmation panel should be visible with warning and disabled button + const confirmPanel = container.querySelector('.admin-delete-confirm') as HTMLElement; + expect(confirmPanel.style.display).not.toBe('none'); + expect(confirmPanel.textContent).toContain('permanently destroy'); + + const confirmBtn = confirmPanel.querySelector('.admin-btn-danger') as HTMLButtonElement; + expect(confirmBtn).toBeTruthy(); + expect(confirmBtn.disabled).toBe(true); + }); + + it('delete confirm button enables only when name matches', async () => { + adminPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-create-form')).toBeTruthy(); + }); + + const tabs = container.querySelectorAll('.v5-tab'); + (tabs[1] as HTMLElement).click(); + + await vi.waitFor(() => { + expect(container.querySelector('.admin-delete-section')).toBeTruthy(); + }); + + // Show confirmation + const deleteToggle = container.querySelector('.admin-delete-section > .admin-btn-danger') as HTMLButtonElement; + deleteToggle.click(); + + const confirmPanel = container.querySelector('.admin-delete-confirm') as HTMLElement; + const confirmInput = confirmPanel.querySelector('.admin-input') as HTMLInputElement; + const confirmBtn = confirmPanel.querySelector('.admin-btn-danger') as HTMLButtonElement; + + // Type wrong name + confirmInput.value = 'wrong'; + confirmInput.dispatchEvent(new Event('input')); + expect(confirmBtn.disabled).toBe(true); + + // Type correct name (first suite = 'nts') + confirmInput.value = 'nts'; + confirmInput.dispatchEvent(new Event('input')); + expect(confirmBtn.disabled).toBe(false); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/commit-detail.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/commit-detail.test.ts new file mode 100644 index 000000000..fdaca23ee --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/commit-detail.test.ts @@ -0,0 +1,504 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getCommit: vi.fn(), + getRunsByCommit: vi.fn(), + updateCommit: vi.fn(), + authErrorMessage: vi.fn((err: unknown) => `Auth error: ${err}`), + }; +}); + +// Mock router navigate +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + navigate: vi.fn(), + getBasePath: vi.fn(() => '/v5/nts'), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { getCommit, getRunsByCommit, updateCommit, authErrorMessage } from '../../api'; +import { navigate } from '../../router'; +import { commitDetailPage } from '../../pages/commit-detail'; +import type { CommitDetail, RunInfo } from '../../types'; + +const mockCommit: CommitDetail = { + commit: '100', + ordinal: 42, + tag: 'release-18', + fields: { rev: '100' }, + previous_commit: { commit: '99', ordinal: 41, tag: null, link: '/commits/99' }, + next_commit: { commit: '101', ordinal: 43, tag: null, link: '/commits/101' }, +}; + +const mockRuns: RunInfo[] = [ + { uuid: 'aaaa-1111', machine: 'clang-x86', commit: '100', submitted_at: '2026-01-01T10:00:00Z' }, + { uuid: 'bbbb-2222', machine: 'clang-x86', commit: '100', submitted_at: '2026-01-01T11:00:00Z' }, + { uuid: 'cccc-3333', machine: 'gcc-arm', commit: '100', submitted_at: '2026-01-01T12:00:00Z' }, +]; + +describe('commitDetailPage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + vi.useFakeTimers(); + container = document.createElement('div'); + + (getCommit as ReturnType<typeof vi.fn>).mockResolvedValue(mockCommit); + (getRunsByCommit as ReturnType<typeof vi.fn>).mockResolvedValue(mockRuns); + (updateCommit as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mockCommit, ordinal: 99 }); + }); + + afterEach(() => { + commitDetailPage.unmount?.(); + vi.useRealTimers(); + }); + + it('renders page header with commit value', () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + expect(container.querySelector('.page-header')?.textContent).toBe('Commit: 100'); + }); + + it('calls getCommit and getRunsByCommit in parallel', () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + expect(getCommit).toHaveBeenCalledWith('nts', '100', expect.any(AbortSignal)); + expect(getRunsByCommit).toHaveBeenCalledWith('nts', '100', expect.any(AbortSignal)); + }); + + it('renders commit fields as dl key-value pairs', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const dl = container.querySelector('.metadata-dl'); + expect(dl).toBeTruthy(); + expect(dl!.textContent).toContain('rev'); + expect(dl!.textContent).toContain('100'); + }); + }); + + it('shows ordinal value and Edit button', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const ordinalDisplay = container.querySelector('[data-field="ordinal"]'); + expect(ordinalDisplay).toBeTruthy(); + expect(ordinalDisplay!.textContent).toContain('42'); + expect(ordinalDisplay!.querySelector('button')?.textContent).toBe('Edit'); + }); + }); + + it('shows "(none)" when ordinal is null', async () => { + (getCommit as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mockCommit, ordinal: null }); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const ordinalDisplay = container.querySelector('[data-field="ordinal"]'); + expect(ordinalDisplay!.textContent).toContain('(none)'); + }); + }); + + it('clicking Edit shows inline edit form with input, Save, Cancel', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('[data-field="ordinal"] button')).toBeTruthy(); + }); + + // Click Edit + const editBtn = container.querySelector('[data-field="ordinal"] button') as HTMLElement; + editBtn.click(); + + // Should now show input, Save, Cancel + const ordinalContainer = container.querySelector('[data-field="ordinal"]')!; + const input = ordinalContainer.querySelector('input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('42'); // pre-filled + + const buttons = ordinalContainer.querySelectorAll('button'); + const buttonTexts = Array.from(buttons).map(b => b.textContent); + expect(buttonTexts).toContain('Save'); + expect(buttonTexts).toContain('Cancel'); + }); + + it('Cancel returns to display mode without API call', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('[data-field="ordinal"] button')).toBeTruthy(); + }); + + // Click Edit + (container.querySelector('[data-field="ordinal"] button') as HTMLElement).click(); + + // Click Cancel + const cancelBtn = Array.from(container.querySelectorAll('[data-field="ordinal"] button')) + .find(b => b.textContent === 'Cancel') as HTMLElement; + cancelBtn.click(); + + // Should be back to display mode + expect(container.querySelector('[data-field="ordinal"]')!.textContent).toContain('42'); + expect(container.querySelector('[data-field="ordinal"]')!.textContent).toContain('Edit'); + expect(updateCommit).not.toHaveBeenCalled(); + }); + + it('Save calls updateCommit and re-renders ordinal on success', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('[data-field="ordinal"] button')).toBeTruthy(); + }); + + // Click Edit + (container.querySelector('[data-field="ordinal"] button') as HTMLElement).click(); + + // Change value and click Save + const input = container.querySelector('[data-field="ordinal"] input') as HTMLInputElement; + input.value = '99'; + + const saveBtn = Array.from(container.querySelectorAll('[data-field="ordinal"] button')) + .find(b => b.textContent === 'Save') as HTMLElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateCommit).toHaveBeenCalledWith('nts', '100', { ordinal: 99 }); + // Should be back in display mode with new ordinal + expect(container.querySelector('[data-field="ordinal"]')!.textContent).toContain('99'); + }); + }); + + it('Save shows error on failure and re-enables button', async () => { + (updateCommit as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('403 forbidden')); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('[data-field="ordinal"] button')).toBeTruthy(); + }); + + (container.querySelector('[data-field="ordinal"] button') as HTMLElement).click(); + + const saveBtn = Array.from(container.querySelectorAll('[data-field="ordinal"] button')) + .find(b => b.textContent === 'Save') as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + const errorEl = container.querySelector('[data-field="ordinal"] .error-banner'); + expect(errorEl).toBeTruthy(); + // Save button should be re-enabled + const currentSaveBtn = Array.from(container.querySelectorAll('[data-field="ordinal"] button')) + .find(b => b.textContent === 'Save') as HTMLButtonElement; + expect(currentSaveBtn.disabled).toBe(false); + }); + }); + + // --------------------------------------------------------------------------- + // Tag display + edit + // --------------------------------------------------------------------------- + + it('shows tag value and Edit button', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const tagDisplay = container.querySelector('[data-field="tag"]'); + expect(tagDisplay).toBeTruthy(); + expect(tagDisplay!.textContent).toContain('Tag:'); + expect(tagDisplay!.textContent).toContain('release-18'); + expect(tagDisplay!.querySelector('button')?.textContent).toBe('Edit'); + }); + }); + + it('shows "(none)" when tag is null', async () => { + (getCommit as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mockCommit, tag: null }); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const tagDisplay = container.querySelector('[data-field="tag"]'); + expect(tagDisplay!.textContent).toContain('(none)'); + }); + }); + + it('Save tag calls updateCommit', async () => { + (updateCommit as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mockCommit, tag: 'new-tag' }); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const tagDisplay = container.querySelector('[data-field="tag"]'); + expect(tagDisplay!.querySelector('button')).toBeTruthy(); + }); + + // Click Edit on the tag container + const tagContainer = container.querySelector('[data-field="tag"]')!; + (tagContainer.querySelector('button') as HTMLElement).click(); + + // Type new tag value and click Save + const input = tagContainer.querySelector('input') as HTMLInputElement; + input.value = 'new-tag'; + + const saveBtn = Array.from(tagContainer.querySelectorAll('button')) + .find(b => b.textContent === 'Save') as HTMLElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateCommit).toHaveBeenCalledWith('nts', '100', { tag: 'new-tag' }); + }); + }); + + it('renders Previous button when previous_commit exists', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const navContainer = container.querySelector('.commit-nav'); + expect(navContainer).toBeTruthy(); + const prevBtn = Array.from(navContainer!.querySelectorAll('button')) + .find(b => b.textContent?.includes('Previous')); + expect(prevBtn).toBeTruthy(); + }); + }); + + it('renders Next button when next_commit exists', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const navContainer = container.querySelector('.commit-nav'); + const nextBtn = Array.from(navContainer!.querySelectorAll('button')) + .find(b => b.textContent?.includes('Next')); + expect(nextBtn).toBeTruthy(); + }); + }); + + it('does not render Previous/Next when neighbors are null', async () => { + (getCommit as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockCommit, + previous_commit: null, + next_commit: null, + }); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const navContainer = container.querySelector('.commit-nav'); + expect(navContainer).toBeTruthy(); + expect(navContainer!.querySelectorAll('button')).toHaveLength(0); + }); + }); + + it('clicking Previous navigates to previous commit', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const navContainer = container.querySelector('.commit-nav'); + expect(navContainer!.querySelectorAll('button').length).toBeGreaterThan(0); + }); + + const prevBtn = Array.from(container.querySelector('.commit-nav')!.querySelectorAll('button')) + .find(b => b.textContent?.includes('Previous')) as HTMLElement; + prevBtn.click(); + + expect(navigate).toHaveBeenCalledWith(expect.stringContaining('/commits/99')); + }); + + it('renders runs summary', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + // 3 runs across 2 machines + expect(container.textContent).toContain('3 runs across 2 machines'); + }); + }); + + it('renders runs table with Machine, Run UUID, Submitted columns', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + expect(headers).toContain('Machine'); + expect(headers).toContain('Run UUID'); + expect(headers).toContain('Submitted'); + }); + }); + + it('machine and run links use suite-scoped hrefs', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const machineLink = container.querySelector('a[href*="/machines/"]') as HTMLAnchorElement; + expect(machineLink).toBeTruthy(); + expect(machineLink.href).toContain('/v5/nts/machines/'); + + const runLink = container.querySelector('a[href*="/runs/"]') as HTMLAnchorElement; + expect(runLink).toBeTruthy(); + expect(runLink.href).toContain('/v5/nts/runs/'); + }); + }); + + it('machine filter filters runs by machine name after 200ms debounce', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'gcc'; + filterInput.dispatchEvent(new Event('input')); + + // Advance past debounce + vi.advanceTimersByTime(200); + + await vi.waitFor(() => { + // Should only show gcc-arm runs (1 of 3) + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(1); + }); + }); + + it('filtered summary shows "X of Y runs across A of B machines"', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'gcc'; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + + await vi.waitFor(() => { + expect(container.textContent).toContain('1 of 3 run'); + expect(container.textContent).toContain('1 of 2 machine'); + }); + }); + + it('shows "No runs matching filter." when filter matches nothing', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'nonexistent'; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + + await vi.waitFor(() => { + expect(container.textContent).toContain('No runs matching filter.'); + }); + }); + + it('regex filter via re: prefix filters machines', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 're:clang.*x86'; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBeGreaterThan(0); + for (const row of rows) { + expect(row.textContent).toMatch(/clang.*x86/i); + } + }); + }); + + it('invalid regex shows filter-invalid class on input', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 're:invalid['; + filterInput.dispatchEvent(new Event('input')); + + expect(filterInput.classList.contains('filter-invalid')).toBe(true); + + filterInput.value = 're:valid.*'; + filterInput.dispatchEvent(new Event('input')); + + expect(filterInput.classList.contains('filter-invalid')).toBe(false); + }); + + it('shows regex badge when typing re: prefix', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 're:clang'; + filterInput.dispatchEvent(new Event('input')); + + const badge = container.querySelector('.filter-regex-badge'); + expect(badge).toBeTruthy(); + expect(badge!.textContent).toBe('regex'); + expect(badge!.classList.contains('filter-regex-badge-invalid')).toBe(false); + }); + + it('regex badge shows invalid state on bad regex', async () => { + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 're:invalid['; + filterInput.dispatchEvent(new Event('input')); + + const badge = container.querySelector('.filter-regex-badge'); + expect(badge).toBeTruthy(); + expect(badge!.classList.contains('filter-regex-badge-invalid')).toBe(true); + }); + + it('shows error banner on initial load failure', async () => { + (getCommit as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Not found')); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load commit'); + }); + }); + + it('unmount aborts without error', () => { + (getCommit as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + (getRunsByCommit as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + + commitDetailPage.mount(container, { testsuite: 'nts', value: '100' }); + expect(() => commitDetailPage.unmount!()).not.toThrow(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/compare.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/compare.test.ts new file mode 100644 index 000000000..4a12f3516 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/compare.test.ts @@ -0,0 +1,527 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module before importing compare page +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getFields: vi.fn(), + getSamples: vi.fn(), + getRuns: vi.fn(), + getMachines: vi.fn(), + getCommits: vi.fn(), + getTestSuiteInfoCached: vi.fn(), + getProfilesForRun: vi.fn(), + getRegressions: vi.fn(), + createRegression: vi.fn(), + addRegressionIndicators: vi.fn(), + getToken: vi.fn(), + authErrorMessage: vi.fn((err: unknown) => `Auth error: ${err}`), + }; +}); + +vi.mock('../../router', () => ({ + navigate: vi.fn(), + getTestsuites: vi.fn(() => ['nts']), + getBasePath: vi.fn(() => '/v5'), + getUrlBase: vi.fn(() => ''), +})); + +// Mock Plotly (loaded via CDN, not available in tests). +// The real Plotly adds an .on() method to the container div as a side effect +// of newPlot/react. Our mock replicates this so chart.ts event wiring works. +function addPlotlyMethods(el: HTMLElement): HTMLElement { + (el as unknown as Record<string, unknown>).on = vi.fn(); + return el; +} +const plotlyMock = { + newPlot: vi.fn().mockImplementation((container: HTMLElement) => { + addPlotlyMethods(container); + return Promise.resolve(container); + }), + react: vi.fn().mockImplementation((container: HTMLElement) => { + addPlotlyMethods(container); + return Promise.resolve(container); + }), + purge: vi.fn(), + Fx: { + hover: vi.fn(), + unhover: vi.fn(), + }, +}; +(globalThis as unknown as Record<string, unknown>).Plotly = plotlyMock; + +import { + getFields, getSamples, getMachines, getRuns, getCommits, + getTestSuiteInfoCached, getProfilesForRun, + getToken, createRegression, addRegressionIndicators, getRegressions, +} from '../../api'; +import { getTestsuites } from '../../router'; +import { comparePage } from '../../pages/compare'; +import type { FieldInfo, SampleInfo, RegressionDetail } from '../../types'; + +const mockFields: FieldInfo[] = [ + { name: 'exec_time', type: 'real', display_name: 'Execution Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, +]; + +const mockSamples: SampleInfo[] = [ + { test: 'test-A', metrics: { exec_time: 10.0 } }, + { test: 'test-B', metrics: { exec_time: 20.0 } }, +]; + +const savedLocation = window.location; + +function setupMocks(): void { + (getTestsuites as ReturnType<typeof vi.fn>).mockReturnValue(['nts']); + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(mockFields); + (getSamples as ReturnType<typeof vi.fn>).mockResolvedValue(mockSamples); + (getMachines as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [] }); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (getCommits as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (getTestSuiteInfoCached as ReturnType<typeof vi.fn>).mockResolvedValue({ metrics: mockFields, commit_fields: [], machine_fields: [] }); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ items: [], next: null, previous: null }); +} + +describe('comparePage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + setupMocks(); + + // Reset URL state — set suite_a so fetchSideData is triggered on mount + delete (window as unknown as Record<string, unknown>).location; + (window as unknown as Record<string, unknown>).location = { + ...savedLocation, + search: '?suite_a=nts', + pathname: '/v5/compare', + }; + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + }); + + afterEach(() => { + comparePage.unmount?.(); + (window as unknown as Record<string, unknown>).location = savedLocation; + }); + + it('mount loads fields for side with suite in URL', async () => { + comparePage.mount(container, { testsuite: '' }); + + // fetchSideData is called for side A because suite_a=nts is in the URL + await vi.waitFor(() => { + expect(getFields).toHaveBeenCalledWith('nts'); + }); + }); + + it('shows error when fields fetch fails', async () => { + (getFields as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error')); + + comparePage.mount(container, { testsuite: '' }); + + // The error from fetchSideData is silently ignored (controls stay disabled). + // The page itself should still render without crashing. + await vi.waitFor(() => { + expect(container.querySelector('.controls-panel')).toBeTruthy(); + }); + }); + + it('renders selection panel after mount', async () => { + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + // Selection panel should be rendered + expect(container.querySelector('.controls-panel')).toBeTruthy(); + }); + }); + + it('unmount cleans up without errors', async () => { + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.controls-panel')).toBeTruthy(); + }); + + // Should not throw + expect(() => comparePage.unmount!()).not.toThrow(); + }); + + it('unmount is safe to call even before mount completes', () => { + comparePage.mount(container, { testsuite: '' }); + // Unmount immediately before async operations complete + expect(() => comparePage.unmount!()).not.toThrow(); + }); + + describe('regression feedback links', () => { + const mockCreatedRegression: RegressionDetail = { + uuid: 'aaaa-bbbb-cccc-dddd', + title: 'My Regression', + bug: null, + notes: null, + state: 'detected', + commit: null, + indicators: [], + }; + + beforeEach(() => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue('test-token'); + }); + + it('"Create New" produces a link with title as text', async () => { + (createRegression as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreatedRegression); + + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.add-to-regression-panel')).toBeTruthy(); + }); + + // Type a title and click Create + const titleInput = container.querySelector('.create-new-tab input') as HTMLInputElement; + titleInput.value = 'My Regression'; + const createBtn = container.querySelector('.create-new-tab .compare-btn') as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + const feedbackP = container.querySelector('.regression-feedback-ok'); + expect(feedbackP).toBeTruthy(); + const link = feedbackP!.querySelector('a'); + expect(link).toBeTruthy(); + expect(link!.textContent).toBe('My Regression'); + expect(link!.href).toContain('/nts/regressions/aaaa-bbbb-cccc-dddd'); + }); + }); + + it('"Create New" without title uses short UUID as link text', async () => { + const noTitleRegression: RegressionDetail = { ...mockCreatedRegression, title: null }; + (createRegression as ReturnType<typeof vi.fn>).mockResolvedValue(noTitleRegression); + + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.add-to-regression-panel')).toBeTruthy(); + }); + + const createBtn = container.querySelector('.create-new-tab .compare-btn') as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + const feedbackP = container.querySelector('.regression-feedback-ok'); + expect(feedbackP).toBeTruthy(); + const link = feedbackP!.querySelector('a'); + expect(link).toBeTruthy(); + expect(link!.textContent).toBe('aaaa-bbb'); + }); + }); + + it('"Create New" clears title input after success', async () => { + (createRegression as ReturnType<typeof vi.fn>).mockResolvedValue(mockCreatedRegression); + + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.add-to-regression-panel')).toBeTruthy(); + }); + + const titleInput = container.querySelector('.create-new-tab input') as HTMLInputElement; + titleInput.value = 'Some Title'; + const createBtn = container.querySelector('.create-new-tab .compare-btn') as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + expect(container.querySelector('.regression-feedback-ok')).toBeTruthy(); + expect(titleInput.value).toBe(''); + }); + }); + + it('"Add to Existing" produces a link to the regression', async () => { + const existingRegression: RegressionDetail = { + uuid: 'xxxx-yyyy-zzzz-wwww', + title: 'Existing Reg', + bug: null, + notes: null, + state: 'detected', + commit: null, + indicators: [], + }; + (addRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue(existingRegression); + + // Set up both sides with runs and a metric so auto-compare fires + // and populates lastRows (needed for indicators to be non-empty) + const samplesA: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 10.0 } }]; + const samplesB: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 12.0 } }]; + (getSamples as ReturnType<typeof vi.fn>).mockImplementation( + (_ts: string, uuid: string) => Promise.resolve(uuid === 'run-a1' ? samplesA : samplesB), + ); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([ + { uuid: 'run-a1', submitted_at: '2025-01-01T00:00:00Z', machine: 'clang-x86', commit: 'abc123' }, + ]); + + delete (window as unknown as Record<string, unknown>).location; + (window as unknown as Record<string, unknown>).location = { + ...savedLocation, + search: '?suite_a=nts&suite_b=nts&machine_a=clang-x86&commit_a=abc123&runs_a=run-a1&machine_b=clang-x86&commit_b=def456&runs_b=run-b1&metric=exec_time', + pathname: '/v5/compare', + }; + + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ uuid: 'xxxx-yyyy-zzzz-wwww', title: 'Existing Reg', state: 'detected', machine_count: 1, test_count: 1 }], + next: null, + previous: null, + }); + + comparePage.mount(container, { testsuite: '' }); + + // Wait for the comparison to complete (table rows appear) + await vi.waitFor(() => { + expect(container.querySelector('.comparison-table .col-test')).toBeTruthy(); + }); + + // Switch to "Add to Existing" tab + const tabs = container.querySelectorAll('.tab-btn'); + (tabs[1] as HTMLButtonElement).click(); + + // Wait for combobox to be lazily created after tab click + await vi.waitFor(() => { + expect(container.querySelector('.add-existing-tab .combobox-input')).toBeTruthy(); + }); + + // Focus combobox input and wait for selectable items (role=option) + const comboInput = container.querySelector('.add-existing-tab .combobox-input') as HTMLInputElement; + comboInput.dispatchEvent(new Event('focus')); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('.add-existing-tab .combobox-item[role="option"]'); + expect(rows.length).toBeGreaterThan(0); + }); + + // Click the first combobox item to select it + const resultRow = container.querySelector('.add-existing-tab .combobox-item[role="option"]') as HTMLElement; + resultRow.click(); + + // Click "Add Indicators" + const addBtn = container.querySelector('.add-existing-tab .compare-btn') as HTMLButtonElement; + addBtn.click(); + + await vi.waitFor(() => { + const feedbackP = container.querySelector('.add-existing-tab .regression-feedback-ok'); + expect(feedbackP).toBeTruthy(); + const link = feedbackP!.querySelector('a'); + expect(link).toBeTruthy(); + expect(link!.textContent).toBe('Existing Reg'); + expect(link!.href).toContain('/nts/regressions/xxxx-yyyy-zzzz-wwww'); + }); + }); + + it('"Create New" failure shows error and preserves title input', async () => { + (createRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('server down')); + + comparePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(container.querySelector('.add-to-regression-panel')).toBeTruthy(); + }); + + const titleInput = container.querySelector('.create-new-tab input') as HTMLInputElement; + titleInput.value = 'Keep This Title'; + const createBtn = container.querySelector('.create-new-tab .compare-btn') as HTMLButtonElement; + createBtn.click(); + + await vi.waitFor(() => { + const feedback = container.querySelector('.create-new-tab .error-banner'); + expect(feedback).toBeTruthy(); + // No link in error feedback + expect(feedback!.querySelector('a')).toBeNull(); + // Title input is NOT cleared on failure + expect(titleInput.value).toBe('Keep This Title'); + }); + }); + + it('"Add to Existing" failure shows error without link', async () => { + (addRegressionIndicators as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('server error')); + + // Set up both sides with runs and a metric so comparison produces indicators + const samplesA: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 10.0 } }]; + const samplesB: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 12.0 } }]; + (getSamples as ReturnType<typeof vi.fn>).mockImplementation( + (_ts: string, uuid: string) => Promise.resolve(uuid === 'run-a1' ? samplesA : samplesB), + ); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([ + { uuid: 'run-a1', submitted_at: '2025-01-01T00:00:00Z', machine: 'clang-x86', commit: 'abc123' }, + ]); + + delete (window as unknown as Record<string, unknown>).location; + (window as unknown as Record<string, unknown>).location = { + ...savedLocation, + search: '?suite_a=nts&suite_b=nts&machine_a=clang-x86&commit_a=abc123&runs_a=run-a1&machine_b=clang-x86&commit_b=def456&runs_b=run-b1&metric=exec_time', + pathname: '/v5/compare', + }; + + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ uuid: 'xxxx-yyyy-zzzz-wwww', title: 'Existing Reg', state: 'detected', machine_count: 1, test_count: 1 }], + next: null, + previous: null, + }); + + comparePage.mount(container, { testsuite: '' }); + + // Switch to "Add to Existing" tab + await vi.waitFor(() => { + const tabs = container.querySelectorAll('.tab-btn'); + expect(tabs.length).toBeGreaterThan(1); + }); + const tabs = container.querySelectorAll('.tab-btn'); + (tabs[1] as HTMLButtonElement).click(); + + // Wait for combobox to be created + await vi.waitFor(() => { + expect(container.querySelector('.add-existing-tab .combobox-input')).toBeTruthy(); + }); + + const comboInput = container.querySelector('.add-existing-tab .combobox-input') as HTMLInputElement; + comboInput.dispatchEvent(new Event('focus')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.add-existing-tab .combobox-item[role="option"]').length).toBeGreaterThan(0); + }); + + // Select a regression via combobox + (container.querySelector('.add-existing-tab .combobox-item[role="option"]') as HTMLElement).click(); + + // Click "Add Indicators" + const addBtn = container.querySelector('.add-existing-tab .compare-btn') as HTMLButtonElement; + addBtn.click(); + + await vi.waitFor(() => { + const feedback = container.querySelector('.add-existing-tab .error-banner'); + expect(feedback).toBeTruthy(); + expect(feedback!.querySelector('a')).toBeNull(); + }); + }); + + it('"Add to Existing" with zero indicators shows error without calling API', async () => { + // Mock regressions so the combobox has items to select + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ uuid: 'xxxx-yyyy-zzzz-wwww', title: 'Some Reg', state: 'detected', machine_count: 1, test_count: 1 }], + next: null, + previous: null, + }); + + // Mount with suite but NO machine/runs so indicators will be empty + comparePage.mount(container, { testsuite: '' }); + + // Switch to "Add to Existing" tab + await vi.waitFor(() => { + const tabs = container.querySelectorAll('.tab-btn'); + expect(tabs.length).toBeGreaterThan(1); + }); + const tabs = container.querySelectorAll('.tab-btn'); + (tabs[1] as HTMLButtonElement).click(); + + // Wait for combobox to be created + await vi.waitFor(() => { + expect(container.querySelector('.add-existing-tab .combobox-input')).toBeTruthy(); + }); + + const comboInput = container.querySelector('.add-existing-tab .combobox-input') as HTMLInputElement; + comboInput.dispatchEvent(new Event('focus')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.add-existing-tab .combobox-item[role="option"]').length).toBeGreaterThan(0); + }); + + (container.querySelector('.add-existing-tab .combobox-item[role="option"]') as HTMLElement).click(); + + // Click "Add Indicators" — no comparison data, so indicators.length === 0 + const addBtn = container.querySelector('.add-existing-tab .compare-btn') as HTMLButtonElement; + addBtn.click(); + + await vi.waitFor(() => { + const feedback = container.querySelector('.add-existing-tab .error-banner'); + expect(feedback).toBeTruthy(); + expect(feedback!.querySelector('a')).toBeNull(); + }); + + // API should NOT have been called + expect(addRegressionIndicators).not.toHaveBeenCalled(); + }); + + it('"Add to Existing" with title: null falls back to short UUID', async () => { + const nullTitleRegression: RegressionDetail = { + uuid: 'xxxx-yyyy-zzzz-wwww', + title: null, + bug: null, + notes: null, + state: 'detected', + commit: null, + indicators: [], + }; + (addRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue(nullTitleRegression); + + // Set up both sides with runs and a metric so comparison produces indicators + const samplesA: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 10.0 } }]; + const samplesB: SampleInfo[] = [{ test: 'test-A', metrics: { exec_time: 12.0 } }]; + (getSamples as ReturnType<typeof vi.fn>).mockImplementation( + (_ts: string, uuid: string) => Promise.resolve(uuid === 'run-a1' ? samplesA : samplesB), + ); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([ + { uuid: 'run-a1', submitted_at: '2025-01-01T00:00:00Z', machine: 'clang-x86', commit: 'abc123' }, + ]); + + delete (window as unknown as Record<string, unknown>).location; + (window as unknown as Record<string, unknown>).location = { + ...savedLocation, + search: '?suite_a=nts&suite_b=nts&machine_a=clang-x86&commit_a=abc123&runs_a=run-a1&machine_b=clang-x86&commit_b=def456&runs_b=run-b1&metric=exec_time', + pathname: '/v5/compare', + }; + + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ uuid: 'xxxx-yyyy-zzzz-wwww', title: null, state: 'detected', machine_count: 1, test_count: 1 }], + next: null, + previous: null, + }); + + comparePage.mount(container, { testsuite: '' }); + + // Wait for the comparison to complete (table rows appear) + await vi.waitFor(() => { + expect(container.querySelector('.comparison-table .col-test')).toBeTruthy(); + }); + + // Switch to "Add to Existing" tab + const tabs = container.querySelectorAll('.tab-btn'); + (tabs[1] as HTMLButtonElement).click(); + + // Wait for combobox to be lazily created after tab click + await vi.waitFor(() => { + expect(container.querySelector('.add-existing-tab .combobox-input')).toBeTruthy(); + }); + + // Focus combobox input and wait for selectable items + const comboInput = container.querySelector('.add-existing-tab .combobox-input') as HTMLInputElement; + comboInput.dispatchEvent(new Event('focus')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.add-existing-tab .combobox-item[role="option"]').length).toBeGreaterThan(0); + }); + + // Select the regression + (container.querySelector('.add-existing-tab .combobox-item[role="option"]') as HTMLElement).click(); + + // Click "Add Indicators" + const addBtn = container.querySelector('.add-existing-tab .compare-btn') as HTMLButtonElement; + addBtn.click(); + + await vi.waitFor(() => { + const feedbackP = container.querySelector('.add-existing-tab .regression-feedback-ok'); + expect(feedbackP).toBeTruthy(); + const link = feedbackP!.querySelector('a'); + expect(link).toBeTruthy(); + // Falls back to first 8 chars of UUID (same as "Create New" null-title test) + expect(link!.textContent).toBe('xxxx-yyy'); + }); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/baselines.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/baselines.test.ts new file mode 100644 index 000000000..242f4e537 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/baselines.test.ts @@ -0,0 +1,383 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMachineComboHandle = { destroy: vi.fn(), clear: vi.fn() }; +vi.mock('../../../components/machine-combobox', () => ({ + renderMachineCombobox: vi.fn(() => mockMachineComboHandle), +})); + +const mockCommitPickerHandle = { + element: document.createElement('div'), + input: document.createElement('input'), + destroy: vi.fn(), +}; +vi.mock('../../../components/commit-combobox', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../components/commit-combobox')>(); + return { + ...actual, + createCommitPicker: vi.fn(() => mockCommitPickerHandle), + }; +}); + +import { createBaselinePanel, type BaselinePanelCallbacks } from '../../../pages/graph/baselines'; +import { renderMachineCombobox } from '../../../components/machine-combobox'; +import { createCommitPicker } from '../../../components/commit-combobox'; +import type { BaselineRef } from '../../../pages/graph/state'; + +function makeBaseline(suite = 'nts', machine = 'm1', commit = 'abc'): BaselineRef { + return { suite, machine, commit }; +} + +function makeCallbacks(overrides?: Partial<BaselinePanelCallbacks>): BaselinePanelCallbacks { + return { + onBaselineAdd: vi.fn(), + onBaselineRemove: vi.fn(), + getCommitFields: vi.fn(() => []), + getBaselineCommits: vi.fn().mockResolvedValue([]), + ...overrides, + }; +} + +describe('createBaselinePanel', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockCommitPickerHandle.input = document.createElement('input'); + mockCommitPickerHandle.element = document.createElement('div'); + }); + + // ---- DOM structure and initial state ---- + + it('renders panel with label, add button visible, and form hidden', () => { + const handle = createBaselinePanel([], new Map(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + + expect(panel.classList.contains('baseline-panel')).toBe(true); + expect(panel.querySelector('label')?.textContent).toBe('Baselines'); + + const addBtn = panel.querySelector('.baseline-add-btn') as HTMLElement; + expect(addBtn).not.toBeNull(); + expect(addBtn.style.display).not.toBe('none'); + + const form = panel.querySelector('.baseline-form') as HTMLElement; + expect(form).not.toBeNull(); + expect(form.style.display).toBe('none'); + }); + + it('renders initial baseline chips with display values from displayMap', () => { + const bl = makeBaseline('nts', 'm1', 'abc'); + const displayMap = new Map([['abc', 'v1.0']]); + const handle = createBaselinePanel([bl], displayMap, ['nts'], makeCallbacks()); + const panel = handle.getElement(); + + const chips = panel.querySelectorAll('.baseline-chip'); + expect(chips.length).toBe(1); + expect(chips[0].textContent).toContain('nts/m1/v1.0'); + }); + + it('renders suite dropdown inside form with all suites', () => { + const handle = createBaselinePanel([], new Map(), ['nts', 'other'], makeCallbacks()); + const panel = handle.getElement(); + + // Show the form + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + expect(suiteSelect).not.toBeNull(); + const values = [...suiteSelect.options].map(o => o.value); + expect(values).toContain(''); + expect(values).toContain('nts'); + expect(values).toContain('other'); + }); + + // ---- Add button toggle ---- + + it('clicking add button shows form and hides the button', () => { + const handle = createBaselinePanel([], new Map(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + + const addBtn = panel.querySelector('.baseline-add-btn') as HTMLElement; + const form = panel.querySelector('.baseline-form') as HTMLElement; + addBtn.click(); + + expect(form.style.display).toBe(''); + expect(addBtn.style.display).toBe('none'); + }); + + // ---- Cascading dropdowns ---- + + it('suite change creates machine combobox for selected suite', () => { + const handle = createBaselinePanel([], new Map(), ['nts', 'other'], makeCallbacks()); + const panel = handle.getElement(); + + // Show form and select suite + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + expect(renderMachineCombobox).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ testsuite: 'nts' }), + ); + }); + + it('suite change to empty clears machine combobox and commit picker', () => { + const handle = createBaselinePanel([], new Map(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + + // Show form, select suite to create machine combobox + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + expect(renderMachineCombobox).toHaveBeenCalledTimes(1); + + // Change suite to empty + mockMachineComboHandle.destroy.mockClear(); + suiteSelect.value = ''; + suiteSelect.dispatchEvent(new Event('change')); + expect(mockMachineComboHandle.destroy).toHaveBeenCalled(); + }); + + it('machine selection triggers loadCommits and creates commit picker', async () => { + const commits = [ + { commit: 'abc', ordinal: 1, tag: null, fields: {} }, + { commit: 'def', ordinal: 2, tag: null, fields: {} }, + ]; + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockResolvedValue(commits), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + // Show form, select suite + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + // Capture and trigger machine onSelect + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + await vi.waitFor(() => { + expect(createCommitPicker).toHaveBeenCalled(); + }); + + expect(callbacks.getBaselineCommits).toHaveBeenCalledWith('nts', 'm1', expect.any(AbortSignal)); + }); + + it('changing machine aborts previous commit fetch and starts new one', async () => { + const deferred1 = new Promise<unknown[]>(() => {}); + + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn() + .mockReturnValueOnce(deferred1) + .mockResolvedValueOnce([{ commit: 'xyz', ordinal: 1, tag: null, fields: {} }]), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + // First machine selection (will block on deferred1) + const machineCall1 = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall1[1].onSelect('m1'); + + // Capture the signal from the first call + const firstSignal = (callbacks.getBaselineCommits as ReturnType<typeof vi.fn>).mock.calls[0][2] as AbortSignal; + + // Destroy and recreate for second machine (suite change triggers clearMachine) + // In practice, the loadCommits function aborts the previous fetch before starting + // a new one when onSelect is called again. But the machine combobox is recreated + // on suite change, not on successive machine selections. The abort happens inside + // loadCommits which is called on each machine select. + machineCall1[1].onSelect('m2'); + + // First signal should be aborted + expect(firstSignal.aborted).toBe(true); + + // Let the second call complete + await vi.waitFor(() => { + expect(createCommitPicker).toHaveBeenCalled(); + }); + }); + + it('commit selection calls onBaselineAdd and resets picker input', async () => { + const commits = [{ commit: 'abc', ordinal: 1, tag: null, fields: {} }]; + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockResolvedValue(commits), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + // Set up cascading: show form -> select suite -> select machine + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + await vi.waitFor(() => { + expect(createCommitPicker).toHaveBeenCalled(); + }); + + // Capture and trigger commit onSelect + const pickerOpts = vi.mocked(createCommitPicker).mock.calls[0][0]; + mockCommitPickerHandle.input.value = 'abc'; + pickerOpts.onSelect('abc'); + + expect(callbacks.onBaselineAdd).toHaveBeenCalledWith({ + suite: 'nts', + machine: 'm1', + commit: 'abc', + }); + expect(mockCommitPickerHandle.input.value).toBe(''); + }); + + // ---- Error handling ---- + + it('loadCommits shows error text when getBaselineCommits rejects', async () => { + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockRejectedValue(new Error('Network fail')), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + await vi.waitFor(() => { + const errorEl = panel.querySelector('.error-text'); + expect(errorEl).not.toBeNull(); + expect(errorEl!.textContent).toBe('Failed to load commits'); + }); + }); + + it('loadCommits silently ignores AbortError', async () => { + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockRejectedValue(new DOMException('Aborted', 'AbortError')), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + // Give the async rejection time to propagate + await vi.waitFor(() => { + expect(callbacks.getBaselineCommits).toHaveBeenCalled(); + }); + + expect(panel.querySelector('.error-text')).toBeNull(); + }); + + // ---- Chip management ---- + + it('chip remove button calls onBaselineRemove', () => { + const bl1 = makeBaseline('nts', 'm1', 'abc'); + const bl2 = makeBaseline('nts', 'm2', 'def'); + const callbacks = makeCallbacks(); + const handle = createBaselinePanel([bl1, bl2], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + const removeButtons = panel.querySelectorAll('.chip-remove'); + expect(removeButtons.length).toBe(2); + (removeButtons[0] as HTMLButtonElement).click(); + expect(callbacks.onBaselineRemove).toHaveBeenCalledWith(bl1); + }); + + it('updateChips replaces chips with new baselines and display values', () => { + const handle = createBaselinePanel([], new Map(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + expect(panel.querySelectorAll('.baseline-chip').length).toBe(0); + + const bl = makeBaseline('nts', 'm1', 'abc'); + handle.updateChips([bl], new Map([['abc', 'v2.0']])); + + const chips = panel.querySelectorAll('.baseline-chip'); + expect(chips.length).toBe(1); + expect(chips[0].textContent).toContain('nts/m1/v2.0'); + }); + + // ---- Handle methods ---- + + it('reset hides form, shows add button, clears state, aborts fetch', async () => { + const blockingPromise = new Promise<unknown[]>(() => {}); + + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockReturnValue(blockingPromise), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + // Show form, select suite, select machine (starts pending fetch) + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + // Capture abort signal before reset + const signal = (callbacks.getBaselineCommits as ReturnType<typeof vi.fn>).mock.calls[0][2] as AbortSignal; + + handle.reset(); + + const form = panel.querySelector('.baseline-form') as HTMLElement; + const addBtn = panel.querySelector('.baseline-add-btn') as HTMLElement; + expect(form.style.display).toBe('none'); + expect(addBtn.style.display).toBe(''); + expect(suiteSelect.value).toBe(''); + expect(signal.aborted).toBe(true); + expect(mockMachineComboHandle.destroy).toHaveBeenCalled(); + }); + + it('destroy cleans up machine handle, commit picker, and abort controller', async () => { + const commits = [{ commit: 'abc', ordinal: 1, tag: null, fields: {} }]; + const callbacks = makeCallbacks({ + getBaselineCommits: vi.fn().mockResolvedValue(commits), + }); + const handle = createBaselinePanel([], new Map(), ['nts'], callbacks); + const panel = handle.getElement(); + + // Set up all sub-components + (panel.querySelector('.baseline-add-btn') as HTMLElement).click(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + const machineCall = vi.mocked(renderMachineCombobox).mock.calls[0]; + machineCall[1].onSelect('m1'); + + await vi.waitFor(() => { + expect(createCommitPicker).toHaveBeenCalled(); + }); + + mockMachineComboHandle.destroy.mockClear(); + mockCommitPickerHandle.destroy.mockClear(); + + handle.destroy(); + expect(mockMachineComboHandle.destroy).toHaveBeenCalled(); + expect(mockCommitPickerHandle.destroy).toHaveBeenCalled(); + }); + + it('destroy is safe when no sub-components exist', () => { + const handle = createBaselinePanel([], new Map(), ['nts'], makeCallbacks()); + expect(() => handle.destroy()).not.toThrow(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/controls.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/controls.test.ts new file mode 100644 index 000000000..b04eba4e3 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/controls.test.ts @@ -0,0 +1,334 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const mockMachineComboHandle = { destroy: vi.fn(), clear: vi.fn() }; +vi.mock('../../../components/machine-combobox', () => ({ + renderMachineCombobox: vi.fn(() => mockMachineComboHandle), +})); + +vi.mock('../../../components/metric-selector', () => ({ + filterMetricFields: vi.fn((fields: unknown[]) => fields), + renderMetricSelector: vi.fn((container: HTMLElement, fields: unknown[], onChange: (m: string) => void, initial?: string) => { + const group = document.createElement('div'); + group.className = 'control-group'; + const select = document.createElement('select'); + select.className = 'metric-select'; + for (const f of fields as Array<{ name: string }>) { + const opt = document.createElement('option'); + opt.value = f.name; + opt.textContent = f.name; + select.append(opt); + } + if (initial) select.value = initial; + select.addEventListener('change', () => onChange(select.value)); + group.append(select); + container.append(group); + return select.value; + }), + renderEmptyMetricSelector: vi.fn((container: HTMLElement) => { + const div = document.createElement('div'); + div.className = 'metric-placeholder'; + container.append(div); + }), +})); + +import { createControls, type ControlsCallbacks } from '../../../pages/graph/controls'; +import { renderMachineCombobox } from '../../../components/machine-combobox'; +import { filterMetricFields, renderMetricSelector, renderEmptyMetricSelector } from '../../../components/metric-selector'; +import type { GraphState } from '../../../pages/graph/state'; + +function makeState(overrides?: Partial<GraphState>): GraphState { + return { + suite: 'nts', + machines: ['m1'], + metric: 'exec_time', + testFilter: '', + runAgg: 'median', + sampleAgg: 'median', + baselines: [], + regressionMode: 'off', + ...overrides, + }; +} + +function makeCallbacks(): { [K in keyof ControlsCallbacks]: ReturnType<typeof vi.fn> } { + return { + onSuiteChange: vi.fn(), + onMachineAdd: vi.fn(), + onMachineRemove: vi.fn(), + onMetricChange: vi.fn(), + onFilterChange: vi.fn(), + onRunAggChange: vi.fn(), + onSampleAggChange: vi.fn(), + onRegressionModeChange: vi.fn(), + }; +} + +function findSelectByOptions(panel: HTMLElement, ...optionValues: string[]): HTMLSelectElement | undefined { + const selects = panel.querySelectorAll<HTMLSelectElement>('select'); + return [...selects].find(s => + optionValues.every(v => [...s.options].some(o => o.value === v)), + ); +} + +describe('createControls', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ---- DOM structure ---- + + it('renders suite dropdown with placeholder and all suites, pre-selecting current', () => { + const handle = createControls(makeState({ suite: 'nts' }), ['nts', 'other'], makeCallbacks()); + const panel = handle.getElement(); + + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + expect(suiteSelect).not.toBeNull(); + expect(suiteSelect.options[0].value).toBe(''); + expect(suiteSelect.options[0].textContent).toBe('-- Select suite --'); + expect([...suiteSelect.options].map(o => o.value)).toContain('nts'); + expect([...suiteSelect.options].map(o => o.value)).toContain('other'); + expect(suiteSelect.value).toBe('nts'); + }); + + it('renders machine combobox for current suite', () => { + createControls(makeState({ suite: 'nts' }), ['nts'], makeCallbacks()); + expect(renderMachineCombobox).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ testsuite: 'nts' }), + ); + }); + + it('renders initial machine chips with correct symbol chars', () => { + const handle = createControls(makeState({ machines: ['m1', 'm2'] }), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + const chips = panel.querySelectorAll('.machine-chip'); + expect(chips.length).toBe(2); + + const symbols = panel.querySelectorAll('.chip-symbol'); + expect(symbols[0].textContent).toBe('●'); + expect(symbols[1].textContent).toBe('▲'); + }); + + it('renders agg dropdowns, filter input, and regression toggle with initial values', () => { + const handle = createControls( + makeState({ runAgg: 'mean', sampleAgg: 'max', testFilter: 'foo', regressionMode: 'active' }), + ['nts'], + makeCallbacks(), + ); + const panel = handle.getElement(); + + const runAggSelect = findSelectByOptions(panel, 'median', 'mean', 'min', 'max'); + expect(runAggSelect).toBeDefined(); + // There are two agg selects — find the one with value 'mean' vs 'max' + const aggSelects = [...panel.querySelectorAll<HTMLSelectElement>('select')] + .filter(s => [...s.options].some(o => o.value === 'median') && [...s.options].some(o => o.value === 'max') && s.options.length === 4); + expect(aggSelects.length).toBe(2); + expect(aggSelects[0].value).toBe('mean'); + expect(aggSelects[1].value).toBe('max'); + + const filterInput = panel.querySelector('.test-filter-input') as HTMLInputElement; + expect(filterInput).not.toBeNull(); + expect(filterInput.value).toBe('foo'); + + const regSelect = findSelectByOptions(panel, 'off', 'active', 'all'); + expect(regSelect).toBeDefined(); + expect(regSelect!.value).toBe('active'); + }); + + // ---- Callbacks ---- + + it('suite change fires onSuiteChange', () => { + const callbacks = makeCallbacks(); + const handle = createControls(makeState(), ['nts', 'other'], callbacks); + const panel = handle.getElement(); + + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'other'; + suiteSelect.dispatchEvent(new Event('change')); + expect(callbacks.onSuiteChange).toHaveBeenCalledWith('other'); + }); + + it('machine combobox onSelect fires onMachineAdd and clears combobox', () => { + const callbacks = makeCallbacks(); + createControls(makeState(), ['nts'], callbacks); + + const call = vi.mocked(renderMachineCombobox).mock.calls[0]; + const onSelect = call[1].onSelect; + onSelect('new-machine'); + + expect(callbacks.onMachineAdd).toHaveBeenCalledWith('new-machine'); + expect(mockMachineComboHandle.clear).toHaveBeenCalled(); + }); + + it('agg changes fire onRunAggChange / onSampleAggChange', () => { + const callbacks = makeCallbacks(); + const handle = createControls(makeState(), ['nts'], callbacks); + const panel = handle.getElement(); + + const aggSelects = [...panel.querySelectorAll<HTMLSelectElement>('select')] + .filter(s => [...s.options].some(o => o.value === 'median') && s.options.length === 4); + + aggSelects[0].value = 'mean'; + aggSelects[0].dispatchEvent(new Event('change')); + expect(callbacks.onRunAggChange).toHaveBeenCalledWith('mean'); + + aggSelects[1].value = 'max'; + aggSelects[1].dispatchEvent(new Event('change')); + expect(callbacks.onSampleAggChange).toHaveBeenCalledWith('max'); + }); + + it('regression mode change fires onRegressionModeChange', () => { + const callbacks = makeCallbacks(); + const handle = createControls(makeState(), ['nts'], callbacks); + const panel = handle.getElement(); + + const regSelect = findSelectByOptions(panel, 'off', 'active', 'all')!; + regSelect.value = 'all'; + regSelect.dispatchEvent(new Event('change')); + expect(callbacks.onRegressionModeChange).toHaveBeenCalledWith('all'); + }); + + it('filter input fires onFilterChange after 200ms debounce, not before', () => { + vi.useFakeTimers(); + try { + const callbacks = makeCallbacks(); + const handle = createControls(makeState(), ['nts'], callbacks); + const panel = handle.getElement(); + + const filterInput = panel.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'abc'; + filterInput.dispatchEvent(new Event('input')); + + expect(callbacks.onFilterChange).not.toHaveBeenCalled(); + vi.advanceTimersByTime(199); + expect(callbacks.onFilterChange).not.toHaveBeenCalled(); + vi.advanceTimersByTime(1); + expect(callbacks.onFilterChange).toHaveBeenCalledWith('abc'); + + // Rapid typing resets the timer + callbacks.onFilterChange.mockClear(); + filterInput.value = 'x'; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(100); + filterInput.value = 'xy'; + filterInput.dispatchEvent(new Event('input')); + vi.advanceTimersByTime(200); + expect(callbacks.onFilterChange).toHaveBeenCalledTimes(1); + expect(callbacks.onFilterChange).toHaveBeenCalledWith('xy'); + } finally { + vi.useRealTimers(); + } + }); + + it('machine chip remove button fires onMachineRemove', () => { + const callbacks = makeCallbacks(); + const handle = createControls(makeState({ machines: ['m1', 'm2'] }), ['nts'], callbacks); + const panel = handle.getElement(); + + const removeButtons = panel.querySelectorAll('.chip-remove'); + expect(removeButtons.length).toBe(2); + (removeButtons[0] as HTMLButtonElement).click(); + expect(callbacks.onMachineRemove).toHaveBeenCalledWith('m1'); + }); + + // ---- Handle methods ---- + + it('setEnabled(false) disables all inputs except suite selector', () => { + const handle = createControls(makeState(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + handle.setEnabled(false); + + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + expect(suiteSelect.disabled).toBe(false); + + const otherInputs = [...panel.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select')] + .filter(el => el !== suiteSelect); + expect(otherInputs.length).toBeGreaterThan(0); + for (const inp of otherInputs) { + expect(inp.disabled).toBe(true); + } + }); + + it('setEnabled(true) re-enables all controls', () => { + const handle = createControls(makeState(), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + handle.setEnabled(false); + handle.setEnabled(true); + + const allInputs = panel.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select'); + for (const inp of allInputs) { + expect(inp.disabled).toBe(false); + } + }); + + it('setSuite destroys old combobox, creates new one, and updates enabled state', () => { + const handle = createControls(makeState({ suite: 'nts' }), ['nts', 'other'], makeCallbacks()); + vi.mocked(renderMachineCombobox).mockClear(); + mockMachineComboHandle.destroy.mockClear(); + + handle.setSuite('other'); + expect(mockMachineComboHandle.destroy).toHaveBeenCalled(); + expect(renderMachineCombobox).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ testsuite: 'other' }), + ); + + // setSuite('') disables controls + vi.mocked(renderMachineCombobox).mockClear(); + handle.setSuite(''); + const panel = handle.getElement(); + const suiteSelect = panel.querySelector('.suite-select') as HTMLSelectElement; + const otherInputs = [...panel.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select')] + .filter(el => el !== suiteSelect); + for (const inp of otherInputs) { + expect(inp.disabled).toBe(true); + } + }); + + it('setRegressionMode updates dropdown value without firing callback', () => { + const callbacks = makeCallbacks(); + const handle = createControls(makeState(), ['nts'], callbacks); + handle.setRegressionMode('all'); + + const panel = handle.getElement(); + const regSelect = findSelectByOptions(panel, 'off', 'active', 'all')!; + expect(regSelect.value).toBe('all'); + expect(callbacks.onRegressionModeChange).not.toHaveBeenCalled(); + }); + + it('updateMachineChips replaces chips with new list', () => { + const handle = createControls(makeState({ machines: ['m1'] }), ['nts'], makeCallbacks()); + const panel = handle.getElement(); + expect(panel.querySelectorAll('.machine-chip').length).toBe(1); + + handle.updateMachineChips(['x', 'y', 'z']); + const chips = panel.querySelectorAll('.machine-chip'); + expect(chips.length).toBe(3); + expect(chips[0].querySelector('.chip-symbol')!.textContent).toBe('●'); + expect(chips[1].querySelector('.chip-symbol')!.textContent).toBe('▲'); + expect(chips[2].querySelector('.chip-symbol')!.textContent).toBe('■'); + }); + + it('updateMetricSelector replaces container with real selector or empty placeholder', () => { + const handle = createControls(makeState(), ['nts'], makeCallbacks()); + + const fields = [{ name: 'exec_time', type: 'real', display_name: 'Exec Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }]; + handle.updateMetricSelector(fields, 'exec_time'); + expect(filterMetricFields).toHaveBeenCalledWith(fields); + expect(renderMetricSelector).toHaveBeenCalled(); + + vi.mocked(renderEmptyMetricSelector).mockClear(); + handle.updateMetricSelector([], ''); + expect(renderEmptyMetricSelector).toHaveBeenCalled(); + }); + + // ---- Lifecycle ---- + + it('destroy calls machineComboHandle.destroy', () => { + const handle = createControls(makeState(), ['nts'], makeCallbacks()); + mockMachineComboHandle.destroy.mockClear(); + handle.destroy(); + expect(mockMachineComboHandle.destroy).toHaveBeenCalled(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/data-cache.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/data-cache.test.ts new file mode 100644 index 000000000..a19525091 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/data-cache.test.ts @@ -0,0 +1,548 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GraphDataCache, type GraphDataApi } from '../../../pages/graph/data-cache'; +import type { QueryDataPoint } from '../../../types'; + +function makePoint(test: string, commitValue: string, value: number, machine = 'm1', metric = 'exec_time'): QueryDataPoint { + return { + test, + machine, + metric, + value, + commit: commitValue, + ordinal: null, + tag: null, + run_uuid: 'r1', + submitted_at: null, + }; +} + +function createMockApi(): GraphDataApi & { + fetchOneCursorPage: ReturnType<typeof vi.fn>; + postOneCursorPage: ReturnType<typeof vi.fn>; +} { + return { + apiUrl: (suite: string, path: string) => `/api/v5/${suite}/${path}`, + fetchOneCursorPage: vi.fn(), + postOneCursorPage: vi.fn(), + }; +} + +describe('GraphDataCache', () => { + let api: ReturnType<typeof createMockApi>; + let cache: GraphDataCache; + + beforeEach(() => { + api = createMockApi(); + cache = new GraphDataCache(api); + }); + + // ---- Scaffold ---- + + describe('getScaffold', () => { + it('fetches commits sorted by ordinal and caches', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [ + { commit: '100', ordinal: 10, tag: null, fields: {} }, + { commit: '101', ordinal: 20, tag: null, fields: {} }, + ], + nextCursor: null, + }); + + const result = await cache.getScaffold('nts', 'm1'); + expect(result).toEqual(['100', '101']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + + const [url, params] = api.fetchOneCursorPage.mock.calls[0]; + expect(url).toContain('/commits'); + expect(params).toMatchObject({ machine: 'm1', sort: 'ordinal' }); + + // Second call returns cached + const result2 = await cache.getScaffold('nts', 'm1'); + expect(result2).toEqual(['100', '101']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + }); + + it('paginates through all results', async () => { + api.fetchOneCursorPage + .mockResolvedValueOnce({ + items: [{ commit: '100', ordinal: 10, tag: null, fields: {} }], + nextCursor: 'cursor1', + }) + .mockResolvedValueOnce({ + items: [{ commit: '101', ordinal: 20, tag: null, fields: {} }], + nextCursor: null, + }); + + const result = await cache.getScaffold('nts', 'm1'); + expect(result).toEqual(['100', '101']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(2); + }); + }); + + // ---- Test Discovery ---- + + describe('discoverTests', () => { + it('fetches once and caches (sorted)', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [{ name: 'test-B' }, { name: 'test-A' }], + nextCursor: null, + }); + + const result = await cache.discoverTests('nts', 'm1', 'exec_time'); + expect(result).toEqual(['test-A', 'test-B']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + + const result2 = await cache.discoverTests('nts', 'm1', 'exec_time'); + expect(result2).toEqual(['test-A', 'test-B']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + }); + + it('paginates through all results', async () => { + api.fetchOneCursorPage + .mockResolvedValueOnce({ items: [{ name: 'test-A' }], nextCursor: 'c1' }) + .mockResolvedValueOnce({ items: [{ name: 'test-B' }], nextCursor: null }); + + const result = await cache.discoverTests('nts', 'm1', 'exec_time'); + expect(result).toEqual(['test-A', 'test-B']); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(2); + }); + }); + + // ---- Query Data ---- + + describe('ensureTestData', () => { + it('fetches only uncached tests', async () => { + // Pre-cache test-A via ensureTestData + api.postOneCursorPage.mockResolvedValueOnce({ + items: [makePoint('test-A', '100', 1.0)], nextCursor: null, + }); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']); + + // Now ensure test-A + test-B — only test-B fetched + api.postOneCursorPage.mockResolvedValueOnce({ + items: [makePoint('test-B', '100', 3.0)], nextCursor: null, + }); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A', 'test-B']); + + expect(api.postOneCursorPage).toHaveBeenCalledTimes(2); + expect(api.postOneCursorPage.mock.calls[1][1].test).toEqual(['test-B']); + }); + + it('is a no-op for fully cached tests', async () => { + api.postOneCursorPage.mockResolvedValueOnce({ + items: [makePoint('test-A', '100', 1.0)], nextCursor: null, + }); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']); + + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']); + expect(api.postOneCursorPage).toHaveBeenCalledTimes(1); + }); + + it('calls onProgress after each page and on completion', async () => { + api.postOneCursorPage + .mockResolvedValueOnce({ + items: [makePoint('test-A', '100', 1.0)], nextCursor: 'c1', + }) + .mockResolvedValueOnce({ + items: [makePoint('test-A', '101', 2.0)], nextCursor: null, + }); + + const onProgress = vi.fn(); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A'], { onProgress }); + expect(onProgress).toHaveBeenCalledTimes(2); + }); + + it('distributes points to per-test entries', async () => { + api.postOneCursorPage.mockResolvedValueOnce({ + items: [ + makePoint('test-A', '100', 1.0), + makePoint('test-B', '100', 2.0), + makePoint('test-A', '101', 3.0), + ], + nextCursor: null, + }); + + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A', 'test-B']); + expect(cache.readCachedTestData('nts', 'm1', 'exec_time', 'test-A')).toHaveLength(2); + expect(cache.readCachedTestData('nts', 'm1', 'exec_time', 'test-B')).toHaveLength(1); + }); + }); + + describe('readCachedTestData', () => { + it('returns [] for uncached test', () => { + expect(cache.readCachedTestData('nts', 'm1', 'exec_time', 'test-A')).toEqual([]); + }); + }); + + describe('isComplete', () => { + it('returns false for uncached', () => { + expect(cache.isComplete('nts', 'm1', 'exec_time', 'test-A')).toBe(false); + }); + + it('returns true after ensureTestData completes', async () => { + api.postOneCursorPage.mockResolvedValueOnce({ items: [], nextCursor: null }); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']); + expect(cache.isComplete('nts', 'm1', 'exec_time', 'test-A')).toBe(true); + }); + }); + + // ---- Baseline Data (delta-fetch) ---- + + describe('getBaselineData', () => { + it('fetches once and caches', async () => { + const points = [makePoint('test-A', '100', 5.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: points, nextCursor: null }); + + const result = await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A']); + expect(result).toEqual(points); + expect(api.postOneCursorPage).toHaveBeenCalledTimes(1); + + // Second call with same tests — no API hit + const result2 = await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A']); + expect(result2).toEqual(points); + expect(api.postOneCursorPage).toHaveBeenCalledTimes(1); + }); + + it('delta-fetches only new tests', async () => { + const pointsA = [makePoint('test-A', '100', 5.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: pointsA, nextCursor: null }); + await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A']); + + // Now request with test-B added — only test-B should be fetched + const pointsB = [makePoint('test-B', '100', 10.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: pointsB, nextCursor: null }); + const result = await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A', 'test-B']); + + expect(api.postOneCursorPage).toHaveBeenCalledTimes(2); + // Second call should only request test-B + expect(api.postOneCursorPage.mock.calls[1][1].test).toEqual(['test-B']); + // Result includes both test-A and test-B points (merged) + expect(result).toHaveLength(2); + expect(result.map(p => p.test)).toEqual(['test-A', 'test-B']); + }); + + it('no-op when all requested tests are covered', async () => { + const points = [makePoint('test-A', '100', 5.0), makePoint('test-B', '100', 10.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: points, nextCursor: null }); + await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A', 'test-B']); + + const result = await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A']); + expect(result).toEqual(points); + expect(api.postOneCursorPage).toHaveBeenCalledTimes(1); + }); + }); + + describe('readCachedBaselineData', () => { + it('returns [] for uncached', () => { + expect(cache.readCachedBaselineData('nts', 'm1', '100', 'exec_time')).toEqual([]); + }); + + it('returns data for cached baseline', async () => { + const points = [makePoint('test-A', '100', 5.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: points, nextCursor: null }); + await cache.getBaselineData('nts', 'm1', '100', 'exec_time', ['test-A']); + expect(cache.readCachedBaselineData('nts', 'm1', '100', 'exec_time')).toEqual(points); + }); + }); + + // ---- Baseline Commits ---- + + describe('getBaselineCommits', () => { + it('fetches and caches commit list', async () => { + const commits = [ + { commit: '100', ordinal: 10, tag: null, fields: {} }, + { commit: '101', ordinal: 20, tag: null, fields: {} }, + ]; + api.fetchOneCursorPage.mockResolvedValueOnce({ items: commits, nextCursor: null }); + + const result = await cache.getBaselineCommits('nts', 'm1'); + expect(result).toEqual(commits); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + + // Cached + const result2 = await cache.getBaselineCommits('nts', 'm1'); + expect(result2).toEqual(commits); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + }); + }); + + // ---- Regressions ---- + + describe('getRegressions', () => { + it('fetches active regressions with state filter', async () => { + const items = [ + { uuid: 'r1', title: 'Reg1', bug: null, state: 'active' as const, commit: '100', machine_count: 1, test_count: 1 }, + ]; + api.fetchOneCursorPage.mockResolvedValueOnce({ items, nextCursor: null }); + + const result = await cache.getRegressions('nts', 'active'); + expect(result).toEqual(items); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + + const [, params] = api.fetchOneCursorPage.mock.calls[0]; + expect(params.state).toBe('detected,active'); + }); + + it('fetches all regressions without state filter', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ items: [], nextCursor: null }); + await cache.getRegressions('nts', 'all'); + + const [, params] = api.fetchOneCursorPage.mock.calls[0]; + expect(params.state).toBeUndefined(); + }); + + it('caches results', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ items: [], nextCursor: null }); + await cache.getRegressions('nts', 'active'); + await cache.getRegressions('nts', 'active'); + expect(api.fetchOneCursorPage).toHaveBeenCalledTimes(1); + }); + }); + + describe('readCachedRegressions', () => { + it('returns null for uncached', () => { + expect(cache.readCachedRegressions('nts', 'active')).toBeNull(); + }); + + it('returns cached data', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ items: [], nextCursor: null }); + await cache.getRegressions('nts', 'active'); + expect(cache.readCachedRegressions('nts', 'active')).toEqual([]); + }); + }); + + // ---- Error Handling ---- + + describe('error handling', () => { + it('does not cache on API error (allows retry)', async () => { + api.postOneCursorPage.mockRejectedValueOnce(new Error('Network error')); + await expect( + cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']), + ).rejects.toThrow('Network error'); + expect(cache.isComplete('nts', 'm1', 'exec_time', 'test-A')).toBe(false); + }); + + it('propagates error to caller', async () => { + api.fetchOneCursorPage.mockRejectedValueOnce(new Error('Server error')); + await expect(cache.getScaffold('nts', 'm1')).rejects.toThrow('Server error'); + }); + }); + + // ---- Abort Signal ---- + + describe('abort signal', () => { + it('does not corrupt cache on abort during ensureTestData', async () => { + const controller = new AbortController(); + + api.postOneCursorPage.mockImplementation(async () => { + controller.abort(); + return { items: [makePoint('test-A', '100', 1.0)], nextCursor: 'cursor1' }; + }); + + await expect( + cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A'], { signal: controller.signal }), + ).rejects.toThrow('Aborted'); + expect(cache.isComplete('nts', 'm1', 'exec_time', 'test-A')).toBe(false); + }); + }); + + // ---- Scaffold Union ---- + + describe('scaffoldUnion', () => { + it('computes union across machines sorted by ordinal', async () => { + api.fetchOneCursorPage + .mockResolvedValueOnce({ + items: [ + { commit: '100', ordinal: 10, tag: null, fields: {} }, + { commit: '101', ordinal: 30, tag: null, fields: {} }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + items: [ + { commit: '101', ordinal: 30, tag: null, fields: {} }, + { commit: '102', ordinal: 40, tag: null, fields: {} }, + ], + nextCursor: null, + }); + + await cache.getScaffold('nts', 'm1'); + await cache.getScaffold('nts', 'm2'); + + const union = cache.scaffoldUnion('nts', ['m1', 'm2']); + expect(union?.commits).toEqual(['100', '101', '102']); + }); + + it('merges interleaved ordinals correctly', async () => { + api.fetchOneCursorPage + .mockResolvedValueOnce({ + items: [ + { commit: 'a', ordinal: 1, tag: null, fields: {} }, + { commit: 'c', ordinal: 3, tag: null, fields: {} }, + { commit: 'e', ordinal: 5, tag: null, fields: {} }, + ], + nextCursor: null, + }) + .mockResolvedValueOnce({ + items: [ + { commit: 'b', ordinal: 2, tag: null, fields: {} }, + { commit: 'c', ordinal: 3, tag: null, fields: {} }, + { commit: 'f', ordinal: 6, tag: null, fields: {} }, + ], + nextCursor: null, + }); + + await cache.getScaffold('nts', 'm1'); + await cache.getScaffold('nts', 'm2'); + + const union = cache.scaffoldUnion('nts', ['m1', 'm2']); + expect(union?.commits).toEqual(['a', 'b', 'c', 'e', 'f']); + }); + + it('returns null when no scaffolds cached', () => { + expect(cache.scaffoldUnion('nts', ['m1'])).toBeNull(); + }); + + it('populates displayMap when commitFields has a display field', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [ + { commit: 'abc', ordinal: 1, tag: null, fields: { sha: 'short-abc' } }, + { commit: 'def', ordinal: 2, tag: null, fields: { sha: 'short-def' } }, + ], + nextCursor: null, + }); + await cache.getScaffold('nts', 'm1'); + + const commitFields = [{ name: 'sha', display: true }]; + const union = cache.scaffoldUnion('nts', ['m1'], commitFields); + expect(union?.displayMap.get('abc')).toBe('short-abc'); + expect(union?.displayMap.get('def')).toBe('short-def'); + }); + + it('returns empty displayMap when no commitFields provided', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [ + { commit: 'abc', ordinal: 1, tag: null, fields: { sha: 'short-abc' } }, + ], + nextCursor: null, + }); + + await cache.getScaffold('nts', 'm1'); + + const union = cache.scaffoldUnion('nts', ['m1']); + expect(union?.displayMap.size).toBe(0); + }); + + it('includes tag in displayMap when commits have tags', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [ + { commit: 'abc', ordinal: 1, tag: 'release-1.0', fields: { sha: 'short-abc' } }, + { commit: 'def', ordinal: 2, tag: null, fields: { sha: 'short-def' } }, + ], + nextCursor: null, + }); + await cache.getScaffold('nts', 'm1'); + + const commitFields = [{ name: 'sha', display: true }]; + const union = cache.scaffoldUnion('nts', ['m1'], commitFields); + // Tagged commit: display field + tag suffix + expect(union?.displayMap.get('abc')).toBe('short-abc (release-1.0)'); + // Untagged commit: display field only + expect(union?.displayMap.get('def')).toBe('short-def'); + }); + + it('includes tag in displayMap even without display fields', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [ + { commit: 'abc', ordinal: 1, tag: 'v1.0', fields: {} }, + { commit: 'def', ordinal: 2, tag: null, fields: {} }, + ], + nextCursor: null, + }); + await cache.getScaffold('nts', 'm1'); + + const commitFields = [{ name: 'sha' }]; // no display: true + const union = cache.scaffoldUnion('nts', ['m1'], commitFields); + // Tag appended to raw commit string + expect(union?.displayMap.get('abc')).toBe('abc (v1.0)'); + // Untagged commit: no entry in displayMap (display === commit) + expect(union?.displayMap.has('def')).toBe(false); + }); + }); + + // ---- Cache Management ---- + + describe('clearSuite', () => { + it('clears suite-specific caches', async () => { + api.fetchOneCursorPage + .mockResolvedValueOnce({ + items: [{ commit: '100', ordinal: 10, tag: null, fields: {} }], nextCursor: null, + }) + .mockResolvedValueOnce({ + items: [{ name: 'test-A' }], nextCursor: null, + }); + api.postOneCursorPage + .mockResolvedValueOnce({ items: [makePoint('test-A', '100', 1.0)], nextCursor: null }); + + await cache.getScaffold('nts', 'm1'); + await cache.discoverTests('nts', 'm1', 'exec_time'); + await cache.ensureTestData('nts', 'm1', 'exec_time', ['test-A']); + + cache.clearSuite(); + + expect(cache.scaffoldUnion('nts', ['m1'])).toBeNull(); + expect(cache.readCachedTestData('nts', 'm1', 'exec_time', 'test-A')).toEqual([]); + }); + + it('preserves baseline commit cache across clearSuite', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [{ commit: '100', ordinal: 10, tag: null, fields: {} }], + nextCursor: null, + }); + await cache.getBaselineCommits('other', 'm1'); + + cache.clearSuite(); + + // Baseline commits should still be cached (no API call) + api.fetchOneCursorPage.mockClear(); + const result = await cache.getBaselineCommits('other', 'm1'); + expect(result).toHaveLength(1); + expect(api.fetchOneCursorPage).not.toHaveBeenCalled(); + }); + + it('preserves baseline data cache across clearSuite', async () => { + const points = [makePoint('test-A', '100', 5.0)]; + api.postOneCursorPage.mockResolvedValueOnce({ items: points, nextCursor: null }); + await cache.getBaselineData('other', 'm1', '100', 'exec_time', ['test-A']); + + cache.clearSuite(); + + expect(cache.readCachedBaselineData('other', 'm1', '100', 'exec_time')).toEqual(points); + }); + }); + + describe('clear', () => { + it('clears everything including baselines', async () => { + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [{ commit: '100', ordinal: 10, tag: null, fields: {} }], nextCursor: null, + }); + api.postOneCursorPage.mockResolvedValueOnce({ + items: [makePoint('test-A', '100', 5.0)], nextCursor: null, + }); + + await cache.getBaselineCommits('other', 'm1'); + await cache.getBaselineData('other', 'm1', '100', 'exec_time', ['test-A']); + + cache.clear(); + + expect(cache.readCachedBaselineData('other', 'm1', '100', 'exec_time')).toEqual([]); + // getBaselineCommits would need to re-fetch + api.fetchOneCursorPage.mockResolvedValueOnce({ + items: [], nextCursor: null, + }); + const result = await cache.getBaselineCommits('other', 'm1'); + expect(result).toEqual([]); + }); + }); +}); \ No newline at end of file diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/index.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/index.test.ts new file mode 100644 index 000000000..b255bf9e0 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/index.test.ts @@ -0,0 +1,1083 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock API module +vi.mock('../../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../api')>(); + return { + ...actual, + getTestSuiteInfoCached: vi.fn().mockResolvedValue({ + name: 'nts', + schema: { + metrics: [ + { name: 'exec_time', type: 'real', display_name: 'Exec Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, + ], + commit_fields: [], + machine_fields: [], + }, + }), + fetchOneCursorPage: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + postOneCursorPage: vi.fn().mockResolvedValue({ items: [], nextCursor: null }), + apiUrl: vi.fn((suite: string, path: string) => `/api/v5/${suite}/${path}`), + }; +}); + +vi.mock('../../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../router')>(); + return { ...actual, navigate: vi.fn(), getTestsuites: vi.fn(() => ['nts']) }; +}); + +vi.mock('../../../utils', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../utils')>(); + return { ...actual, resolveDisplayMap: vi.fn().mockResolvedValue(new Map()) }; +}); + +const mockMachineComboHandle = { destroy: vi.fn(), clear: vi.fn(), getValue: vi.fn(() => '') }; +vi.mock('../../../components/machine-combobox', () => ({ + renderMachineCombobox: vi.fn(() => mockMachineComboHandle), +})); + +const mockCommitPickerHandle = { + element: document.createElement('div'), + input: document.createElement('input'), + destroy: vi.fn(), +}; +vi.mock('../../../components/commit-combobox', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../../components/commit-combobox')>(); + return { + ...actual, + createCommitPicker: vi.fn(() => mockCommitPickerHandle), + }; +}); + +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn().mockResolvedValue(document.createElement('div')), + react: vi.fn(), + purge: vi.fn(), + restyle: vi.fn(), + addTraces: vi.fn(), + deleteTraces: vi.fn(), + relayout: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { graphPage } from '../../../pages/graph/index'; +import { renderMachineCombobox } from '../../../components/machine-combobox'; +import { resolveDisplayMap } from '../../../utils'; +import { GRAPH_CHART_DBLCLICK, GRAPH_TABLE_HOVER } from '../../../events'; + +describe('graphPage', () => { + let container: HTMLElement; + + const params = { testsuite: '' }; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + // Default empty URL state + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '', pathname: '/v5/graph' }, + writable: true, + }); + }); + + afterEach(() => { + graphPage.unmount?.(); + vi.unstubAllGlobals(); + }); + + it('renders page header and controls panel', () => { + graphPage.mount(container, params); + expect(container.querySelector('.page-header')?.textContent).toBe('Graph'); + expect(container.querySelector('.controls-panel')).not.toBeNull(); + }); + + it('renders suite selector with options', () => { + graphPage.mount(container, params); + const suiteSelect = container.querySelector('.suite-select') as HTMLSelectElement; + expect(suiteSelect).not.toBeNull(); + expect(suiteSelect.options.length).toBeGreaterThan(1); + expect(suiteSelect.options[1].value).toBe('nts'); + }); + + it('renders baseline panel', () => { + graphPage.mount(container, params); + expect(container.querySelector('.baseline-panel')).not.toBeNull(); + }); + + it('renders machine combobox even without suite selected', () => { + graphPage.mount(container, params); + expect(renderMachineCombobox).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ testsuite: '' }), + ); + }); + + it('re-creates machine combobox with suite when suite is selected', () => { + graphPage.mount(container, params); + vi.mocked(renderMachineCombobox).mockClear(); + + const suiteSelect = container.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = 'nts'; + suiteSelect.dispatchEvent(new Event('change')); + + expect(renderMachineCombobox).toHaveBeenCalledWith( + expect.any(HTMLElement), + expect.objectContaining({ testsuite: 'nts' }), + ); + }); + + it('parses machines from URL', () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=m1&machine=m2&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + // Chips should be rendered + const chips = container.querySelectorAll('.machine-chip'); + expect(chips.length).toBe(2); + }); + + it('unmount cleans up without errors', () => { + graphPage.mount(container, params); + expect(() => graphPage.unmount?.()).not.toThrow(); + }); + + it('can mount again after unmount', () => { + graphPage.mount(container, params); + graphPage.unmount?.(); + const container2 = document.createElement('div'); + expect(() => graphPage.mount(container2, params)).not.toThrow(); + graphPage.unmount?.(); + }); + + it('suite change resets regression mode dropdown to off', async () => { + // Mount with regressions=active in URL + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts®ressions=active', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + // Verify initial regression dropdown shows 'active' + const selects = container.querySelectorAll<HTMLSelectElement>('select'); + const regressionSelect = [...selects].find(s => + [...s.options].some(o => o.value === 'active' && o.text === 'Active'), + ); + expect(regressionSelect).toBeDefined(); + expect(regressionSelect!.value).toBe('active'); + + // Trigger suite change + const suiteSelect = container.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = ''; + suiteSelect.dispatchEvent(new Event('change')); + + // Regression dropdown should reset to 'off' + expect(regressionSelect!.value).toBe('off'); + }); + + it('resolves baseline display values when baselines exist in URL', () => { + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&baseline=nts::m1::abc123', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + expect(resolveDisplayMap).toHaveBeenCalledWith( + 'nts', + ['abc123'], + expect.any(AbortSignal), + ); + }); + + it('does not resolve baseline display values when no baselines in URL', () => { + graphPage.mount(container, params); + expect(resolveDisplayMap).not.toHaveBeenCalled(); + }); + + it('shows progress indicator during test discovery', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + const mockFetch = vi.mocked(fetchOneCursorPage); + + let resolveDiscovery!: (val: { items: never[]; nextCursor: null }) => void; + const discoveryPromise = new Promise<{ items: never[]; nextCursor: null }>( + r => { resolveDiscovery = r; }); + + let callCount = 0; + mockFetch.mockImplementation(async () => { + callCount++; + // First call is scaffold (let it resolve), second is test discovery (block) + if (callCount <= 1) return { items: [], nextCursor: null }; + return discoveryPromise; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, + search: '?suite=nts&machine=m-progress&metric=exec_time', + pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + const el = container.querySelector('.progress-label') as HTMLElement; + expect(el).not.toBeNull(); + expect(el.style.display).not.toBe('none'); + }); + + resolveDiscovery({ items: [], nextCursor: null }); + + await vi.waitFor(() => { + const el = container.querySelector('.progress-label') as HTMLElement; + expect(el).not.toBeNull(); + expect(el.style.display).toBe('none'); + }); + }); + + it('doPlot cancels prior plot cycle on re-entry', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + const mockFetch = vi.mocked(fetchOneCursorPage); + + // Track abort signals passed to fetches + const receivedSignals: AbortSignal[] = []; + mockFetch.mockImplementation(async (_url, _params, signal) => { + if (signal) receivedSignals.push(signal); + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + // Wait for initial load to complete + await vi.waitFor(() => { + expect(receivedSignals.length).toBeGreaterThan(0); + }); + + const firstSignals = [...receivedSignals]; + receivedSignals.length = 0; + + // Trigger a metric change (which calls doPlot internally) + const metricSelect = container.querySelector('.metric-select') as HTMLSelectElement; + expect(metricSelect).not.toBeNull(); + expect(metricSelect.options.length).toBeGreaterThan(1); + metricSelect.value = metricSelect.options[1].value; + metricSelect.dispatchEvent(new Event('change')); + + // The first plot cycle's signals should be aborted + for (const sig of firstSignals) { + expect(sig.aborted).toBe(true); + } + }); + + // =========================================================================== + // doPlot pipeline + // =========================================================================== + + describe('doPlot pipeline', () => { + it('fetches scaffolds then discovers tests for all machines', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + const mockFetch = vi.mocked(fetchOneCursorPage); + + const callUrls: string[] = []; + mockFetch.mockImplementation(async (url: string) => { + callUrls.push(url); + if (url.includes('/tests')) { + return { items: [{ name: 'test-A' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=par-m1&machine=par-m2&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + // Should have fetched scaffolds (commits) and tests for both machines + const scaffoldCalls = callUrls.filter(u => u.includes('/commits')); + const testCalls = callUrls.filter(u => u.includes('/tests')); + expect(scaffoldCalls.length).toBeGreaterThanOrEqual(2); + expect(testCalls.length).toBeGreaterThanOrEqual(2); + }); + }); + + it('populates test selection table after discovery completes', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + const mockFetch = vi.mocked(fetchOneCursorPage); + + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'alpha-test' }, { name: 'beta-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=dp-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('[data-test]'); + expect(rows.length).toBe(2); + }); + }); + + it('chart starts empty after doPlot — nothing plotted until user selects tests', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'some-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=empty-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + // No Plotly.newPlot should have been called (chart starts empty) + const Plotly = (globalThis as Record<string, unknown>).Plotly as Record<string, ReturnType<typeof vi.fn>>; + expect(Plotly.newPlot).not.toHaveBeenCalled(); + }); + }); + + // =========================================================================== + // handleSuiteChange + // =========================================================================== + + it('suite change resets machines, clears chart, table, and all state', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'test-Z' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=sc-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + // Wait for initial load + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + // Trigger suite change (to empty) + const suiteSelect = container.querySelector('.suite-select') as HTMLSelectElement; + suiteSelect.value = ''; + suiteSelect.dispatchEvent(new Event('change')); + + // Machine chips should be cleared + expect(container.querySelectorAll('.machine-chip').length).toBe(0); + // Test table should be gone + expect(container.querySelectorAll('[data-test]').length).toBe(0); + }); + + // =========================================================================== + // handleSelectionChange + // =========================================================================== + + describe('handleSelectionChange', () => { + it('fetches uncached test data for selected tests across machines', async () => { + const { fetchOneCursorPage, postOneCursorPage } = await import('../../../api'); + const mockFetch = vi.mocked(fetchOneCursorPage); + const mockPost = vi.mocked(postOneCursorPage); + + mockFetch.mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'sel-test-A' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=sel-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + mockPost.mockClear(); + mockPost.mockResolvedValue({ items: [], nextCursor: null }); + + // Click the test row to select it (single click + wait for 200ms delay) + const row = container.querySelector('[data-test="sel-test-A"]') as HTMLElement; + row.click(); + + await vi.waitFor(() => { + expect(mockPost).toHaveBeenCalled(); + const postUrl = mockPost.mock.calls[0][0] as string; + expect(postUrl).toContain('/query'); + }); + }); + + it('aborts previous selection fetch when new selection arrives', async () => { + const { fetchOneCursorPage, postOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'abort-test-A' }, { name: 'abort-test-B' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + const receivedSignals: AbortSignal[] = []; + const mockPost = vi.mocked(postOneCursorPage); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=abort-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(2); + }); + + // Set up mock to capture signals + mockPost.mockImplementation(async (_url, _params, signal) => { + if (signal) receivedSignals.push(signal); + return { items: [], nextCursor: null }; + }); + + // Select first test + const rowA = container.querySelector('[data-test="abort-test-A"]') as HTMLElement; + rowA.click(); + + // Immediately double-click on test B to isolate it (cancels previous selection) + const rowB = container.querySelector('[data-test="abort-test-B"]') as HTMLElement; + rowB.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + await vi.waitFor(() => { + // At least one signal should have been captured + expect(receivedSignals.length).toBeGreaterThan(0); + }); + }); + }); + + // =========================================================================== + // Machine add/remove + // =========================================================================== + + describe('machine add/remove', () => { + it('handleMachineAdd adds chip and triggers doPlot when metric is set', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + // Wait for suite fields to load + await vi.waitFor(() => { + const metricSelect = container.querySelector('.metric-select') as HTMLSelectElement; + expect(metricSelect).not.toBeNull(); + }); + + vi.mocked(fetchOneCursorPage).mockClear(); + vi.mocked(fetchOneCursorPage).mockResolvedValue({ items: [], nextCursor: null }); + + // Add a machine via the combobox onSelect + const calls = vi.mocked(renderMachineCombobox).mock.calls; + const machineCall = calls[calls.length - 1]!; + machineCall[1].onSelect('add-m1'); + + // Chip should appear + await vi.waitFor(() => { + expect(container.querySelectorAll('.machine-chip').length).toBe(1); + }); + + // doPlot should have been triggered (scaffold fetch) + await vi.waitFor(() => { + expect(vi.mocked(fetchOneCursorPage)).toHaveBeenCalled(); + }); + }); + + it('handleMachineAdd is a no-op for duplicate machine', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockResolvedValue({ items: [], nextCursor: null }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=dup-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.machine-chip').length).toBe(1); + }); + + vi.mocked(fetchOneCursorPage).mockClear(); + const calls = vi.mocked(renderMachineCombobox).mock.calls; + const machineCall = calls[calls.length - 1]!; + machineCall[1].onSelect('dup-m1'); + + // Still one chip, no new fetch + expect(container.querySelectorAll('.machine-chip').length).toBe(1); + expect(vi.mocked(fetchOneCursorPage)).not.toHaveBeenCalled(); + }); + + it('handleMachineRemove removes chip and re-runs doPlot or clears when last', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'rem-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=rem-m1&machine=rem-m2&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.machine-chip').length).toBe(2); + }); + + // Remove first machine + const removeBtn = container.querySelector('.chip-remove') as HTMLButtonElement; + removeBtn.click(); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.machine-chip').length).toBe(1); + }); + + // Remove last machine + const lastRemoveBtn = container.querySelector('.chip-remove') as HTMLButtonElement; + lastRemoveBtn.click(); + expect(container.querySelectorAll('.machine-chip').length).toBe(0); + }); + }); + + // =========================================================================== + // Metric change + // =========================================================================== + + it('metric change clears test state and triggers doPlot', async () => { + const { fetchOneCursorPage, getTestSuiteInfoCached } = await import('../../../api'); + vi.mocked(getTestSuiteInfoCached).mockResolvedValue({ + name: 'nts', + schema: { + metrics: [ + { name: 'exec_time', type: 'real', display_name: 'Exec Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, + { name: 'mem_bytes', type: 'real', display_name: 'Memory', unit: 'B', unit_abbrev: 'B', bigger_is_better: false }, + ], + commit_fields: [], + machine_fields: [], + }, + }); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'metric-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=met-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + vi.mocked(fetchOneCursorPage).mockClear(); + + // Change metric + const metricSelect = container.querySelector('.metric-select') as HTMLSelectElement; + expect(metricSelect).not.toBeNull(); + expect(metricSelect.options.length).toBeGreaterThan(1); + metricSelect.value = 'mem_bytes'; + metricSelect.dispatchEvent(new Event('change')); + + // doPlot should be triggered again + await vi.waitFor(() => { + expect(vi.mocked(fetchOneCursorPage)).toHaveBeenCalled(); + }); + }); + + // =========================================================================== + // Filter + // =========================================================================== + + describe('handleFilterChange', () => { + it('filter prunes selection to matching tests (case-insensitive)', async () => { + vi.useFakeTimers(); + try { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'Alpha-test' }, { name: 'Beta-test' }, { name: 'ALPHA-other' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=filt-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(3); + }); + + // Apply filter + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'alpha'; + filterInput.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(200); + + await vi.waitFor(() => { + const rows = container.querySelectorAll<HTMLElement>('[data-test]'); + const visible = [...rows].filter(r => r.style.display !== 'none'); + expect(visible.length).toBe(2); + const names = visible.map(r => r.getAttribute('data-test')); + expect(names).toContain('Alpha-test'); + expect(names).toContain('ALPHA-other'); + }); + } finally { + vi.useRealTimers(); + } + }); + }); + + // =========================================================================== + // Aggregation + // =========================================================================== + + it('aggregation change re-renders chart from cache without new API calls', async () => { + const { fetchOneCursorPage, postOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'agg-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=agg-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + // Clear fetch counters + vi.mocked(fetchOneCursorPage).mockClear(); + vi.mocked(postOneCursorPage).mockClear(); + + // Change run aggregation + const selects = container.querySelectorAll<HTMLSelectElement>('select'); + const aggSelect = [...selects].find(s => + s.options.length === 4 && [...s.options].some(o => o.value === 'median'), + ); + expect(aggSelect).toBeDefined(); + aggSelect!.value = 'mean'; + aggSelect!.dispatchEvent(new Event('change')); + + // No new API calls should have been made + expect(vi.mocked(fetchOneCursorPage)).not.toHaveBeenCalled(); + expect(vi.mocked(postOneCursorPage)).not.toHaveBeenCalled(); + }); + + // =========================================================================== + // Baselines + // =========================================================================== + + describe('baseline handlers', () => { + it('handleBaselineRemove updates chips and schedules chart update', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockResolvedValue({ items: [], nextCursor: null }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=bl-m1&metric=exec_time&baseline=nts::bl-m1::abc', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + const chips = container.querySelectorAll('.baseline-chip'); + expect(chips.length).toBe(1); + }); + + // Remove the baseline + const removeBtn = container.querySelector('.baseline-chip .chip-remove') as HTMLButtonElement; + removeBtn.click(); + expect(container.querySelectorAll('.baseline-chip').length).toBe(0); + }); + }); + + // =========================================================================== + // Regressions + // =========================================================================== + + it('regression mode change fetches regressions', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + const callUrls: string[] = []; + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + callUrls.push(url); + if (url.includes('/tests')) { + return { items: [{ name: 'reg-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=reg-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + callUrls.length = 0; + + // Change regression mode to 'active' + const selects = container.querySelectorAll<HTMLSelectElement>('select'); + const regSelect = [...selects].find(s => + [...s.options].some(o => o.value === 'active' && o.text === 'Active'), + ); + expect(regSelect).toBeDefined(); + regSelect!.value = 'active'; + regSelect!.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + const regCalls = callUrls.filter(u => u.includes('/regressions')); + expect(regCalls.length).toBeGreaterThan(0); + }); + }); + + // =========================================================================== + // State preservation (back-nav) + // =========================================================================== + + describe('module-level state preservation', () => { + it('preserves machine chips and test table across unmount/remount', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'persist-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=persist-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + expect(container.querySelectorAll('.machine-chip').length).toBe(1); + }); + + // Unmount + graphPage.unmount?.(); + + // Remount on fresh container + const container2 = document.createElement('div'); + graphPage.mount(container2, params); + + // Machine chips and test table should render from preserved state + await vi.waitFor(() => { + expect(container2.querySelectorAll('.machine-chip').length).toBe(1); + }); + }); + }); + + // =========================================================================== + // Error handling + // =========================================================================== + + it('showError displays banner then auto-hides after 5s', async () => { + vi.useFakeTimers(); + try { + const { getTestSuiteInfoCached } = await import('../../../api'); + vi.mocked(getTestSuiteInfoCached).mockRejectedValueOnce(new Error('fail')); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner') as HTMLElement; + expect(banner).not.toBeNull(); + expect(banner.style.display).not.toBe('none'); + expect(banner.textContent).toContain('Failed to load suite fields'); + }); + + vi.advanceTimersByTime(5000); + + const banner = container.querySelector('.error-banner') as HTMLElement; + expect(banner.style.display).toBe('none'); + } finally { + vi.useRealTimers(); + } + }); + + // =========================================================================== + // Hover sync + // =========================================================================== + + describe('hover sync', () => { + it('GRAPH_CHART_DBLCLICK isolates the double-clicked test in selection', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'hover-test-A' }, { name: 'hover-test-B' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=hover-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(2); + }); + + // Dispatch GRAPH_CHART_DBLCLICK to isolate hover-test-A + document.dispatchEvent(new CustomEvent(GRAPH_CHART_DBLCLICK, { detail: 'hover-test-A' })); + + await vi.waitFor(() => { + const selectedRows = container.querySelectorAll('.row-selected'); + expect(selectedRows.length).toBe(1); + expect(selectedRows[0].getAttribute('data-test')).toBe('hover-test-A'); + }); + }); + + it('GRAPH_TABLE_HOVER calls hoverTrace with multi-machine trace names', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'th-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=th-m1&machine=th-m2&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + // Dispatch table hover — the orchestrator should call chartHandle.hoverTrace + // with trace names for both machines. Since chart is not created (no selection), + // this just verifies the event handler doesn't throw. + document.dispatchEvent(new CustomEvent(GRAPH_TABLE_HOVER, { detail: 'th-test' })); + document.dispatchEvent(new CustomEvent(GRAPH_TABLE_HOVER, { detail: null })); + }); + }); + + // =========================================================================== + // Additional handleSelectionChange tests + // =========================================================================== + + it('skips fetch when all selected tests are already cached', async () => { + const { fetchOneCursorPage, postOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'cache-test-A' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + vi.mocked(postOneCursorPage).mockResolvedValue({ items: [], nextCursor: null }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=cache-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + // Select the test to trigger initial fetch + const row = container.querySelector('[data-test="cache-test-A"]') as HTMLElement; + row.click(); + + await vi.waitFor(() => { + expect(vi.mocked(postOneCursorPage)).toHaveBeenCalled(); + }); + + // Wait for the data to finish loading + await vi.waitFor(() => { + const loadingRows = container.querySelectorAll('.row-loading'); + expect(loadingRows.length).toBe(0); + }); + + // Clear mock counts, then deselect and re-select — should not fetch again + vi.mocked(postOneCursorPage).mockClear(); + + // Double-click to re-trigger handleSelectionChange with the same test + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + // Flush microtasks to allow any async work to start + await Promise.resolve(); + + // No new POST calls — data was already cached + expect(vi.mocked(postOneCursorPage)).not.toHaveBeenCalled(); + }); + + // =========================================================================== + // Additional filter test + // =========================================================================== + + describe('handleFilterChange (additional)', () => { + it('clearing filter restores full test list', async () => { + vi.useFakeTimers(); + try { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'cf-alpha' }, { name: 'cf-beta' }, { name: 'cf-gamma' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=cf-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(3); + }); + + // Apply filter + const filterInput = container.querySelector('.test-filter-input') as HTMLInputElement; + filterInput.value = 'alpha'; + filterInput.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(200); + + await vi.waitFor(() => { + const rows = container.querySelectorAll<HTMLElement>('[data-test]'); + const visible = [...rows].filter(r => r.style.display !== 'none'); + expect(visible.length).toBe(1); + }); + + // Clear filter + filterInput.value = ''; + filterInput.dispatchEvent(new Event('input')); + await vi.advanceTimersByTimeAsync(200); + + await vi.waitFor(() => { + const rows = container.querySelectorAll<HTMLElement>('[data-test]'); + const visible = [...rows].filter(r => r.style.display !== 'none'); + expect(visible.length).toBe(3); + }); + } finally { + vi.useRealTimers(); + } + }); + }); + + // =========================================================================== + // handleBaselineAdd + // =========================================================================== + + it('handleBaselineAdd adds chip and triggers baseline data fetch', async () => { + const { fetchOneCursorPage, postOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'bla-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + vi.mocked(postOneCursorPage).mockResolvedValue({ items: [], nextCursor: null }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=bla-m1&metric=exec_time&baseline=nts::bla-m1::abc', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + // Baseline chip should appear from the URL-provided baseline + await vi.waitFor(() => { + expect(container.querySelectorAll('.baseline-chip').length).toBe(1); + }); + + // The baseline chip text should contain the commit value + const chip = container.querySelector('.baseline-chip'); + expect(chip).not.toBeNull(); + expect(chip!.textContent).toContain('abc'); + }); + + // =========================================================================== + // Additional state preservation test + // =========================================================================== + + it('renders from cache on remount without new scaffold fetches', async () => { + const { fetchOneCursorPage } = await import('../../../api'); + vi.mocked(fetchOneCursorPage).mockImplementation(async (url: string) => { + if (url.includes('/tests')) { + return { items: [{ name: 'nocache-test' }], nextCursor: null }; + } + return { items: [], nextCursor: null }; + }); + + Object.defineProperty(window, 'location', { + value: { ...window.location, search: '?suite=nts&machine=nocache-m1&metric=exec_time', pathname: '/v5/graph' }, + writable: true, + }); + graphPage.mount(container, params); + + await vi.waitFor(() => { + expect(container.querySelectorAll('[data-test]').length).toBe(1); + }); + + graphPage.unmount?.(); + vi.mocked(fetchOneCursorPage).mockClear(); + + const container2 = document.createElement('div'); + graphPage.mount(container2, params); + + // Wait for remount to settle + await vi.waitFor(() => { + expect(container2.querySelectorAll('.machine-chip').length).toBe(1); + }); + + // Scaffold data was cached — check that commits endpoint was NOT called again + const scaffoldCalls = vi.mocked(fetchOneCursorPage).mock.calls + .filter(c => (c[0] as string).includes('/commits')); + expect(scaffoldCalls.length).toBe(0); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/state.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/state.test.ts new file mode 100644 index 000000000..94d301f77 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/state.test.ts @@ -0,0 +1,220 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { decodeGraphState, encodeGraphState, replaceGraphUrl } from '../../../pages/graph/state'; +import type { GraphState } from '../../../pages/graph/state'; + +function makeDefault(): GraphState { + return { + suite: '', + machines: [], + metric: '', + testFilter: '', + runAgg: 'median', + sampleAgg: 'median', + baselines: [], + regressionMode: 'off', + }; +} + +describe('decodeGraphState', () => { + it('returns defaults for empty search string', () => { + expect(decodeGraphState('')).toEqual(makeDefault()); + }); + + it('returns defaults for "?"', () => { + expect(decodeGraphState('?')).toEqual(makeDefault()); + }); + + it('parses suite and metric', () => { + const state = decodeGraphState('?suite=nts&metric=exec_time'); + expect(state.suite).toBe('nts'); + expect(state.metric).toBe('exec_time'); + }); + + it('parses single machine', () => { + const state = decodeGraphState('?machine=host1'); + expect(state.machines).toEqual(['host1']); + }); + + it('parses multiple machines (repeated param)', () => { + const state = decodeGraphState('?machine=host1&machine=host2&machine=host3'); + expect(state.machines).toEqual(['host1', 'host2', 'host3']); + }); + + it('filters empty machine values', () => { + const state = decodeGraphState('?machine=host1&machine=&machine=host2'); + expect(state.machines).toEqual(['host1', 'host2']); + }); + + it('parses test_filter', () => { + const state = decodeGraphState('?test_filter=benchmark'); + expect(state.testFilter).toBe('benchmark'); + }); + + it('parses aggregation functions', () => { + const state = decodeGraphState('?run_agg=mean&sample_agg=max'); + expect(state.runAgg).toBe('mean'); + expect(state.sampleAgg).toBe('max'); + }); + + it('defaults invalid aggregation to median', () => { + const state = decodeGraphState('?run_agg=invalid&sample_agg=bogus'); + expect(state.runAgg).toBe('median'); + expect(state.sampleAgg).toBe('median'); + }); + + it('parses single baseline', () => { + const state = decodeGraphState('?baseline=nts::machine1::abc123'); + expect(state.baselines).toEqual([ + { suite: 'nts', machine: 'machine1', commit: 'abc123' }, + ]); + }); + + it('parses multiple baselines', () => { + const state = decodeGraphState( + '?baseline=nts::m1::c1&baseline=other::m2::c2', + ); + expect(state.baselines).toHaveLength(2); + expect(state.baselines[0]).toEqual({ suite: 'nts', machine: 'm1', commit: 'c1' }); + expect(state.baselines[1]).toEqual({ suite: 'other', machine: 'm2', commit: 'c2' }); + }); + + it('skips malformed baselines', () => { + const state = decodeGraphState( + '?baseline=nts::m1::c1&baseline=bad_format&baseline=::m2::', + ); + expect(state.baselines).toHaveLength(1); + expect(state.baselines[0]).toEqual({ suite: 'nts', machine: 'm1', commit: 'c1' }); + }); + + it('parses regressionMode', () => { + expect(decodeGraphState('?regressions=active').regressionMode).toBe('active'); + expect(decodeGraphState('?regressions=all').regressionMode).toBe('all'); + expect(decodeGraphState('?regressions=off').regressionMode).toBe('off'); + }); + + it('defaults invalid regressionMode to off', () => { + expect(decodeGraphState('?regressions=bogus').regressionMode).toBe('off'); + }); + + it('parses a full URL with all params', () => { + const state = decodeGraphState( + '?suite=nts&machine=m1&machine=m2&metric=exec_time&test_filter=bench' + + '&run_agg=mean&sample_agg=min&baseline=nts::m1::c1®ressions=active', + ); + expect(state.suite).toBe('nts'); + expect(state.machines).toEqual(['m1', 'm2']); + expect(state.metric).toBe('exec_time'); + expect(state.testFilter).toBe('bench'); + expect(state.runAgg).toBe('mean'); + expect(state.sampleAgg).toBe('min'); + expect(state.baselines).toEqual([{ suite: 'nts', machine: 'm1', commit: 'c1' }]); + expect(state.regressionMode).toBe('active'); + }); +}); + +describe('encodeGraphState', () => { + it('returns empty string for default state', () => { + expect(encodeGraphState(makeDefault())).toBe(''); + }); + + it('encodes suite and metric', () => { + const state = { ...makeDefault(), suite: 'nts', metric: 'exec_time' }; + const search = encodeGraphState(state); + expect(search).toContain('suite=nts'); + expect(search).toContain('metric=exec_time'); + }); + + it('encodes multiple machines', () => { + const state = { ...makeDefault(), machines: ['m1', 'm2'] }; + const search = encodeGraphState(state); + expect(search).toContain('machine=m1'); + expect(search).toContain('machine=m2'); + }); + + it('omits default aggregation', () => { + const state = { ...makeDefault(), suite: 'nts' }; + const search = encodeGraphState(state); + expect(search).not.toContain('run_agg'); + expect(search).not.toContain('sample_agg'); + }); + + it('includes non-default aggregation', () => { + const state = { ...makeDefault(), runAgg: 'mean' as const, sampleAgg: 'max' as const }; + const search = encodeGraphState(state); + expect(search).toContain('run_agg=mean'); + expect(search).toContain('sample_agg=max'); + }); + + it('omits regression mode when off (default)', () => { + const state = { ...makeDefault(), suite: 'nts' }; + const search = encodeGraphState(state); + expect(search).not.toContain('regressions'); + }); + + it('includes regression mode when not off', () => { + const state = { ...makeDefault(), regressionMode: 'active' as const }; + const search = encodeGraphState(state); + expect(search).toContain('regressions=active'); + }); + + it('encodes baselines', () => { + const state = { + ...makeDefault(), + baselines: [ + { suite: 'nts', machine: 'm1', commit: 'c1' }, + { suite: 'other', machine: 'm2', commit: 'c2' }, + ], + }; + const search = encodeGraphState(state); + expect(search).toContain('baseline=nts%3A%3Am1%3A%3Ac1'); + expect(search).toContain('baseline=other%3A%3Am2%3A%3Ac2'); + }); + + it('omits empty suite and metric', () => { + const search = encodeGraphState(makeDefault()); + expect(search).not.toContain('suite='); + expect(search).not.toContain('metric='); + }); +}); + +describe('encode/decode round-trip', () => { + it('round-trips a full state', () => { + const original: GraphState = { + suite: 'nts', + machines: ['m1', 'm2'], + metric: 'exec_time', + testFilter: 'bench', + runAgg: 'mean', + sampleAgg: 'min', + baselines: [{ suite: 'nts', machine: 'm1', commit: 'c1' }], + regressionMode: 'active', + }; + const encoded = encodeGraphState(original); + const decoded = decodeGraphState(encoded); + expect(decoded).toEqual(original); + }); + + it('round-trips default state', () => { + const original = makeDefault(); + const encoded = encodeGraphState(original); + const decoded = decodeGraphState(encoded); + expect(decoded).toEqual(original); + }); + + it('round-trips state with only suite', () => { + const original = { ...makeDefault(), suite: 'nts' }; + const encoded = encodeGraphState(original); + const decoded = decodeGraphState(encoded); + expect(decoded).toEqual(original); + }); +}); + +describe('replaceGraphUrl', () => { + it('calls history.replaceState with encoded URL', () => { + const state = { ...makeDefault(), suite: 'nts', metric: 'exec_time' }; + replaceGraphUrl(state); + expect(window.location.search).toContain('suite=nts'); + expect(window.location.search).toContain('metric=exec_time'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/test-selection-table.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/test-selection-table.test.ts new file mode 100644 index 000000000..aadd2df37 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/test-selection-table.test.ts @@ -0,0 +1,514 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { createTestSelectionTable, type TestSelectionEntry } from '../../../pages/graph/test-selection-table'; +import { GRAPH_TABLE_HOVER } from '../../../events'; + +// jsdom doesn't provide CSS.escape — polyfill for tests +if (typeof CSS === 'undefined' || !CSS.escape) { + (globalThis as Record<string, unknown>).CSS = { + escape: (s: string) => s.replace(/([^\w-])/g, '\\$1'), + }; +} + +function makeEntries( + selected: string[], + unselected: string[], + opts?: { loading?: string[] }, +): TestSelectionEntry[] { + return [ + ...selected.map((name, i) => ({ + testName: name, + selected: true, + color: `#color${i}`, + loading: opts?.loading?.includes(name), + })), + ...unselected.map(name => ({ + testName: name, + selected: false, + loading: opts?.loading?.includes(name), + })), + ]; +} + +describe('createTestSelectionTable', () => { + let container: HTMLElement; + const onSelectionChange = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + container = document.createElement('div'); + }); + + afterEach(() => { + vi.useRealTimers(); + document.body.replaceChildren(); + }); + + it('renders all entries as rows with checkboxes', () => { + const entries = makeEntries(['test-A'], ['test-B', 'test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + + const checkboxes = container.querySelectorAll('tbody input[type="checkbox"]') as NodeListOf<HTMLInputElement>; + expect(checkboxes).toHaveLength(3); + }); + + it('selected entries have checked checkboxes and colored symbol', () => { + const entries = makeEntries(['test-A'], ['test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const rows = container.querySelectorAll('tbody tr'); + const cbA = rows[0].querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(cbA.checked).toBe(true); + expect(rows[0].classList.contains('row-selected')).toBe(true); + + const symbol = rows[0].querySelector('.legend-symbol') as HTMLElement; + expect(symbol).not.toBeNull(); + expect(symbol.textContent).toBe('●'); + }); + + it('unselected entries have unchecked checkboxes and no symbol', () => { + const entries = makeEntries([], ['test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr')!; + const cb = row.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(cb.checked).toBe(false); + expect(row.classList.contains('row-selected')).toBe(false); + expect(row.querySelector('.legend-symbol')).toBeNull(); + }); + + it('single click toggles selection after 200ms delay', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr[data-test="test-A"]') as HTMLElement; + row.click(); + + expect(onSelectionChange).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(200); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('test-A')).toBe(true); + expect(sel.has('test-B')).toBe(false); + }); + + it('single click deselects a selected test', () => { + const entries = makeEntries(['test-A'], ['test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr[data-test="test-A"]') as HTMLElement; + row.click(); + + vi.advanceTimersByTime(200); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('test-A')).toBe(false); + }); + + it('clicking directly on checkbox triggers correct selection change', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + // Click the checkbox element directly (not the row) + const cb = container.querySelector('tr[data-test="test-A"] input[type="checkbox"]') as HTMLInputElement; + expect(cb.checked).toBe(false); + cb.click(); + + // The native toggle is undone, so checkbox stays unchecked during the 200ms delay + expect(cb.checked).toBe(false); + + vi.advanceTimersByTime(200); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('test-A')).toBe(true); + }); + + it('shift-click selects range immediately (no 200ms delay)', () => { + const entries = makeEntries([], ['a-test', 'b-test', 'c-test', 'd-test']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + // First click (normal) on a-test + const rowA = container.querySelector('tr[data-test="a-test"]') as HTMLElement; + rowA.click(); + vi.advanceTimersByTime(200); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + onSelectionChange.mockClear(); + + // Shift-click on c-test — should fire immediately + const rowC = container.querySelector('tr[data-test="c-test"]') as HTMLElement; + rowC.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + + // Should fire immediately, not after 200ms + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('a-test')).toBe(true); + expect(sel.has('b-test')).toBe(true); + expect(sel.has('c-test')).toBe(true); + expect(sel.has('d-test')).toBe(false); + }); + + it('shift-click is additive to existing selection', () => { + // d-test is already selected + const entries = makeEntries(['d-test'], ['a-test', 'b-test', 'c-test']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + // Click a-test first + const rowA = container.querySelector('tr[data-test="a-test"]') as HTMLElement; + rowA.click(); + vi.advanceTimersByTime(200); + onSelectionChange.mockClear(); + + // Shift-click b-test — adds a-test and b-test, d-test stays selected + const rowB = container.querySelector('tr[data-test="b-test"]') as HTMLElement; + rowB.dispatchEvent(new MouseEvent('click', { bubbles: true, shiftKey: true })); + + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('a-test')).toBe(true); + expect(sel.has('b-test')).toBe(true); + expect(sel.has('d-test')).toBe(true); // preserved + }); + + it('shift-click with stale lastClickedTest acts as normal click', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + const handle = createTestSelectionTable(container, { entries, onSelectionChange }); + + // Click test-A + container.querySelector('tr[data-test="test-A"]')!.dispatchEvent( + new MouseEvent('click', { bubbles: true }), + ); + vi.advanceTimersByTime(200); + onSelectionChange.mockClear(); + + // Update entries without test-A — lastClickedTest becomes stale and is reset + handle.update(makeEntries([], ['test-B', 'test-C'])); + + // Shift-click test-C — lastClickedTest is null, falls through to normal toggle + container.querySelector('tr[data-test="test-C"]')!.dispatchEvent( + new MouseEvent('click', { bubbles: true, shiftKey: true }), + ); + + // Normal click with 200ms delay + expect(onSelectionChange).not.toHaveBeenCalled(); + vi.advanceTimersByTime(200); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.has('test-C')).toBe(true); + }); + + it('double-click isolates (select only this test)', () => { + const entries = makeEntries(['test-A', 'test-B'], ['test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr[data-test="test-A"]') as HTMLElement; + row.click(); + row.click(); + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + vi.advanceTimersByTime(200); + + // Should have called onSelectionChange with only test-A + expect(onSelectionChange).toHaveBeenCalled(); + const lastCall = onSelectionChange.mock.calls[onSelectionChange.mock.calls.length - 1]; + const sel = lastCall[0] as Set<string>; + expect(sel.size).toBe(1); + expect(sel.has('test-A')).toBe(true); + }); + + it('double-click restores all when already the sole selection', () => { + const entries = makeEntries(['test-A'], ['test-B', 'test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr[data-test="test-A"]') as HTMLElement; + row.click(); + row.click(); + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + vi.advanceTimersByTime(200); + + const lastCall = onSelectionChange.mock.calls[onSelectionChange.mock.calls.length - 1]; + const sel = lastCall[0] as Set<string>; + // Should select ALL entries + expect(sel.size).toBe(3); + expect(sel.has('test-A')).toBe(true); + expect(sel.has('test-B')).toBe(true); + expect(sel.has('test-C')).toBe(true); + }); + + it('loading entries show loading class and disabled checkbox', () => { + const entries = makeEntries(['test-A'], [], { loading: ['test-A'] }); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const row = container.querySelector('tr[data-test="test-A"]')!; + expect(row.classList.contains('row-loading')).toBe(true); + + const cb = row.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(cb.disabled).toBe(true); + }); + + it('dispatches GRAPH_TABLE_HOVER with bare test name on hover', () => { + document.body.append(container); + const entries = makeEntries([], ['test-A']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const events: Array<string | null> = []; + document.addEventListener(GRAPH_TABLE_HOVER, ((e: CustomEvent) => { + events.push(e.detail); + }) as EventListener); + + const row = container.querySelector('tr[data-test="test-A"]') as HTMLElement; + row.dispatchEvent(new MouseEvent('mouseenter', { bubbles: true })); + row.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true })); + + expect(events).toEqual(['test-A', null]); + }); + + it('highlightRow adds and removes highlight class', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + const handle = createTestSelectionTable(container, { entries, onSelectionChange }); + + handle.highlightRow('test-A'); + const rowA = container.querySelector('tr[data-test="test-A"]')!; + expect(rowA.classList.contains('row-highlighted')).toBe(true); + + handle.highlightRow('test-B'); + expect(rowA.classList.contains('row-highlighted')).toBe(false); + const rowB = container.querySelector('tr[data-test="test-B"]')!; + expect(rowB.classList.contains('row-highlighted')).toBe(true); + + handle.highlightRow(null); + expect(rowB.classList.contains('row-highlighted')).toBe(false); + }); + + it('update() replaces content', () => { + const entries = makeEntries(['test-A'], []); + const handle = createTestSelectionTable(container, { entries, onSelectionChange }); + + handle.update(makeEntries([], ['test-B', 'test-C']), '0 of 2 tests selected'); + + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(2); + expect(rows[0].getAttribute('data-test')).toBe('test-B'); + expect(rows[1].getAttribute('data-test')).toBe('test-C'); + + const message = container.querySelector('.test-selection-message'); + expect(message?.textContent).toBe('0 of 2 tests selected'); + }); + + it('shows message when provided', () => { + const entries = makeEntries(['a'], []); + createTestSelectionTable(container, { + entries, + onSelectionChange, + message: '1 of 100 tests selected', + }); + + const msg = container.querySelector('.test-selection-message'); + expect(msg?.textContent).toBe('1 of 100 tests selected'); + }); + + it('destroy() removes the table and cleans up click timer', () => { + const entries = makeEntries([], ['test-A']); + const handle = createTestSelectionTable(container, { entries, onSelectionChange }); + + expect(container.querySelector('.test-selection-table')).not.toBeNull(); + handle.destroy(); + expect(container.querySelector('.test-selection-table')).toBeNull(); + }); + + // --- Header "check all" checkbox --- + + it('renders header checkbox in thead', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const thead = container.querySelector('thead'); + expect(thead).not.toBeNull(); + const headerCb = thead!.querySelector('input[type="checkbox"]') as HTMLInputElement; + expect(headerCb).not.toBeNull(); + expect(headerCb.checked).toBe(false); + expect(headerCb.indeterminate).toBe(false); + }); + + it('header checkbox selects all when none selected', () => { + const entries = makeEntries([], ['test-A', 'test-B', 'test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + headerCb.click(); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.size).toBe(3); + expect(sel.has('test-A')).toBe(true); + expect(sel.has('test-B')).toBe(true); + expect(sel.has('test-C')).toBe(true); + }); + + it('header checkbox deselects all when all selected', () => { + const entries = makeEntries(['test-A', 'test-B'], []); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + expect(headerCb.checked).toBe(true); + headerCb.click(); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.size).toBe(0); + }); + + it('header checkbox shows indeterminate when some selected', () => { + const entries = makeEntries(['test-A'], ['test-B', 'test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + expect(headerCb.checked).toBe(false); + expect(headerCb.indeterminate).toBe(true); + }); + + it('header checkbox selects all when in indeterminate state', () => { + const entries = makeEntries(['test-A'], ['test-B', 'test-C']); + createTestSelectionTable(container, { entries, onSelectionChange }); + + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + headerCb.click(); + + const sel = onSelectionChange.mock.calls[0][0] as Set<string>; + expect(sel.size).toBe(3); + }); + + it('header checkbox updates after update() call', () => { + const entries = makeEntries([], ['test-A', 'test-B']); + const handle = createTestSelectionTable(container, { entries, onSelectionChange }); + + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + expect(headerCb.checked).toBe(false); + expect(headerCb.indeterminate).toBe(false); + + // Update to all selected + handle.update(makeEntries(['test-A', 'test-B'], [])); + expect(headerCb.checked).toBe(true); + expect(headerCb.indeterminate).toBe(false); + + // Update to partial + handle.update(makeEntries(['test-A'], ['test-B'])); + expect(headerCb.checked).toBe(false); + expect(headerCb.indeterminate).toBe(true); + }); +}); + +describe('setFilter (display:none fast path)', () => { + let container: HTMLElement; + + beforeEach(() => { container = document.createElement('div'); }); + + it('hides non-matching rows via display:none', () => { + const entries = makeEntries(['test-A'], ['test-B', 'other-C']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + handle.setFilter('test'); + + const rowA = container.querySelector<HTMLElement>('tr[data-test="test-A"]'); + const rowB = container.querySelector<HTMLElement>('tr[data-test="test-B"]'); + const rowC = container.querySelector<HTMLElement>('tr[data-test="other-C"]'); + expect(rowA!.style.display).toBe(''); + expect(rowB!.style.display).toBe(''); + expect(rowC!.style.display).toBe('none'); + + handle.destroy(); + }); + + it('shows all rows when filter is cleared', () => { + const entries = makeEntries(['test-A'], ['test-B', 'other-C']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + handle.setFilter('test'); + handle.setFilter(''); + + const rows = container.querySelectorAll<HTMLElement>('tr[data-test]'); + for (const tr of rows) { + expect(tr.style.display).toBe(''); + } + + handle.destroy(); + }); + + it('hides all rows on invalid regex', () => { + const entries = makeEntries(['test-A'], ['test-B']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + handle.setFilter('re:invalid['); + + const rows = container.querySelectorAll<HTMLElement>('tr[data-test]'); + for (const tr of rows) { + expect(tr.style.display).toBe('none'); + } + + handle.destroy(); + }); + + it('header checkbox reflects only visible entries', () => { + const entries = makeEntries(['test-A', 'test-B'], ['other-C']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + // All 3 entries, 2 selected → indeterminate + const headerCb = container.querySelector('thead input[type="checkbox"]') as HTMLInputElement; + expect(headerCb.indeterminate).toBe(true); + + // Filter to only "test-" rows → 2 visible, both selected → checked + handle.setFilter('test'); + expect(headerCb.checked).toBe(true); + expect(headerCb.indeterminate).toBe(false); + + handle.destroy(); + }); + + it('filter persists across update() calls', () => { + const entries = makeEntries(['test-A'], ['other-B']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + handle.setFilter('test'); + + // Update with new entries — filter should still be applied + handle.update(makeEntries(['test-X', 'test-Y'], ['other-Z'])); + + const rowX = container.querySelector<HTMLElement>('tr[data-test="test-X"]'); + const rowZ = container.querySelector<HTMLElement>('tr[data-test="other-Z"]'); + expect(rowX!.style.display).toBe(''); + expect(rowZ!.style.display).toBe('none'); + + handle.destroy(); + }); + + it('updates checkbox state from entry data (prevents stale checkboxes)', () => { + const entries = makeEntries(['test-A', 'test-B'], ['other-C']); + const handle = createTestSelectionTable(container, { + entries, onSelectionChange: vi.fn(), + }); + + // Update entries: deselect test-B + handle.update(makeEntries(['test-A'], ['test-B', 'other-C'])); + + // Apply filter to show only test- rows + handle.setFilter('test'); + + const cbB = container.querySelector<HTMLElement>('tr[data-test="test-B"] input[type="checkbox"]') as HTMLInputElement; + expect(cbB.checked).toBe(false); + + handle.destroy(); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/time-series-chart.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/time-series-chart.test.ts new file mode 100644 index 000000000..831a82161 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/time-series-chart.test.ts @@ -0,0 +1,833 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { buildPlotlyData, createTimeSeriesChart } from '../../../pages/graph/time-series-chart'; +import type { TimeSeriesTrace, TimeSeriesChartOptions } from '../../../pages/graph/time-series-chart'; +import { TRACE_SEP } from '../../../utils'; + +function makeTrace(name: string, points: Array<{ commit: string; value: number }>, machine = 'm1'): TimeSeriesTrace { + return { + testName: name, + machine, + points: points.map(p => ({ ...p, runCount: 1, submitted_at: null })), + }; +} + +describe('buildPlotlyData', () => { + it('builds one Plotly trace per test', () => { + const opts: TimeSeriesChartOptions = { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.5 }, { commit: '101', value: 2.0 }]), + makeTrace('test-B', [{ commit: '100', value: 3.0 }]), + ], + yAxisLabel: 'exec_time', + }; + + const { data } = buildPlotlyData(opts); + expect(data).toHaveLength(2); + const trace0 = data[0] as { x: string[]; y: number[]; name: string }; + expect(trace0.name).toBe(`test-A${TRACE_SEP}m1`); + expect(trace0.x).toEqual(['100', '101']); + expect(trace0.y).toEqual([1.5, 2.0]); + }); + + it('sets x-axis to category type', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('t', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }; + + const { layout } = buildPlotlyData(opts); + expect((layout as { xaxis: { type: string } }).xaxis.type).toBe('category'); + }); + + it('includes customdata for hover template', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.5 }])], + yAxisLabel: 'metric', + }; + + const { data } = buildPlotlyData(opts); + const trace = data[0] as { customdata: string[][] }; + expect(trace.customdata[0][0]).toBe('100'); // commit + expect(trace.customdata[0][1]).toBe(`test-A${TRACE_SEP}m1`); // traceName + expect(trace.customdata[0][4]).toBe('test-A'); // testName + expect(trace.customdata[0][5]).toBe('m1'); // machine + }); + + it('generates reference commit traces with hover', () => { + const refValues = new Map<string, number>(); + refValues.set('test-A', 2.5); + + const mainTrace = makeTrace('test-A', [{ commit: '100', value: 1.5 }, { commit: '102', value: 2.0 }]); + mainTrace.color = '#1f77b4'; + const opts: TimeSeriesChartOptions = { + traces: [mainTrace], + yAxisLabel: 'metric', + baselines: [{ + label: '101 (release-18)', + values: refValues, + }], + }; + + const { data } = buildPlotlyData(opts); + // 1 main trace + 1 reference trace + expect(data).toHaveLength(2); + const refTrace = data[1] as { + x: string[]; y: number[]; mode: string; + line: { dash: string; color: string }; + showlegend: boolean; hovertemplate: string; + }; + expect(refTrace.y).toEqual([2.5, 2.5]); + expect(refTrace.mode).toBe('lines'); + expect(refTrace.line.dash).toBe('dot'); + expect(refTrace.line.color).toBe('#1f77b4'); + expect(refTrace.showlegend).toBe(false); + expect(refTrace.hovertemplate).toContain('Baseline: 101 (release-18)'); + expect(refTrace.hovertemplate).toContain('test-A'); + expect(refTrace.hovertemplate).toContain('2.500'); + }); + + it('HTML-escapes user-controlled values in baseline hover templates', () => { + const refValues = new Map<string, number>(); + refValues.set('<script>alert("xss")</script>', 3.0); + + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('<script>alert("xss")</script>', [{ commit: '100', value: 1.5 }])], + yAxisLabel: 'metric', + baselines: [{ + label: '101 (<img onerror=alert(1)>)', + values: refValues, + }], + }; + + const { data } = buildPlotlyData(opts); + const refTrace = data[1] as { hovertemplate: string }; + // Verify that HTML special characters are escaped + expect(refTrace.hovertemplate).not.toContain('<script>'); + expect(refTrace.hovertemplate).not.toContain('<img'); + expect(refTrace.hovertemplate).toContain('<script>'); + expect(refTrace.hovertemplate).toContain('<img onerror=alert(1)>'); + }); + + it('uses all scaffold categories for reference trace x-values', () => { + const refValues = new Map<string, number>(); + refValues.set('test-A', 2.5); + + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '102', value: 1.5 }, { commit: '103', value: 2.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102', '103', '104', '105'], + baselines: [{ + label: '101', + values: refValues, + }], + }; + + const { data } = buildPlotlyData(opts); + const refTrace = data[1] as { x: string[]; y: number[] }; + // x-values include every scaffold category (for hover detection along the line) + expect(refTrace.x).toEqual(['100', '101', '102', '103', '104', '105']); + expect(refTrace.y).toEqual([2.5, 2.5, 2.5, 2.5, 2.5, 2.5]); + }); + + it('skips reference traces for tests not in traces', () => { + const refValues = new Map<string, number>(); + refValues.set('nonexistent-test', 5.0); + + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + baselines: [{ + label: '100', + values: refValues, + }], + }; + + const { data } = buildPlotlyData(opts); + // Only the main trace, no reference trace (test not found) + expect(data).toHaveLength(1); + }); + + it('hides legend when only one trace', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('t', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }; + + const { layout } = buildPlotlyData(opts); + expect((layout as { showlegend: boolean }).showlegend).toBe(false); + }); + + it('sets categoryarray when categoryOrder is provided', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('t', [{ commit: '102', value: 1.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102', '103'], + }; + + const { layout } = buildPlotlyData(opts); + const xaxis = (layout as { xaxis: Record<string, unknown> }).xaxis; + expect(xaxis.categoryorder).toBe('array'); + expect(xaxis.categoryarray).toEqual(['100', '101', '102', '103']); + }); + + it('does not set categoryarray when categoryOrder is omitted', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('t', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }; + + const { layout } = buildPlotlyData(opts); + const xaxis = (layout as { xaxis: Record<string, unknown> }).xaxis; + expect(xaxis.categoryorder).toBeUndefined(); + expect(xaxis.categoryarray).toBeUndefined(); + }); + + it('always hides built-in legend (replaced by test-selection table)', () => { + const opts: TimeSeriesChartOptions = { + traces: [ + makeTrace('t1', [{ commit: '100', value: 1.0 }]), + makeTrace('t2', [{ commit: '100', value: 2.0 }]), + ], + yAxisLabel: 'metric', + }; + + const { layout } = buildPlotlyData(opts); + expect((layout as { showlegend: boolean }).showlegend).toBe(false); + }); +}); + +// =========================================================================== +// createTimeSeriesChart +// =========================================================================== + +describe('createTimeSeriesChart', () => { + let mockNewPlot: ReturnType<typeof vi.fn>; + let mockReact: ReturnType<typeof vi.fn>; + let mockPurge: ReturnType<typeof vi.fn>; + let mockRestyle: ReturnType<typeof vi.fn>; + let mockAddTraces: ReturnType<typeof vi.fn>; + let mockDeleteTraces: ReturnType<typeof vi.fn>; + let mockRelayout: ReturnType<typeof vi.fn>; + + beforeEach(() => { + // Mock Plotly on globalThis + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn(); + + mockNewPlot = vi.fn().mockResolvedValue(mockGd); + mockReact = vi.fn().mockResolvedValue(mockGd); + mockPurge = vi.fn(); + mockRestyle = vi.fn(); + mockAddTraces = vi.fn(); + mockDeleteTraces = vi.fn(); + mockRelayout = vi.fn(); + + vi.stubGlobal('Plotly', { + newPlot: mockNewPlot, + react: mockReact, + purge: mockPurge, + restyle: mockRestyle, + addTraces: mockAddTraces, + deleteTraces: mockDeleteTraces, + relayout: mockRelayout, + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('calls Plotly.newPlot on creation', () => { + const container = document.createElement('div'); + createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + expect(mockNewPlot).toHaveBeenCalledTimes(1); + expect(mockReact).not.toHaveBeenCalled(); + }); + + it('calls Plotly.react on update()', async () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 2.0 }])], + yAxisLabel: 'metric', + }); + + // react() is chained after newPlot() resolves — flush microtasks + await new Promise(r => setTimeout(r, 0)); + + expect(mockNewPlot).toHaveBeenCalledTimes(1); + expect(mockReact).toHaveBeenCalledTimes(1); + }); + + it('calls Plotly.purge on destroy()', () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + handle.destroy(); + + expect(mockPurge).toHaveBeenCalledTimes(1); + }); + + it('shows "No data to plot" for zero traces', () => { + const container = document.createElement('div'); + createTimeSeriesChart(container, { + traces: [], + yAxisLabel: 'metric', + }); + + expect(container.textContent).toContain('No data to plot'); + expect(mockNewPlot).not.toHaveBeenCalled(); + }); + + it('replaces "No data" message with chart on update with data', () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [], + yAxisLabel: 'metric', + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + expect(mockNewPlot).toHaveBeenCalledTimes(1); + expect(container.querySelector('.graph-chart')).not.toBeNull(); + }); + + it('preserves x-axis range on update()', async () => { + const container = document.createElement('div'); + + // Mock newPlot to set .layout on the chart div (simulating Plotly behavior) + mockNewPlot.mockImplementation((div: HTMLElement) => { + (div as unknown as { layout: unknown }).layout = { + xaxis: { range: [10, 50], autorange: false }, + yaxis: { autorange: true }, + }; + const gd = div as unknown as { on: ReturnType<typeof vi.fn> }; + gd.on = vi.fn(); + return Promise.resolve(div); + }); + + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102'], + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 2.0 }, { commit: '101', value: 3.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102'], + }); + + await new Promise(r => setTimeout(r, 0)); + + expect(mockReact).toHaveBeenCalledTimes(1); + const layout = mockReact.mock.calls[0][2] as { xaxis: { range: unknown; autorange: unknown } }; + expect(layout.xaxis.range).toEqual([10, 50]); + expect(layout.xaxis.autorange).toBe(false); + }); + + it('preserves y-axis range when user has zoomed (autorange=false)', async () => { + const container = document.createElement('div'); + + mockNewPlot.mockImplementation((div: HTMLElement) => { + (div as unknown as { layout: unknown }).layout = { + xaxis: { range: [-0.5, 2.5], autorange: false }, + yaxis: { range: [1.0, 5.0], autorange: false }, + }; + const gd = div as unknown as { on: ReturnType<typeof vi.fn> }; + gd.on = vi.fn(); + return Promise.resolve(div); + }); + + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 2.0 }])], + yAxisLabel: 'metric', + }); + + await new Promise(r => setTimeout(r, 0)); + + const layout = mockReact.mock.calls[0][2] as { yaxis: { range: unknown; autorange: unknown } }; + expect(layout.yaxis.range).toEqual([1.0, 5.0]); + expect(layout.yaxis.autorange).toBe(false); + }); + + it('does not set y-axis range when autorange is true (no user zoom)', async () => { + const container = document.createElement('div'); + + mockNewPlot.mockImplementation((div: HTMLElement) => { + (div as unknown as { layout: unknown }).layout = { + xaxis: { range: [-0.5, 2.5], autorange: false }, + yaxis: { autorange: true }, + }; + const gd = div as unknown as { on: ReturnType<typeof vi.fn> }; + gd.on = vi.fn(); + return Promise.resolve(div); + }); + + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 2.0 }])], + yAxisLabel: 'metric', + }); + + await new Promise(r => setTimeout(r, 0)); + + const layout = mockReact.mock.calls[0][2] as { yaxis: { range?: unknown; autorange?: unknown } }; + // yaxis should NOT have an explicit range — let Plotly auto-range + expect(layout.yaxis.autorange).toBeUndefined(); + expect(layout.yaxis.range).toBeUndefined(); + }); + + it('does not set explicit ranges after zoom reset (autorange=true on both axes)', async () => { + const container = document.createElement('div'); + + mockNewPlot.mockImplementation((div: HTMLElement) => { + // Simulate state after double-click zoom reset + (div as unknown as { layout: unknown }).layout = { + xaxis: { range: [-0.5, 2.5], autorange: true }, + yaxis: { autorange: true }, + }; + const gd = div as unknown as { on: ReturnType<typeof vi.fn> }; + gd.on = vi.fn(); + return Promise.resolve(div); + }); + + const handle = createTimeSeriesChart(container, { + traces: [makeTrace('t1', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102'], + }); + + handle.update({ + traces: [makeTrace('t1', [{ commit: '100', value: 2.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101', '102'], + }); + + await new Promise(r => setTimeout(r, 0)); + + const layout = mockReact.mock.calls[0][2] as { + xaxis: { range: unknown; autorange: unknown }; + yaxis: { range?: unknown; autorange?: unknown }; + }; + // X-axis preserves whatever Plotly has (autorange=true from double-click) + expect(layout.xaxis.autorange).toBe(true); + // Y-axis should not have explicit range + expect(layout.yaxis.autorange).toBeUndefined(); + expect(layout.yaxis.range).toBeUndefined(); + }); + + it('hoverTrace() calls restyle to emphasize one trace and dim others', async () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }]), + makeTrace('test-B', [{ commit: '100', value: 2.0 }]), + makeTrace('test-C', [{ commit: '100', value: 3.0 }]), + ], + yAxisLabel: 'metric', + }); + + // Wait for newPlot to resolve + await new Promise(r => setTimeout(r, 0)); + + handle.hoverTrace(`test-B${TRACE_SEP}m1`); + await new Promise(r => setTimeout(r, 0)); + + // First call: dim all 3 traces + expect(mockRestyle).toHaveBeenCalledTimes(2); + expect(mockRestyle.mock.calls[0][1]).toEqual({ opacity: 0.2, 'line.width': 1.5 }); + expect(mockRestyle.mock.calls[0][2]).toEqual([0, 1, 2]); + // Second call: emphasize trace index 1 (test-B) + expect(mockRestyle.mock.calls[1][1]).toEqual({ opacity: 1.0, 'line.width': 3 }); + expect(mockRestyle.mock.calls[1][2]).toEqual([1]); + }); + + it('hoverTrace(null) restores all traces to normal', async () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }]), + makeTrace('test-B', [{ commit: '100', value: 2.0 }]), + ], + yAxisLabel: 'metric', + }); + + await new Promise(r => setTimeout(r, 0)); + + handle.hoverTrace(null); + await new Promise(r => setTimeout(r, 0)); + + expect(mockRestyle).toHaveBeenCalledTimes(1); + expect(mockRestyle.mock.calls[0][1]).toEqual({ opacity: 1.0, 'line.width': 1.5 }); + expect(mockRestyle.mock.calls[0][2]).toEqual([0, 1]); + }); + + it('hoverTrace() with array emphasizes multiple traces', async () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }], 'm1'), + makeTrace('test-A', [{ commit: '100', value: 1.1 }], 'm2'), + makeTrace('test-B', [{ commit: '100', value: 2.0 }], 'm1'), + ], + yAxisLabel: 'metric', + }); + + await new Promise(r => setTimeout(r, 0)); + + // Highlight both traces for test-A across two machines + handle.hoverTrace([`test-A${TRACE_SEP}m1`, `test-A${TRACE_SEP}m2`]); + await new Promise(r => setTimeout(r, 0)); + + expect(mockRestyle).toHaveBeenCalledTimes(2); + // First call: dim all 3 traces + expect(mockRestyle.mock.calls[0][1]).toEqual({ opacity: 0.2, 'line.width': 1.5 }); + expect(mockRestyle.mock.calls[0][2]).toEqual([0, 1, 2]); + // Second call: emphasize traces 0 and 1 (test-A on m1 and m2) + expect(mockRestyle.mock.calls[1][1]).toEqual({ opacity: 1.0, 'line.width': 3 }); + expect(mockRestyle.mock.calls[1][2]).toEqual([0, 1]); + }); + + it('hoverTrace([]) restores all traces (same as null)', async () => { + const container = document.createElement('div'); + const handle = createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }]), + makeTrace('test-B', [{ commit: '100', value: 2.0 }]), + ], + yAxisLabel: 'metric', + }); + + await new Promise(r => setTimeout(r, 0)); + + handle.hoverTrace([]); + await new Promise(r => setTimeout(r, 0)); + + expect(mockRestyle).toHaveBeenCalledTimes(1); + expect(mockRestyle.mock.calls[0][1]).toEqual({ opacity: 1.0, 'line.width': 1.5 }); + expect(mockRestyle.mock.calls[0][2]).toEqual([0, 1]); + }); + + it('hoverTrace() dims reference-commit traces along with non-hovered main traces', async () => { + const container = document.createElement('div'); + const refValues = new Map<string, number>(); + refValues.set('test-A', 5.0); + + const handle = createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }]), + makeTrace('test-B', [{ commit: '100', value: 2.0 }]), + ], + yAxisLabel: 'metric', + baselines: [{ + label: '100', values: refValues, + }], + }); + + await new Promise(r => setTimeout(r, 0)); + + handle.hoverTrace(`test-A${TRACE_SEP}m1`); + await new Promise(r => setTimeout(r, 0)); + + // 2 main traces + 1 reference trace = 3 total + expect(mockRestyle.mock.calls[0][1]).toEqual({ opacity: 0.2, 'line.width': 1.5 }); + expect(mockRestyle.mock.calls[0][2]).toEqual([0, 1, 2]); + // Emphasize only trace 0 (test-A · m1) + expect(mockRestyle.mock.calls[1][1]).toEqual({ opacity: 1.0, 'line.width': 3 }); + expect(mockRestyle.mock.calls[1][2]).toEqual([0]); + }); + + it('shows scatter trace on hover when getRawValues returns >1 values', async () => { + const container = document.createElement('div'); + // Capture the gd.on handlers + const handlers = new Map<string, Function>(); + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn( + (evt: string, cb: Function) => { handlers.set(evt, cb); }, + ); + mockNewPlot.mockResolvedValue(mockGd); + + createTimeSeriesChart(container, { + traces: [ + { testName: 'test-A', machine: 'm1', color: '#1f77b4', points: [{ commit: '100', value: 2.0, runCount: 3, submitted_at: null }] }, + ], + yAxisLabel: 'metric', + getRawValues: (_test, _machine, _commit) => [1.0, 2.0, 3.0], + }); + + await new Promise(r => setTimeout(r, 0)); + + // Simulate hover + const hoverHandler = handlers.get('plotly_hover'); + expect(hoverHandler).toBeDefined(); + hoverHandler!({ points: [{ customdata: ['100', `test-A${TRACE_SEP}m1`, '2.000', '3', 'test-A', 'm1'], curveNumber: 0, pointNumber: 0 }] }); + await new Promise(r => setTimeout(r, 0)); + + expect(mockAddTraces).toHaveBeenCalledTimes(1); + const scatter = mockAddTraces.mock.calls[0][1]; + expect(scatter.x).toEqual(['100', '100', '100']); + expect(scatter.y).toEqual([1.0, 2.0, 3.0]); + expect(scatter.mode).toBe('markers'); + expect(scatter.marker.color).toBe('#1f77b4'); + expect(scatter.marker.opacity).toBe(0.3); + expect(scatter.showlegend).toBe(false); + expect(scatter.hoverinfo).toBe('skip'); + }); + + it('does not show scatter when getRawValues returns <=1 values', async () => { + const container = document.createElement('div'); + const handlers = new Map<string, Function>(); + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn( + (evt: string, cb: Function) => { handlers.set(evt, cb); }, + ); + mockNewPlot.mockResolvedValue(mockGd); + + createTimeSeriesChart(container, { + traces: [ + { testName: 'test-A', machine: 'm1', color: '#1f77b4', points: [{ commit: '100', value: 1.0, runCount: 1, submitted_at: null }] }, + ], + yAxisLabel: 'metric', + getRawValues: () => [1.0], + }); + + await new Promise(r => setTimeout(r, 0)); + + handlers.get('plotly_hover')!({ points: [{ customdata: ['100', `test-A${TRACE_SEP}m1`, '1.000', '1', 'test-A', 'm1'], curveNumber: 0, pointNumber: 0 }] }); + await new Promise(r => setTimeout(r, 0)); + + expect(mockAddTraces).not.toHaveBeenCalled(); + }); + + it('removes scatter trace on unhover', async () => { + const container = document.createElement('div'); + const handlers = new Map<string, Function>(); + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn( + (evt: string, cb: Function) => { handlers.set(evt, cb); }, + ); + mockNewPlot.mockResolvedValue(mockGd); + + createTimeSeriesChart(container, { + traces: [ + { testName: 'test-A', machine: 'm1', color: '#1f77b4', points: [{ commit: '100', value: 2.0, runCount: 3, submitted_at: null }] }, + ], + yAxisLabel: 'metric', + getRawValues: () => [1.0, 2.0, 3.0], + }); + + await new Promise(r => setTimeout(r, 0)); + + // Hover to add scatter + handlers.get('plotly_hover')!({ points: [{ customdata: ['100', `test-A${TRACE_SEP}m1`, '2.000', '3', 'test-A', 'm1'], curveNumber: 0, pointNumber: 0 }] }); + await new Promise(r => setTimeout(r, 0)); + expect(mockAddTraces).toHaveBeenCalledTimes(1); + + // Unhover to remove scatter + handlers.get('plotly_unhover')!(); + await new Promise(r => setTimeout(r, 0)); + expect(mockDeleteTraces).toHaveBeenCalledTimes(1); + expect(mockDeleteTraces.mock.calls[0][1]).toEqual([-1]); + }); + + it('does not add scatter when getRawValues is not provided', async () => { + const container = document.createElement('div'); + const handlers = new Map<string, Function>(); + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn( + (evt: string, cb: Function) => { handlers.set(evt, cb); }, + ); + mockNewPlot.mockResolvedValue(mockGd); + + createTimeSeriesChart(container, { + traces: [ + { testName: 'test-A', machine: 'm1', color: '#1f77b4', points: [{ commit: '100', value: 2.0, runCount: 3, submitted_at: null }] }, + ], + yAxisLabel: 'metric', + // no getRawValues + }); + + await new Promise(r => setTimeout(r, 0)); + + handlers.get('plotly_hover')!({ points: [{ customdata: ['100', `test-A${TRACE_SEP}m1`, '2.000', '3', 'test-A', 'm1'], curveNumber: 0, pointNumber: 0 }] }); + await new Promise(r => setTimeout(r, 0)); + + expect(mockAddTraces).not.toHaveBeenCalled(); + }); +}); + +// --------------------------------------------------------------------------- +// displayMap option +// --------------------------------------------------------------------------- + +describe('buildPlotlyData with displayMap', () => { + it('maps trace x values and categoryarray to display values', () => { + const displayMap = new Map([['100', 'v1.0'], ['101', 'v2.0']]); + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.5 }, { commit: '101', value: 2.0 }])], + yAxisLabel: 'metric', + categoryOrder: ['100', '101'], + displayMap, + }; + + const { data, layout } = buildPlotlyData(opts); + const trace = data[0] as { x: string[] }; + expect(trace.x).toEqual(['v1.0', 'v2.0']); + + const xaxis = (layout as { xaxis: { categoryarray: string[] } }).xaxis; + expect(xaxis.categoryarray).toEqual(['v1.0', 'v2.0']); + }); + + it('puts display value in customdata[6] and raw commit in customdata[0]', () => { + const displayMap = new Map([['100', 'v1.0']]); + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.5 }])], + yAxisLabel: 'metric', + displayMap, + }; + + const { data } = buildPlotlyData(opts); + const trace = data[0] as { customdata: string[][] }; + expect(trace.customdata[0][0]).toBe('100'); // raw commit + expect(trace.customdata[0][6]).toBe('v1.0'); // display value + }); + + it('uses raw commit when displayMap has no entry', () => { + const displayMap = new Map([['100', 'v1.0']]); + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.5 }, { commit: '999', value: 2.0 }])], + yAxisLabel: 'metric', + displayMap, + }; + + const { data } = buildPlotlyData(opts); + const trace = data[0] as { x: string[]; customdata: string[][] }; + expect(trace.x[1]).toBe('999'); // unmapped commit stays raw + expect(trace.customdata[1][6]).toBe('999'); + }); + + it('uses identity when no displayMap provided', () => { + const opts: TimeSeriesChartOptions = { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.5 }])], + yAxisLabel: 'metric', + }; + + const { data } = buildPlotlyData(opts); + const trace = data[0] as { x: string[]; customdata: string[][] }; + expect(trace.x[0]).toBe('100'); + expect(trace.customdata[0][6]).toBe('100'); + }); +}); + +// --------------------------------------------------------------------------- +// Double-click detection +// --------------------------------------------------------------------------- + +describe('chart double-click detection', () => { + let mockNewPlot: ReturnType<typeof vi.fn>; + + function makeClickEvent(testName: string, commit = '100') { + return { + points: [{ + customdata: [commit, `${testName}${TRACE_SEP}m1`, '1.000', '1', testName, 'm1', commit], + curveNumber: 0, + pointNumber: 0, + }], + }; + } + + beforeEach(() => { + const mockGd = document.createElement('div'); + (mockGd as unknown as { on: ReturnType<typeof vi.fn> }).on = vi.fn( + (evt: string, cb: Function) => { handlers.set(evt, cb); }, + ); + mockNewPlot = vi.fn().mockResolvedValue(mockGd); + vi.stubGlobal('Plotly', { + newPlot: mockNewPlot, + react: vi.fn(), + purge: vi.fn(), + restyle: vi.fn(), + addTraces: vi.fn(), + deleteTraces: vi.fn(), + relayout: vi.fn(), + }); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + handlers.clear(); + dblClickEvents.length = 0; + }); + + const handlers = new Map<string, Function>(); + const dblClickEvents: string[] = []; + const dblClickListener = ((e: CustomEvent) => { + dblClickEvents.push(e.detail); + }) as EventListener; + + beforeEach(() => { + document.addEventListener('graph-chart-dblclick', dblClickListener); + }); + + afterEach(() => { + document.removeEventListener('graph-chart-dblclick', dblClickListener); + }); + + it('dispatches GRAPH_CHART_DBLCLICK on rapid double-click of same test', async () => { + const container = document.createElement('div'); + createTimeSeriesChart(container, { + traces: [makeTrace('test-A', [{ commit: '100', value: 1.0 }])], + yAxisLabel: 'metric', + }); + await new Promise(r => setTimeout(r, 0)); + + const clickHandler = handlers.get('plotly_click')!; + clickHandler(makeClickEvent('test-A')); + clickHandler(makeClickEvent('test-A')); + + expect(dblClickEvents).toEqual(['test-A']); + }); + + it('does not fire dblclick when clicking different tests', async () => { + const container = document.createElement('div'); + createTimeSeriesChart(container, { + traces: [ + makeTrace('test-A', [{ commit: '100', value: 1.0 }]), + makeTrace('test-B', [{ commit: '100', value: 2.0 }]), + ], + yAxisLabel: 'metric', + }); + await new Promise(r => setTimeout(r, 0)); + + const clickHandler = handlers.get('plotly_click')!; + clickHandler(makeClickEvent('test-A')); + clickHandler(makeClickEvent('test-B')); + + expect(dblClickEvents).toEqual([]); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/traces.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/traces.test.ts new file mode 100644 index 000000000..c5021309e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/graph/traces.test.ts @@ -0,0 +1,481 @@ +// @vitest-environment jsdom +import { describe, it, expect } from 'vitest'; +import { + buildTraces, + buildBaselinesFromData, + buildChartData, + buildColorMap, + buildRegressionOverlays, + buildRawValuesCallback, + assignSymbol, + assignSymbolChar, + MACHINE_SYMBOLS, + SYMBOL_CHARS, +} from '../../../pages/graph/traces'; +import type { QueryDataPoint, RegressionListItem } from '../../../types'; + +function makePoint(test: string, commitValue: string, value: number, runUuid = 'r1', machine = 'm1'): QueryDataPoint { + return { + test, + machine, + metric: 'exec_time', + value, + commit: commitValue, + ordinal: null, + run_uuid: runUuid, + tag: null, + submitted_at: null, + }; +} + +// ---- buildTraces ---- + +describe('buildTraces', () => { + it('groups points by test name into separate traces', () => { + const points = [ + makePoint('test-A', '100', 1.0), + makePoint('test-A', '101', 2.0), + makePoint('test-B', '100', 3.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces).toHaveLength(2); + expect(traces[0].testName).toBe('test-A'); + expect(traces[0].points).toHaveLength(2); + expect(traces[1].testName).toBe('test-B'); + expect(traces[1].points).toHaveLength(1); + }); + + it('handles pre-filtered input (single test)', () => { + const points = [ + makePoint('compile/test-A', '100', 1.0), + makePoint('compile/test-A', '101', 2.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces).toHaveLength(1); + expect(traces[0].testName).toBe('compile/test-A'); + }); + + it('aggregates multiple runs at same commit using run aggregation', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 3.0, 'r2'), + makePoint('test-A', '100', 5.0, 'r3'), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces[0].points[0].value).toBe(3.0); + expect(traces[0].points[0].runCount).toBe(3); + }); + + it('uses mean aggregation when specified', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 3.0, 'r2'), + ]; + const traces = buildTraces(points, 'mean', 'median'); + expect(traces[0].points[0].value).toBe(2.0); + }); + + it('applies sample aggregation within a single run', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 3.0, 'r1'), + makePoint('test-A', '100', 5.0, 'r1'), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces[0].points[0].value).toBe(3.0); + expect(traces[0].points[0].runCount).toBe(1); + }); + + it('applies sampleAgg then runAgg in two steps', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 3.0, 'r1'), + makePoint('test-A', '100', 10.0, 'r2'), + makePoint('test-A', '100', 20.0, 'r2'), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces[0].points[0].value).toBe(8.5); + expect(traces[0].points[0].runCount).toBe(2); + }); + + it('two-step aggregation differs from flat aggregation', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 1.0, 'r1'), + makePoint('test-A', '100', 100.0, 'r2'), + ]; + const traces = buildTraces(points, 'mean', 'median'); + expect(traces[0].points[0].value).toBe(50.5); // NOT 25.75 + }); + + it('returns empty array for empty input', () => { + expect(buildTraces([], 'median', 'median')).toHaveLength(0); + }); + + it('sorts traces by test name', () => { + const points = [ + makePoint('zebra', '100', 1.0), + makePoint('alpha', '100', 2.0), + makePoint('middle', '100', 3.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces.map(t => t.testName)).toEqual(['alpha', 'middle', 'zebra']); + }); + + it('preserves commit value across aggregation', () => { + const points = [ + makePoint('test-A', '100', 1.0), + makePoint('test-A', '101', 2.0), + makePoint('test-A', '102', 3.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces[0].points.map(p => p.commit)).toEqual(['100', '101', '102']); + }); + + it('preserves insertion order for reversed input', () => { + const points = [ + makePoint('test-A', '102', 3.0), + makePoint('test-A', '101', 2.0), + makePoint('test-A', '100', 1.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces[0].points.map(p => p.commit)).toEqual(['102', '101', '100']); + }); + + it('handles interleaved test data in reverse order', () => { + const points = [ + makePoint('test-A', '102', 3.0), makePoint('test-B', '102', 6.0), + makePoint('test-A', '101', 2.0), makePoint('test-B', '101', 5.0), + makePoint('test-A', '100', 1.0), makePoint('test-B', '100', 4.0), + ]; + const traces = buildTraces(points, 'median', 'median'); + expect(traces).toHaveLength(2); + expect(traces[0].testName).toBe('test-A'); + expect(traces[0].points.map(p => p.commit)).toEqual(['102', '101', '100']); + expect(traces[1].testName).toBe('test-B'); + }); +}); + +// ---- buildBaselinesFromData ---- + +describe('buildBaselinesFromData', () => { + const med = (values: number[]): number => { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; + }; + const avg = (values: number[]): number => values.reduce((s, v) => s + v, 0) / values.length; + + function buildLookup( + baselines: Array<{ suite: string; machine: string; commit: string }>, + metric: string, + pointsPerBaseline: QueryDataPoint[][], + ): (s: string, m: string, c: string, met: string) => QueryDataPoint[] { + const cache = new Map<string, QueryDataPoint[]>(); + for (let i = 0; i < baselines.length; i++) { + const bl = baselines[i]; + cache.set(`${bl.suite}::${bl.machine}::${bl.commit}::${metric}`, pointsPerBaseline[i] || []); + } + return (s, m, o, met) => cache.get(`${s}::${m}::${o}::${met}`) ?? []; + } + + const emptyLookup = () => [] as QueryDataPoint[]; + + it('aggregates multiple runs using provided agg function', () => { + const bl = [{ suite: 'nts', machine: 'm1', commit: '100' }]; + const pts = [makePoint('test-A', '100', 1.0), makePoint('test-A', '100', 3.0), makePoint('test-A', '100', 5.0)]; + const result = buildBaselinesFromData(bl, buildLookup(bl, 'exec_time', [pts]), 'exec_time', med); + expect(result[0].values.get('test-A')).toBe(3.0); + }); + + it('is consistent with buildTraces', () => { + const bl = [{ suite: 'nts', machine: 'm1', commit: '100' }]; + const pts = [makePoint('test-A', '100', 1.0), makePoint('test-A', '100', 3.0), makePoint('test-A', '100', 5.0)]; + const blResult = buildBaselinesFromData(bl, buildLookup(bl, 'exec_time', [pts]), 'exec_time', med); + const traces = buildTraces(pts, 'median', 'median'); + expect(blResult[0].values.get('test-A')).toBe(traces[0].points[0].value); + }); + + it('uses mean aggregation when provided', () => { + const bl = [{ suite: 'nts', machine: 'm1', commit: '100' }]; + const pts = [makePoint('test-A', '100', 1.0), makePoint('test-A', '100', 3.0)]; + const result = buildBaselinesFromData(bl, buildLookup(bl, 'exec_time', [pts]), 'exec_time', avg); + expect(result[0].values.get('test-A')).toBe(2.0); + }); + + it('handles single data point', () => { + const bl = [{ suite: 'nts', machine: 'm1', commit: '100' }]; + const pts = [makePoint('test-A', '100', 42.0)]; + expect(buildBaselinesFromData(bl, buildLookup(bl, 'exec_time', [pts]), 'exec_time', med)[0].values.get('test-A')).toBe(42.0); + }); + + it('handles multiple tests at same commit', () => { + const bl = [{ suite: 'nts', machine: 'm1', commit: '100' }]; + const pts = [ + makePoint('test-A', '100', 1.0), makePoint('test-A', '100', 3.0), + makePoint('test-B', '100', 10.0), makePoint('test-B', '100', 20.0), + ]; + const result = buildBaselinesFromData(bl, buildLookup(bl, 'exec_time', [pts]), 'exec_time', med); + expect(result[0].values.get('test-A')).toBe(2.0); + expect(result[0].values.get('test-B')).toBe(15.0); + }); + + it('handles multiple baselines', () => { + const bls = [ + { suite: 'nts', machine: 'm1', commit: '100' }, + { suite: 'nts', machine: 'm1', commit: '101' }, + ]; + const result = buildBaselinesFromData( + bls, + buildLookup(bls, 'exec_time', [[makePoint('test-A', '100', 1.0)], [makePoint('test-A', '101', 5.0)]]), + 'exec_time', med, + ); + expect(result).toHaveLength(2); + expect(result[0].values.get('test-A')).toBe(1.0); + expect(result[1].values.get('test-A')).toBe(5.0); + }); + + it('returns empty values map when no cached data', () => { + const result = buildBaselinesFromData([{ suite: 'nts', machine: 'm1', commit: '999' }], emptyLookup, 'exec_time', med); + expect(result[0].values.size).toBe(0); + }); + + it('returns empty array for no baselines', () => { + expect(buildBaselinesFromData([], emptyLookup, 'exec_time', med)).toHaveLength(0); + }); + + it('builds label with suite/machine/commit format', () => { + const result = buildBaselinesFromData([{ suite: 'nts', machine: 'm1', commit: '100' }], emptyLookup, 'exec_time', med); + expect(result[0].label).toBe('nts/m1/100'); + }); + + it('uses displayMap for commit in label', () => { + const dm = new Map([['100', 'v1.0']]); + const result = buildBaselinesFromData( + [{ suite: 'nts', machine: 'm1', commit: '100' }], emptyLookup, 'exec_time', med, dm, + ); + expect(result[0].label).toBe('nts/m1/v1.0'); + }); + + it('supports cross-suite baselines', () => { + const bls = [ + { suite: 'nts', machine: 'm1', commit: '100' }, + { suite: 'other', machine: 'm2', commit: '200' }, + ]; + const result = buildBaselinesFromData( + bls, + buildLookup(bls, 'exec_time', [[makePoint('test-A', '100', 1.0)], [makePoint('test-A', '200', 9.0)]]), + 'exec_time', med, + ); + expect(result[0].label).toBe('nts/m1/100'); + expect(result[1].label).toBe('other/m2/200'); + }); +}); + +// ---- buildColorMap ---- + +describe('buildColorMap', () => { + it('assigns colors by alphabetical position', () => { + const map = buildColorMap(['alpha', 'beta', 'gamma']); + expect(map.size).toBe(3); + // Same position always gets same color + const map2 = buildColorMap(['alpha', 'beta', 'gamma']); + expect(map2.get('alpha')).toBe(map.get('alpha')); + }); + + it('is stable: adding tests does not change existing colors', () => { + const small = buildColorMap(['beta', 'gamma']); + const large = buildColorMap(['alpha', 'beta', 'gamma']); + // beta is index 0 in small but index 1 in large — colors differ because + // stability is by position in the FULL list, not a subset + expect(large.get('beta')).not.toBe(small.get('beta')); + // But within the same full list, colors are stable + const large2 = buildColorMap(['alpha', 'beta', 'gamma']); + expect(large2.get('beta')).toBe(large.get('beta')); + }); +}); + +// ---- buildChartData ---- + +describe('buildChartData', () => { + function makeLookup(points: QueryDataPoint[]): (s: string, m: string, met: string, t: string) => QueryDataPoint[] { + const byKey = new Map<string, QueryDataPoint[]>(); + for (const pt of points) { + const key = `${pt.machine}::${pt.metric}::${pt.test}`; + let arr = byKey.get(key); + if (!arr) { arr = []; byKey.set(key, arr); } + arr.push(pt); + } + return (_s, m, met, t) => byKey.get(`${m}::${met}::${t}`) ?? []; + } + + it('builds traces across multiple machines', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1', 'm1'), + makePoint('test-A', '100', 2.0, 'r1', 'm2'), + ]; + const { traces } = buildChartData({ + selectedTests: new Set(['test-A']), + machines: ['m1', 'm2'], + metric: 'exec_time', + runAgg: 'median', + sampleAgg: 'median', + readCachedTestData: makeLookup(points), + suite: 'nts', + colorMap: buildColorMap(['test-A']), + }); + expect(traces).toHaveLength(2); + expect(traces[0].machine).toBe('m1'); + expect(traces[1].machine).toBe('m2'); + }); + + it('assigns different marker symbols per machine', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1', 'm1'), + makePoint('test-A', '100', 2.0, 'r1', 'm2'), + ]; + const { traces } = buildChartData({ + selectedTests: new Set(['test-A']), + machines: ['m1', 'm2'], + metric: 'exec_time', + runAgg: 'median', + sampleAgg: 'median', + readCachedTestData: makeLookup(points), + suite: 'nts', + colorMap: buildColorMap(['test-A']), + }); + expect(traces[0].markerSymbol).toBe('circle'); + expect(traces[1].markerSymbol).toBe('triangle-up'); + }); + + it('assigns same color for same test across machines', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1', 'm1'), + makePoint('test-A', '100', 2.0, 'r1', 'm2'), + ]; + const { traces } = buildChartData({ + selectedTests: new Set(['test-A']), + machines: ['m1', 'm2'], + metric: 'exec_time', + runAgg: 'median', + sampleAgg: 'median', + readCachedTestData: makeLookup(points), + suite: 'nts', + colorMap: buildColorMap(['test-A']), + }); + expect(traces[0].color).toBe(traces[1].color); + }); + + it('builds rawValuesIndex for hover scatter', () => { + const points = [ + makePoint('test-A', '100', 1.0, 'r1', 'm1'), + makePoint('test-A', '100', 3.0, 'r2', 'm1'), + ]; + const { rawValuesIndex } = buildChartData({ + selectedTests: new Set(['test-A']), + machines: ['m1'], + metric: 'exec_time', + runAgg: 'median', + sampleAgg: 'median', + readCachedTestData: makeLookup(points), + suite: 'nts', + colorMap: buildColorMap(['test-A']), + }); + expect(rawValuesIndex.get('test-A|m1|100')).toEqual([1.0, 3.0]); + }); + + it('sorts traces by name', () => { + const points = [ + makePoint('zebra', '100', 1.0, 'r1', 'm1'), + makePoint('alpha', '100', 2.0, 'r1', 'm1'), + ]; + const { traces } = buildChartData({ + selectedTests: new Set(['zebra', 'alpha']), + machines: ['m1'], + metric: 'exec_time', + runAgg: 'median', + sampleAgg: 'median', + readCachedTestData: makeLookup(points), + suite: 'nts', + colorMap: buildColorMap(['alpha', 'zebra']), + }); + expect(traces[0].testName).toBe('alpha'); + expect(traces[1].testName).toBe('zebra'); + }); +}); + +// ---- buildRawValuesCallback ---- + +describe('buildRawValuesCallback', () => { + it('returns values from index', () => { + const index = new Map([['test-A|m1|100', [1.0, 2.0, 3.0]]]); + const cb = buildRawValuesCallback(index); + expect(cb('test-A', 'm1', '100')).toEqual([1.0, 2.0, 3.0]); + }); + + it('returns empty array for missing key', () => { + const cb = buildRawValuesCallback(new Map()); + expect(cb('missing', 'm1', '100')).toEqual([]); + }); +}); + +// ---- buildRegressionOverlays ---- + +describe('buildRegressionOverlays', () => { + function makeRegression(commit: string, state: 'active' | 'detected' | 'fixed', title = 'Reg'): RegressionListItem { + return { uuid: 'r1', title, bug: null, state, commit, machine_count: 1, test_count: 1 }; + } + + it('creates shapes and annotations for each regression with a commit', () => { + const regs = [makeRegression('100', 'active'), makeRegression('101', 'detected')]; + const { shapes, annotations } = buildRegressionOverlays(regs, new Map()); + expect(shapes).toHaveLength(2); + expect(annotations).toHaveLength(2); + }); + + it('skips regressions without commits', () => { + const regs = [{ uuid: 'r1', title: 'Reg', bug: null, state: 'active' as const, commit: null, machine_count: 1, test_count: 1 }]; + const { shapes } = buildRegressionOverlays(regs, new Map()); + expect(shapes).toHaveLength(0); + }); + + it('colors active red, detected orange, others gray', () => { + const regs = [ + makeRegression('100', 'active'), + makeRegression('101', 'detected'), + makeRegression('102', 'fixed'), + ]; + const { shapes } = buildRegressionOverlays(regs, new Map()); + expect((shapes![0] as { line: { color: string } }).line.color).toBe('#d62728'); + expect((shapes![1] as { line: { color: string } }).line.color).toBe('#ff7f0e'); + expect((shapes![2] as { line: { color: string } }).line.color).toBe('#999'); + }); + + it('uses displayMap for x-axis position', () => { + const regs = [makeRegression('abc', 'active')]; + const dm = new Map([['abc', 'v1.0']]); + const { shapes } = buildRegressionOverlays(regs, dm); + expect((shapes![0] as { x0: string }).x0).toBe('v1.0'); + }); +}); + +// ---- Symbol assignment ---- + +describe('assignSymbol / assignSymbolChar', () => { + it('returns valid Plotly symbols', () => { + expect(assignSymbol(0)).toBe('circle'); + expect(assignSymbol(1)).toBe('triangle-up'); + expect(assignSymbol(2)).toBe('square'); + }); + + it('wraps around for large indices', () => { + expect(assignSymbol(MACHINE_SYMBOLS.length)).toBe('circle'); + }); + + it('returns matching unicode chars', () => { + expect(assignSymbolChar(0)).toBe('●'); + expect(assignSymbolChar(1)).toBe('▲'); + expect(assignSymbolChar(SYMBOL_CHARS.length)).toBe('●'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/home.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/home.test.ts new file mode 100644 index 000000000..3b47d8132 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/home.test.ts @@ -0,0 +1,181 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getTestSuiteInfo: vi.fn(), + getRunsPage: vi.fn(), + fetchTrends: vi.fn(), + }; +}); + +// Mock router (getTestsuites) +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + getTestsuites: vi.fn(() => ['nts', 'compile-suite']), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn().mockResolvedValue(document.createElement('div')), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; +(globalThis as unknown as Record<string, unknown>).lnt_url_base = ''; + +import { getTestSuiteInfo, getRunsPage, fetchTrends } from '../../api'; +import type { CursorPageResult, TrendsDataPoint } from '../../api'; +import { homePage } from '../../pages/home'; +import type { RunInfo, TestSuiteInfo } from '../../types'; + +const mockSuiteInfo: TestSuiteInfo = { + name: 'nts', + schema: { + metrics: [ + { name: 'execution_time', type: 'real', display_name: 'Execution Time', unit: 'seconds', unit_abbrev: 's', bigger_is_better: false }, + { name: 'compile_time', type: 'real', display_name: 'Compile Time', unit: 'seconds', unit_abbrev: 's', bigger_is_better: false }, + ], + commit_fields: [{ name: 'llvm_project_revision', type: 'text' }], + machine_fields: [], + }, +}; + +const mockSuiteInfo2: TestSuiteInfo = { + name: 'compile-suite', + schema: { + metrics: [ + { name: 'score', type: 'real', display_name: 'Score', unit: null, unit_abbrev: null, bigger_is_better: true }, + ], + commit_fields: [{ name: 'revision', type: 'text' }], + machine_fields: [], + }, +}; + +const mockRuns: RunInfo[] = [ + { uuid: 'r1', machine: 'machine-a', commit: '100', submitted_at: '2026-01-01T10:00:00Z' }, + { uuid: 'r2', machine: 'machine-b', commit: '101', submitted_at: '2026-01-02T10:00:00Z' }, + { uuid: 'r3', machine: 'machine-a', commit: '102', submitted_at: '2026-01-03T10:00:00Z' }, +]; + +function mockRunsPage(items: RunInfo[], nextCursor: string | null = null): CursorPageResult<RunInfo> { + return { items, nextCursor }; +} + +const mockTrendsData: TrendsDataPoint[] = [ + { machine: 'machine-a', commit: '100', ordinal: 100, tag: null, submitted_at: '2026-01-01T10:00:00Z', value: 14.14 }, +]; + +let container: HTMLElement; +let savedReplaceState: typeof window.history.replaceState; + +beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + document.body.append(container); + + // Mock window.history.replaceState + savedReplaceState = window.history.replaceState; + window.history.replaceState = vi.fn(); + + // Default mock implementations + (getTestSuiteInfo as ReturnType<typeof vi.fn>).mockImplementation((suite: string) => { + if (suite === 'nts') return Promise.resolve(mockSuiteInfo); + return Promise.resolve(mockSuiteInfo2); + }); + (getRunsPage as ReturnType<typeof vi.fn>).mockResolvedValue(mockRunsPage(mockRuns)); + (fetchTrends as ReturnType<typeof vi.fn>).mockResolvedValue(mockTrendsData); +}); + +afterEach(() => { + if (homePage.unmount) homePage.unmount(); + container.remove(); + window.history.replaceState = savedReplaceState; +}); + +describe('Dashboard page', () => { + it('renders a Dashboard heading', async () => { + homePage.mount(container, { testsuite: '' }); + + expect(container.querySelector('h2')?.textContent).toBe('Dashboard'); + }); + + it('renders a suite section header for each test suite', async () => { + homePage.mount(container, { testsuite: '' }); + + const h3s = container.querySelectorAll('h3'); + expect(h3s.length).toBe(2); + expect(h3s[0].textContent).toBe('nts'); + expect(h3s[1].textContent).toBe('compile-suite'); + }); + + it('renders commit range buttons with Last 500 active by default', () => { + homePage.mount(container, { testsuite: '' }); + + const buttons = container.querySelectorAll('.dashboard-range-btn'); + expect(buttons.length).toBe(3); + expect(buttons[0].textContent).toBe('Last 100'); + expect(buttons[1].textContent).toBe('Last 500'); + expect(buttons[2].textContent).toBe('Last 1000'); + expect(buttons[1].classList.contains('dashboard-range-btn-active')).toBe(true); + expect(buttons[0].classList.contains('dashboard-range-btn-active')).toBe(false); + }); + + it('renders sparkline cards with correct metric titles after data loads', async () => { + homePage.mount(container, { testsuite: '' }); + + // Wait for async data loading to complete + await vi.waitFor(() => { + const titles = container.querySelectorAll('.sparkline-title'); + expect(titles.length).toBeGreaterThanOrEqual(1); + }, { timeout: 500 }); + + const titles = Array.from(container.querySelectorAll('.sparkline-title')); + const titleTexts = titles.map(t => t.textContent); + expect(titleTexts).toContain('Execution Time (s)'); + expect(titleTexts).toContain('Compile Time (s)'); + expect(titleTexts).toContain('Score'); + }); + + it('fetches suite info and runs for each suite', async () => { + homePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(getTestSuiteInfo).toHaveBeenCalledWith('nts', expect.anything()); + expect(getTestSuiteInfo).toHaveBeenCalledWith('compile-suite', expect.anything()); + expect(getRunsPage).toHaveBeenCalledTimes(2); + }, { timeout: 500 }); + }); + + it('passes lastN to fetchTrends', async () => { + homePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(fetchTrends).toHaveBeenCalled(); + }, { timeout: 500 }); + + // Default range is 500, so lastN should be 500 + const call = (fetchTrends as ReturnType<typeof vi.fn>).mock.calls[0]; + expect(call[1]).toHaveProperty('lastN', 500); + }); + + it('passes trend data through to Plotly sparkline charts', async () => { + homePage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + const calls = ((globalThis as Record<string, unknown>).Plotly as Record<string, ReturnType<typeof vi.fn>>).newPlot.mock.calls; + // Find a call with non-empty trace data (not a loading placeholder) + const dataCall = calls.find( + (c: unknown[]) => Array.isArray(c[1]) && c[1].length > 0 && c[1][0].x?.length > 0, + ); + expect(dataCall).toBeTruthy(); + }, { timeout: 500 }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/machine-detail.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/machine-detail.test.ts new file mode 100644 index 000000000..20d19cf2b --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/machine-detail.test.ts @@ -0,0 +1,297 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getMachine: vi.fn(), + getMachineRuns: vi.fn(), + deleteMachine: vi.fn(), + getRegressions: vi.fn(), + }; +}); + +// Mock router navigate +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + navigate: vi.fn(), + getBasePath: vi.fn(() => '/v5/nts'), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { getMachine, getMachineRuns, deleteMachine, getRegressions } from '../../api'; +import { machineDetailPage } from '../../pages/machine-detail'; +import type { MachineInfo, MachineRunInfo, RegressionListItem } from '../../types'; + +const mockMachine: MachineInfo = { + name: 'clang-x86', + info: { hostname: 'build01', os: 'linux', arch: 'x86_64' }, +}; + +const mockRuns: MachineRunInfo[] = [ + { uuid: 'aaaaaaaa-1111-2222-3333-444444444444', commit: '100', submitted_at: '2026-01-01T10:00:00Z' }, + { uuid: 'bbbbbbbb-1111-2222-3333-444444444444', commit: '101', submitted_at: '2026-01-02T10:00:00Z' }, +]; + +function runsResponse(items: MachineRunInfo[], nextCursor: string | null = null) { + return { items, cursor: { next: nextCursor, previous: null } }; +} + +const mockRegressionItems: RegressionListItem[] = [ + { uuid: 'reg-1111', title: 'compile_time regression', bug: null, state: 'active', commit: '100', machine_count: 1, test_count: 2 }, +]; + +describe('machineDetailPage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + + (getMachine as ReturnType<typeof vi.fn>).mockResolvedValue(mockMachine); + (getMachineRuns as ReturnType<typeof vi.fn>).mockResolvedValue(runsResponse(mockRuns)); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: mockRegressionItems, + nextCursor: null, + }); + }); + + afterEach(() => { + machineDetailPage.unmount?.(); + }); + + it('renders page header with machine name', () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + expect(container.querySelector('.page-header')?.textContent).toBe('Machine: clang-x86'); + }); + + it('calls getMachine with correct testsuite and name', () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + expect(getMachine).toHaveBeenCalledWith('nts', 'clang-x86', expect.any(AbortSignal)); + }); + + it('renders metadata dl with machine info key-value pairs', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const dl = container.querySelector('.metadata-dl'); + expect(dl).toBeTruthy(); + expect(dl!.textContent).toContain('hostname'); + expect(dl!.textContent).toContain('build01'); + expect(dl!.textContent).toContain('os'); + expect(dl!.textContent).toContain('linux'); + }); + }); + + it('shows "No metadata available." when machine.info is empty', async () => { + (getMachine as ReturnType<typeof vi.fn>).mockResolvedValue({ name: 'empty-machine', info: {} }); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'empty-machine' }); + + await vi.waitFor(() => { + expect(container.querySelector('.no-results')?.textContent).toBe('No metadata available.'); + }); + }); + + it('shows error banner when getMachine fails', async () => { + (getMachine as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Not found')); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'bad-machine' }); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load machine'); + }); + }); + + it('renders View Graph and Compare as agnostic links with suite params', () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + const links = container.querySelectorAll('.action-links a'); + expect(links).toHaveLength(2); + + // View Graph — agnostic link to /v5/graph + expect(links[0].textContent).toBe('View Graph'); + const graphHref = links[0].getAttribute('href')!; + expect(graphHref).toMatch(/^\/v5\/graph\?/); + expect(graphHref).not.toContain('/v5/nts/graph'); + expect(graphHref).toContain('suite=nts'); + expect(graphHref).toContain('machine=clang-x86'); + + // Compare — agnostic link to /v5/compare + expect(links[1].textContent).toBe('Compare'); + const compareHref = links[1].getAttribute('href')!; + expect(compareHref).toMatch(/^\/v5\/compare\?/); + expect(compareHref).not.toContain('/v5/nts/compare'); + expect(compareHref).toContain('suite_a=nts'); + expect(compareHref).toContain('machine_a=clang-x86'); + }); + + it('calls getMachineRuns with correct params', () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + expect(getMachineRuns).toHaveBeenCalledWith( + 'nts', 'clang-x86', + { sort: '-submitted_at', limit: 25, cursor: undefined }, + expect.any(AbortSignal), + ); + }); + + it('renders run history table with UUID, Commit, Submitted columns', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + expect(headers).toContain('Run UUID'); + expect(headers).toContain('Commit'); + expect(headers).toContain('Submitted'); + }); + }); + + it('run UUIDs shown truncated to 8 chars as suite-scoped SPA links', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const runLink = container.querySelector('a[href*="/runs/"]') as HTMLAnchorElement; + expect(runLink).toBeTruthy(); + expect(runLink.textContent).toBe('aaaaaaaa'); + expect(runLink.href).toContain('/v5/nts/runs/'); + }); + }); + + it('pagination: Previous disabled on first page, Next present when cursor.next exists', async () => { + (getMachineRuns as ReturnType<typeof vi.fn>).mockResolvedValue( + runsResponse(mockRuns, 'next-cursor-1'), + ); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const buttons = container.querySelectorAll('.pagination-btn'); + const prevBtn = Array.from(buttons).find(b => b.textContent?.includes('Previous')) as HTMLButtonElement | undefined; + const nextBtn = Array.from(buttons).find(b => b.textContent?.includes('Next')) as HTMLButtonElement | undefined; + // First page: Previous exists but is disabled (cursorStack empty) + expect(prevBtn?.disabled).toBe(true); + expect(nextBtn).toBeTruthy(); + expect(nextBtn!.disabled).toBe(false); + }); + }); + + it('shows "No runs found." when no runs', async () => { + (getMachineRuns as ReturnType<typeof vi.fn>).mockResolvedValue(runsResponse([])); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + expect(container.textContent).toContain('No runs found.'); + }); + }); + + it('shows error banner when getMachineRuns fails', async () => { + (getMachineRuns as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Server error')); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const banners = container.querySelectorAll('.error-banner'); + const runBanner = Array.from(banners).find(b => b.textContent?.includes('Failed to load runs')); + expect(runBanner).toBeTruthy(); + }); + }); + + it('renders delete confirmation section', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + const deleteBtn = container.querySelector('.action-links .admin-btn-danger'); + expect(deleteBtn).toBeTruthy(); + expect(deleteBtn!.textContent).toContain('Delete Machine'); + }); + + it('post-deletion redirects to suite-agnostic test-suites page', async () => { + (deleteMachine as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); + const originalLocation = window.location; + const assignMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignMock }, + writable: true, + configurable: true, + }); + + try { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + // Click the "Delete Machine" button to reveal confirmation + const deleteBtn = container.querySelector('.admin-btn-danger') as HTMLButtonElement; + deleteBtn.click(); + + // Type the machine name to enable confirm + const confirmInput = container.querySelector('.delete-machine-confirm input') as HTMLInputElement; + confirmInput.value = 'clang-x86'; + confirmInput.dispatchEvent(new Event('input')); + + // Click "Confirm Delete" + const confirmBtn = Array.from(container.querySelectorAll('.delete-machine-confirm .admin-btn-danger')) + .find(b => b.textContent?.includes('Confirm')) as HTMLButtonElement; + confirmBtn.click(); + + await vi.waitFor(() => { + expect(deleteMachine).toHaveBeenCalledWith('nts', 'clang-x86'); + // Should redirect to suite-agnostic test-suites page (not suite-scoped /machines) + expect(assignMock).toHaveBeenCalledWith('/v5/test-suites?suite=nts'); + }); + } finally { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + } + }); + + it('unmount aborts without error', () => { + (getMachine as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + (getMachineRuns as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + expect(() => machineDetailPage.unmount!()).not.toThrow(); + }); + + describe('Show all regressions link', () => { + it('"Show all regressions" link present after active regressions load', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const link = Array.from(container.querySelectorAll('a')) + .find(a => a.textContent === 'Show all regressions'); + expect(link).toBeTruthy(); + }); + }); + + it('link href points to test-suites regressions tab', async () => { + machineDetailPage.mount(container, { testsuite: 'nts', name: 'clang-x86' }); + + await vi.waitFor(() => { + const link = Array.from(container.querySelectorAll('a')) + .find(a => a.textContent === 'Show all regressions') as HTMLAnchorElement; + expect(link).toBeTruthy(); + expect(link.getAttribute('href')).toContain('/test-suites?suite=nts&tab=regressions'); + }); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/profiles.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/profiles.test.ts new file mode 100644 index 000000000..988a06745 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/profiles.test.ts @@ -0,0 +1,291 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; + +// Mock API +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getRun: vi.fn(), + getRuns: vi.fn(), + getCommits: vi.fn(), + getMachines: vi.fn(), + getProfilesForRun: vi.fn(), + getProfileMetadata: vi.fn(), + getProfileFunctions: vi.fn(), + getProfileFunctionDetail: vi.fn(), + }; +}); + +// Mock router +vi.mock('../../router', () => ({ + navigate: vi.fn(), + getTestsuites: vi.fn(() => ['nts', 'test-suite']), + getBasePath: vi.fn(() => '/v5'), + getUrlBase: vi.fn(() => ''), +})); + +// Mock machine combobox +vi.mock('../../components/machine-combobox', () => ({ + renderMachineCombobox: vi.fn((_container: HTMLElement, _opts: unknown) => ({ + destroy: vi.fn(), + getValue: vi.fn(() => ''), + clear: vi.fn(), + })), +})); + +// Mock combobox (commit picker) +vi.mock('../../components/commit-combobox', () => ({ + createCommitPicker: vi.fn((_opts: unknown) => { + const input = document.createElement('input'); + return { + element: document.createElement('div'), + input, + destroy: vi.fn(), + }; + }), +})); + +import { profilesPage } from '../../pages/profiles'; +import { + getRun, getRuns, getCommits, + getProfilesForRun, getProfileMetadata, getProfileFunctions, +} from '../../api'; +import type { RunInfo, RunDetail, ProfileListItem, ProfileMetadata } from '../../types'; + +let container: HTMLElement; +const savedLocation = window.location; + +function setUrl(search: string): void { + delete (window as unknown as Record<string, unknown>).location; + (window as unknown as Record<string, unknown>).location = { + ...savedLocation, + search, + pathname: '/v5/profiles', + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + setUrl(''); + vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + + // Default mocks + (getCommits as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([]); +}); + +afterEach(() => { + profilesPage.unmount?.(); + (window as unknown as Record<string, unknown>).location = savedLocation; +}); + +describe('profilesPage — mount', () => { + it('renders page header', () => { + profilesPage.mount(container, { testsuite: '' }); + + expect(container.querySelector('.page-header')?.textContent).toBe('Profiles'); + }); + + it('renders per-side suite selectors populated from getTestsuites()', () => { + profilesPage.mount(container, { testsuite: '' }); + + const selects = container.querySelectorAll('.profile-side select') as NodeListOf<HTMLSelectElement>; + expect(selects).toHaveLength(2); // one per side + for (const select of selects) { + const options = Array.from(select.options).map(o => o.value); + expect(options).toContain('nts'); + expect(options).toContain('test-suite'); + } + }); + + it('renders A/B picker with Side A and Side B headings', () => { + profilesPage.mount(container, { testsuite: '' }); + + const headings = container.querySelectorAll('.profile-side h3'); + expect(headings).toHaveLength(2); + expect(headings[0].textContent).toBe('Side A'); + expect(headings[1].textContent).toBe('Side B'); + }); + + it('shows "Select a suite first" when no suite is selected', () => { + profilesPage.mount(container, { testsuite: '' }); + + const messages = container.querySelectorAll('.no-results'); + const texts = Array.from(messages).map(m => m.textContent); + expect(texts).toContain('Select a suite first.'); + }); +}); + +describe('profilesPage — URL params', () => { + it('pre-selects suite from URL param', async () => { + setUrl('?suite_a=nts'); + + profilesPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + const select = container.querySelector('select') as HTMLSelectElement; + expect(select.value).toBe('nts'); + }); + }); + + it('restores side A from run_a and test_a URL params', async () => { + setUrl('?suite_a=nts&run_a=run-uuid-1&test_a=bench/foo'); + + const runDetail: RunDetail = { + uuid: 'run-uuid-1', + machine: 'machine-1', + commit: 'abc123', + submitted_at: '2025-01-01T00:00:00Z', + run_parameters: {}, + }; + const runs: RunInfo[] = [ + { uuid: 'run-uuid-1', machine: 'machine-1', commit: 'abc123', submitted_at: '2025-01-01T00:00:00Z' }, + ]; + const profiles: ProfileListItem[] = [ + { test: 'bench/foo', uuid: 'prof-uuid-1' }, + ]; + const metadata: ProfileMetadata = { + uuid: 'prof-uuid-1', + test: 'bench/foo', + run_uuid: 'run-uuid-1', + counters: { cycles: 1000 }, + disassembly_format: 'raw', + }; + + (getRun as ReturnType<typeof vi.fn>).mockResolvedValue(runDetail); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue(runs); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue(profiles); + (getProfileMetadata as ReturnType<typeof vi.fn>).mockResolvedValue(metadata); + (getProfileFunctions as ReturnType<typeof vi.fn>).mockResolvedValue({ functions: [] }); + (getCommits as ReturnType<typeof vi.fn>).mockResolvedValue([ + { commit: 'abc123', ordinal: null, fields: {} }, + ]); + + profilesPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(getRun).toHaveBeenCalledWith('nts', 'run-uuid-1', expect.anything()); + expect(getProfilesForRun).toHaveBeenCalledWith('nts', 'run-uuid-1', expect.anything()); + expect(getProfileMetadata).toHaveBeenCalledWith('nts', 'prof-uuid-1', expect.anything()); + }); + + // Verify has_profiles=true is used for commit and run loading + expect(getCommits).toHaveBeenCalledWith('nts', expect.objectContaining({ + machine: 'machine-1', + has_profiles: true, + })); + expect(getRuns).toHaveBeenCalledWith('nts', expect.objectContaining({ + machine: 'machine-1', + commit: 'abc123', + has_profiles: true, + }), expect.anything()); + }); +}); + +describe('profilesPage — URL sync', () => { + it('updates URL via replaceState on suite change', async () => { + profilesPage.mount(container, { testsuite: '' }); + + // Suite selectors are per-side, inside .profile-side containers + const suiteSelects = container.querySelectorAll('.profile-side select') as NodeListOf<HTMLSelectElement>; + expect(suiteSelects.length).toBeGreaterThanOrEqual(2); + suiteSelects[0].value = 'nts'; + suiteSelects[0].dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(window.history.replaceState).toHaveBeenCalled(); + const calls = (window.history.replaceState as ReturnType<typeof vi.fn>).mock.calls; + const lastCall = calls[calls.length - 1]; + expect(lastCall?.[2]).toContain('suite_a=nts'); + }); + }); + + it('handles C++ mangled test name in URL params', async () => { + const mangledTest = 'SingleSource/Benchmarks/Dhrystone/dry'; + setUrl(`?suite_a=nts&run_a=run-uuid-1&test_a=${encodeURIComponent(mangledTest)}`); + + const runDetail = { + uuid: 'run-uuid-1', machine: 'machine-1', commit: 'abc123', + submitted_at: '2025-01-01T00:00:00Z', run_parameters: {}, + }; + (getRun as ReturnType<typeof vi.fn>).mockResolvedValue(runDetail); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([ + { uuid: 'run-uuid-1', machine: 'machine-1', commit: 'abc123', submitted_at: '2025-01-01T00:00:00Z' }, + ]); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([ + { test: mangledTest, uuid: 'prof-mangled' }, + ]); + (getProfileMetadata as ReturnType<typeof vi.fn>).mockResolvedValue({ + uuid: 'prof-mangled', test: mangledTest, run_uuid: 'run-uuid-1', + counters: { cycles: 4523891, 'branch-misses': 18742, 'cache-misses': 3201, instructions: 12847623 }, + disassembly_format: 'llvm-objdump', + }); + (getProfileFunctions as ReturnType<typeof vi.fn>).mockResolvedValue({ + functions: [ + { name: '_ZN5llvm12SelectionDAG15computeKnownBitsENS_7SDValueERKNS_3APEE', counters: { cycles: 34.2 }, length: 187 }, + { name: '_ZNSt6vectorIiSaIiEE9push_backEOi', counters: { cycles: 6.1 }, length: 42 }, + ], + }); + (getCommits as ReturnType<typeof vi.fn>).mockResolvedValue([ + { commit: 'abc123', ordinal: null, fields: {} }, + ]); + + profilesPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(getProfilesForRun).toHaveBeenCalledWith('nts', 'run-uuid-1', expect.anything()); + expect(getProfileMetadata).toHaveBeenCalledWith('nts', 'prof-mangled', expect.anything()); + }); + }); +}); + +describe('profilesPage — unmount', () => { + it('unmount cleans up without errors', () => { + profilesPage.mount(container, { testsuite: '' }); + expect(() => profilesPage.unmount?.()).not.toThrow(); + }); +}); + +describe('profilesPage — no N+1 profile checks', () => { + it('does not call getProfilesForRun during mount without URL params', () => { + profilesPage.mount(container, { testsuite: '' }); + expect(getProfilesForRun).not.toHaveBeenCalled(); + }); + + it('getProfilesForRun is called only once during URL restoration (for test list)', async () => { + setUrl('?suite_a=nts&run_a=run-uuid-1&test_a=bench/foo'); + + (getRun as ReturnType<typeof vi.fn>).mockResolvedValue({ + uuid: 'run-uuid-1', machine: 'machine-1', commit: 'abc123', + submitted_at: '2025-01-01T00:00:00Z', run_parameters: {}, + }); + (getRuns as ReturnType<typeof vi.fn>).mockResolvedValue([ + { uuid: 'run-uuid-1', machine: 'machine-1', commit: 'abc123', submitted_at: '2025-01-01T00:00:00Z' }, + ]); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([ + { test: 'bench/foo', uuid: 'prof-uuid-1' }, + ]); + (getProfileMetadata as ReturnType<typeof vi.fn>).mockResolvedValue({ + uuid: 'prof-uuid-1', test: 'bench/foo', run_uuid: 'run-uuid-1', + counters: { cycles: 1000 }, disassembly_format: 'raw', + }); + (getProfileFunctions as ReturnType<typeof vi.fn>).mockResolvedValue({ functions: [] }); + (getCommits as ReturnType<typeof vi.fn>).mockResolvedValue([ + { commit: 'abc123', ordinal: null, fields: {} }, + ]); + + profilesPage.mount(container, { testsuite: '' }); + + await vi.waitFor(() => { + expect(getProfileMetadata).toHaveBeenCalled(); + }); + + // getProfilesForRun should be called exactly once — for the specific + // run's test list, not for each run during cascade (N+1 eliminated). + expect(getProfilesForRun).toHaveBeenCalledTimes(1); + expect(getProfilesForRun).toHaveBeenCalledWith('nts', 'run-uuid-1', expect.anything()); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-detail.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-detail.test.ts new file mode 100644 index 000000000..a7b1984ce --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-detail.test.ts @@ -0,0 +1,1249 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getRegression: vi.fn(), + updateRegression: vi.fn(), + deleteRegression: vi.fn(), + addRegressionIndicators: vi.fn(), + removeRegressionIndicators: vi.fn(), + getFields: vi.fn(), + getMachines: vi.fn(), + getTests: vi.fn(), + getToken: vi.fn(), + authErrorMessage: vi.fn((err: unknown) => `Auth error: ${err}`), + }; +}); + +// Mock router (needed for transitive imports) +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + getBasePath: vi.fn(() => '/v5/nts'), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { + getRegression, updateRegression, deleteRegression, + removeRegressionIndicators, addRegressionIndicators, + getFields, getMachines, getTests, getToken, authErrorMessage, +} from '../../api'; +import { regressionDetailPage } from '../../pages/regression-detail'; +import type { RegressionDetail, FieldInfo } from '../../types'; + +const TEST_UUID = 'aaaa1111-2222-3333-4444-555555555555'; + +const mockRegression: RegressionDetail = { + uuid: TEST_UUID, + title: 'compile_time regression', + bug: 'https://bugs.example.com/1', + notes: 'Some notes about this regression', + state: 'detected', + commit: 'abc123', + indicators: [ + { uuid: 'ind-1111', machine: 'clang-x86', test: 'test_a', metric: 'compile_time' }, + { uuid: 'ind-2222', machine: 'gcc-arm', test: 'test_b', metric: 'execution_time' }, + ], +}; + +const mockFields: FieldInfo[] = [ + { name: 'compile_time', type: 'real', display_name: 'Compile Time', unit: null, unit_abbrev: null, bigger_is_better: null }, + { name: 'execution_time', type: 'real', display_name: 'Execution Time', unit: null, unit_abbrev: null, bigger_is_better: null }, +]; + +describe('regressionDetailPage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + + (getToken as ReturnType<typeof vi.fn>).mockReturnValue('test-token'); + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ ...mockRegression }); + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(mockFields); + (updateRegression as ReturnType<typeof vi.fn>).mockImplementation( + (_ts: string, _uuid: string, updates: Record<string, unknown>) => + Promise.resolve({ ...mockRegression, ...updates }), + ); + (getTests as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ name: 'test_x' }, { name: 'test_y' }], + nextCursor: null, + }); + (getMachines as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: [{ name: 'machine-a' }, { name: 'machine-b' }], + total: 2, + }); + }); + + afterEach(() => { + regressionDetailPage.unmount?.(); + }); + + /** Mount the page and wait for the header to render. */ + async function mountAndWait(): Promise<void> { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + await vi.waitFor(() => { + expect(container.querySelector('.regression-header')).toBeTruthy(); + }); + } + + // --------------------------------------------------------------- + // 1. Mount & rendering + // --------------------------------------------------------------- + + describe('mount & rendering', () => { + it('renders page header with truncated UUID', () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(container.querySelector('.page-header')?.textContent).toBe( + `Regression: ${TEST_UUID.slice(0, 8)}\u2026`, + ); + }); + + it('updates page header to show title after data loads', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.page-header')?.textContent).toBe( + 'Regression: compile_time regression', + ); + }); + }); + + it('shows UUID in page header when title is empty', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue( + { ...mockRegression, title: '' }, + ); + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.regression-header')).toBeTruthy(); + }); + expect(container.querySelector('.page-header')?.textContent).toBe( + `Regression: ${TEST_UUID.slice(0, 8)}\u2026`, + ); + }); + + it('calls getRegression with correct params', () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(getRegression).toHaveBeenCalledWith('nts', TEST_UUID, expect.any(AbortSignal)); + }); + + it('renders header fields after load', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const header = container.querySelector('.regression-header'); + expect(header).toBeTruthy(); + expect(header!.textContent).toContain('Title'); + expect(header!.textContent).toContain('compile_time regression'); + expect(header!.textContent).toContain('State'); + expect(header!.textContent).toContain('Bug'); + expect(header!.textContent).toContain('Commit'); + expect(header!.textContent).toContain('Notes'); + }); + }); + + it('calls getFields when token is present', () => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue('test-token'); + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(getFields).toHaveBeenCalledWith('nts', expect.any(AbortSignal)); + }); + + it('does not call getFields when token is null', () => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue(null); + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(getFields).not.toHaveBeenCalled(); + }); + }); + + // --------------------------------------------------------------- + // 2. Read-only mode + // --------------------------------------------------------------- + + describe('read-only mode (no token)', () => { + beforeEach(() => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue(null); + }); + + it('shows state as badge not dropdown', async () => { + await mountAndWait(); + + expect(container.querySelector('.state-badge')).toBeTruthy(); + expect(container.querySelector('.regression-header select')).toBeFalsy(); + }); + + it('shows no edit buttons', async () => { + await mountAndWait(); + + expect(container.querySelectorAll('.edit-btn').length).toBe(0); + }); + + it('shows no add indicators panel', async () => { + await mountAndWait(); + + expect(container.querySelector('.add-indicators-panel')?.children.length || 0).toBe(0); + }); + + it('shows no delete section', async () => { + await mountAndWait(); + + // delete section should not have been appended to the container + expect(container.querySelector('.delete-section')?.children.length || 0).toBe(0); + }); + + it('shows no checkboxes in indicator table', async () => { + await mountAndWait(); + + expect(container.querySelectorAll('.indicator-table-container input[type="checkbox"]').length).toBe(0); + }); + }); + + // --------------------------------------------------------------- + // 3. Title editing + // --------------------------------------------------------------- + + describe('title editing', () => { + it('shows Edit button, clicking opens input with Save and Cancel', async () => { + await mountAndWait(); + + const titleRow = container.querySelector('.field-row') as HTMLElement; + const editBtn = titleRow.querySelector('.edit-btn') as HTMLElement; + expect(editBtn.textContent).toBe('Edit'); + editBtn.click(); + + const input = titleRow.querySelector('input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('compile_time regression'); + + const buttons = Array.from(titleRow.querySelectorAll('button')); + expect(buttons.map(b => b.textContent)).toContain('Save'); + expect(buttons.map(b => b.textContent)).toContain('Cancel'); + }); + + it('Cancel returns to display mode without API call', async () => { + await mountAndWait(); + + const titleRow = container.querySelector('.field-row') as HTMLElement; + (titleRow.querySelector('.edit-btn') as HTMLElement).click(); + + const cancelBtn = Array.from(titleRow.querySelectorAll('button')) + .find(b => b.textContent === 'Cancel') as HTMLElement; + cancelBtn.click(); + + // Should be back to display mode + await vi.waitFor(() => { + expect(container.querySelector('.regression-header')!.textContent).toContain('compile_time regression'); + expect(container.querySelector('.regression-header')!.textContent).toContain('Edit'); + }); + expect(updateRegression).not.toHaveBeenCalled(); + }); + + it('Save calls updateRegression and re-renders on success', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + title: 'Updated title', + }); + + await mountAndWait(); + + const titleRow = container.querySelector('.field-row') as HTMLElement; + (titleRow.querySelector('.edit-btn') as HTMLElement).click(); + + const input = titleRow.querySelector('input') as HTMLInputElement; + input.value = 'Updated title'; + + const saveBtn = Array.from(titleRow.querySelectorAll('button')) + .find(b => b.textContent === 'Save') as HTMLElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { title: 'Updated title' }, + expect.any(AbortSignal), + ); + expect(container.querySelector('.regression-header')!.textContent).toContain('Updated title'); + }); + }); + + it('Save shows error on failure', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('403')); + + await mountAndWait(); + + const titleRow = container.querySelector('.field-row') as HTMLElement; + (titleRow.querySelector('.edit-btn') as HTMLElement).click(); + + const saveBtn = Array.from(titleRow.querySelectorAll('button')) + .find(b => b.textContent === 'Save') as HTMLElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + expect(container.querySelector('.error-banner')).toBeTruthy(); + }); + }); + + it('Enter key triggers save', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + title: 'Enter saved', + }); + + await mountAndWait(); + + const titleRow = container.querySelector('.field-row') as HTMLElement; + (titleRow.querySelector('.edit-btn') as HTMLElement).click(); + + const input = titleRow.querySelector('input') as HTMLInputElement; + input.value = 'Enter saved'; + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { title: 'Enter saved' }, + expect.any(AbortSignal), + ); + }); + }); + }); + + // --------------------------------------------------------------- + // 4. State editing + // --------------------------------------------------------------- + + describe('state editing', () => { + it('dropdown change calls updateRegression', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.regression-header select')).toBeTruthy(); + }); + + const stateSelect = container.querySelector('.regression-header select') as HTMLSelectElement; + stateSelect.value = 'active'; + stateSelect.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { state: 'active' }, + expect.any(AbortSignal), + ); + }); + }); + + it('reverts dropdown on error', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Fail')); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.regression-header select')).toBeTruthy(); + }); + + const stateSelect = container.querySelector('.regression-header select') as HTMLSelectElement; + stateSelect.value = 'fixed'; + stateSelect.dispatchEvent(new Event('change')); + + await vi.waitFor(() => { + expect(stateSelect.value).toBe('detected'); + expect(container.querySelector('.error-banner')).toBeTruthy(); + }); + }); + }); + + // --------------------------------------------------------------- + // 5. Bug editing + // --------------------------------------------------------------- + + describe('bug editing', () => { + it('shows Edit button, clicking opens input with Save and Cancel', async () => { + await mountAndWait(); + + const fieldRows = container.querySelectorAll('.field-row'); + const bugRow = Array.from(fieldRows).find(r => + r.querySelector('label')?.textContent === 'Bug') as HTMLElement; + + const editBtn = bugRow.querySelector('.edit-btn') as HTMLElement; + expect(editBtn.textContent).toBe('Edit'); + editBtn.click(); + + const input = bugRow.querySelector('input') as HTMLInputElement; + expect(input).toBeTruthy(); + expect(input.value).toBe('https://bugs.example.com/1'); + + const buttons = Array.from(bugRow.querySelectorAll('button')); + expect(buttons.map(b => b.textContent)).toContain('Save'); + expect(buttons.map(b => b.textContent)).toContain('Cancel'); + }); + + it('Cancel returns to display mode', async () => { + await mountAndWait(); + + const fieldRows = container.querySelectorAll('.field-row'); + const bugRow = Array.from(fieldRows).find(r => + r.querySelector('label')?.textContent === 'Bug') as HTMLElement; + + (bugRow.querySelector('.edit-btn') as HTMLElement).click(); + + const cancelBtn = Array.from(bugRow.querySelectorAll('button')) + .find(b => b.textContent === 'Cancel') as HTMLElement; + cancelBtn.click(); + + expect(bugRow.textContent).toContain('bugs.example.com'); + expect(updateRegression).not.toHaveBeenCalled(); + }); + + it('Save with empty string sends null', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + bug: null, + }); + + await mountAndWait(); + + const fieldRows = container.querySelectorAll('.field-row'); + const bugRow = Array.from(fieldRows).find(r => + r.querySelector('label')?.textContent === 'Bug') as HTMLElement; + + (bugRow.querySelector('.edit-btn') as HTMLElement).click(); + + const input = bugRow.querySelector('input') as HTMLInputElement; + input.value = ''; + + const saveBtn = Array.from(bugRow.querySelectorAll('button')) + .find(b => b.textContent === 'Save') as HTMLElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { bug: null }, + expect.any(AbortSignal), + ); + }); + }); + + it('bug link has target=_blank', async () => { + await mountAndWait(); + + const bugLink = container.querySelector( + '.regression-header a[href="https://bugs.example.com/1"]', + ) as HTMLAnchorElement; + expect(bugLink).toBeTruthy(); + expect(bugLink.getAttribute('target')).toBe('_blank'); + }); + + it('normalizes bug URL without protocol', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + bug: 'bugs.example.com/123', + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const bugLink = container.querySelector('.regression-header a') as HTMLAnchorElement; + expect(bugLink).toBeTruthy(); + expect(bugLink.getAttribute('href')).toBe('https://bugs.example.com/123'); + expect(bugLink.textContent).toContain('bugs.example.com/123'); + }); + }); + + it('leaves https:// URLs unchanged', async () => { + await mountAndWait(); + + const bugLink = container.querySelector('.regression-header a') as HTMLAnchorElement; + expect(bugLink.getAttribute('href')).toBe('https://bugs.example.com/1'); + }); + }); + + // --------------------------------------------------------------- + // 6. Commit editing + // --------------------------------------------------------------- + + describe('commit editing', () => { + it('Clear calls updateRegression with commit: null', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + commit: null, + }); + + await mountAndWait(); + + const fieldRows = container.querySelectorAll('.field-row'); + const commitRow = Array.from(fieldRows).find(r => + r.querySelector('label')?.textContent === 'Commit') as HTMLElement; + + const clearBtn = Array.from(commitRow.querySelectorAll('.edit-btn')) + .find(b => b.textContent === 'Clear') as HTMLElement; + expect(clearBtn).toBeTruthy(); + clearBtn.click(); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { commit: null }, + expect.any(AbortSignal), + ); + }); + }); + + it('Clear shows error on failure', async () => { + (updateRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('500')); + + await mountAndWait(); + + const fieldRows = container.querySelectorAll('.field-row'); + const commitRow = Array.from(fieldRows).find(r => + r.querySelector('label')?.textContent === 'Commit') as HTMLElement; + + const clearBtn = Array.from(commitRow.querySelectorAll('.edit-btn')) + .find(b => b.textContent === 'Clear') as HTMLElement; + clearBtn.click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + expect(container.querySelector('.error-banner')).toBeTruthy(); + }); + }); + }); + + // --------------------------------------------------------------- + // 7. Notes + // --------------------------------------------------------------- + + describe('notes', () => { + async function mountAndWaitForNotes() { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + await vi.waitFor(() => { + expect(container.querySelector('.regression-notes')).toBeTruthy(); + }); + } + + it('shows notes text and Edit button in display mode', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + expect(row.querySelector('.notes-display')?.textContent).toBe('Some notes about this regression'); + expect(row.querySelector('.edit-btn')).toBeTruthy(); + // No textarea visible in display mode + expect(row.querySelector('.regression-notes-input')).toBeNull(); + }); + + it('click Edit shows textarea with current notes', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + const editBtn = row.querySelector('.edit-btn') as HTMLButtonElement; + editBtn.click(); + + const textarea = row.querySelector('.regression-notes-input') as HTMLTextAreaElement; + expect(textarea).toBeTruthy(); + expect(textarea.value).toBe('Some notes about this regression'); + }); + + it('Save calls updateRegression and returns to display mode', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + (row.querySelector('.edit-btn') as HTMLButtonElement).click(); + + const textarea = row.querySelector('.regression-notes-input') as HTMLTextAreaElement; + textarea.value = 'Updated notes'; + + const saveBtn = row.querySelector('.compare-btn') as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { notes: 'Updated notes' }, + expect.any(AbortSignal), + ); + // Back to display mode + expect(row.querySelector('.notes-display')).toBeTruthy(); + expect(row.querySelector('.regression-notes-input')).toBeNull(); + }); + }); + + it('Save with empty string sends null', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + (row.querySelector('.edit-btn') as HTMLButtonElement).click(); + + const textarea = row.querySelector('.regression-notes-input') as HTMLTextAreaElement; + textarea.value = ''; + + const saveBtn = row.querySelector('.compare-btn') as HTMLButtonElement; + saveBtn.click(); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { notes: null }, + expect.any(AbortSignal), + ); + }); + }); + + it('Cancel returns to display mode without API call', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + (row.querySelector('.edit-btn') as HTMLButtonElement).click(); + + // Click Cancel + const cancelBtn = row.querySelector('.pagination-btn') as HTMLButtonElement; + cancelBtn.click(); + + expect(updateRegression).not.toHaveBeenCalled(); + expect(row.querySelector('.notes-display')).toBeTruthy(); + expect(row.querySelector('.regression-notes-input')).toBeNull(); + }); + + it('Ctrl+Enter saves notes', async () => { + await mountAndWaitForNotes(); + + const row = container.querySelector('.regression-notes')!; + (row.querySelector('.edit-btn') as HTMLButtonElement).click(); + + const textarea = row.querySelector('.regression-notes-input') as HTMLTextAreaElement; + textarea.value = 'Ctrl+Enter saved'; + textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', ctrlKey: true })); + + await vi.waitFor(() => { + expect(updateRegression).toHaveBeenCalledWith( + 'nts', TEST_UUID, + { notes: 'Ctrl+Enter saved' }, + expect.any(AbortSignal), + ); + }); + }); + }); + + // --------------------------------------------------------------- + // 8. Indicators + // --------------------------------------------------------------- + + describe('indicators', () => { + it('renders indicator rows', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const table = container.querySelector('.indicator-table-container table'); + expect(table).toBeTruthy(); + const rows = table!.querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + expect(table!.textContent).toContain('clang-x86'); + expect(table!.textContent).toContain('test_a'); + expect(table!.textContent).toContain('compile_time'); + }); + }); + + it('renders view-on-graph links', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const graphLinks = container.querySelectorAll( + '.indicator-table-container a[href*="/graph?"]', + ); + expect(graphLinks.length).toBe(2); + const href = graphLinks[0].getAttribute('href')!; + expect(href).toContain('suite=nts'); + expect(href).toContain('machine=clang-x86'); + expect(href).toContain('metric=compile_time'); + expect(href).toContain('test_filter=test_a'); + expect(href).toContain('commit=abc123'); + }); + }); + + it('single remove calls removeRegressionIndicators', async () => { + (removeRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [mockRegression.indicators[1]], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container .row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.indicator-table-container .row-delete-btn') as HTMLElement).click(); + + await vi.waitFor(() => { + expect(removeRegressionIndicators).toHaveBeenCalledWith( + 'nts', TEST_UUID, + ['ind-1111'], + expect.any(AbortSignal), + ); + }); + }); + + it('batch remove sends selected UUIDs', async () => { + (removeRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container input[type="checkbox"][data-uuid]').length).toBe(2); + }); + + // Check both checkboxes + const checkboxes = container.querySelectorAll<HTMLInputElement>( + '.indicator-table-container input[type="checkbox"][data-uuid]', + ); + checkboxes.forEach(cb => { + cb.checked = true; + cb.dispatchEvent(new Event('change')); + }); + + // Click batch remove + const removeBtn = Array.from(container.querySelectorAll('.indicator-actions button')) + .find(b => b.textContent?.includes('Remove selected')) as HTMLButtonElement; + expect(removeBtn).toBeTruthy(); + expect(removeBtn.disabled).toBe(false); + removeBtn.click(); + + await vi.waitFor(() => { + expect(removeRegressionIndicators).toHaveBeenCalledWith( + 'nts', TEST_UUID, + expect.arrayContaining(['ind-1111', 'ind-2222']), + expect.any(AbortSignal), + ); + }); + }); + + it('shows error on remove failure', async () => { + (removeRegressionIndicators as ReturnType<typeof vi.fn>).mockRejectedValue( + new Error('Server error'), + ); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container .row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.indicator-table-container .row-delete-btn') as HTMLElement).click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + expect(container.querySelector('.error-banner')).toBeTruthy(); + }); + }); + + it('shows empty state when no indicators', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container .no-results')?.textContent).toBe( + 'No indicators.', + ); + }); + }); + + it('select-all checkbox toggles all indicator checkboxes', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container input[type="checkbox"][data-uuid]').length).toBe(2); + }); + + // Find the select-all checkbox (in thead, no data-uuid) + const selectAll = container.querySelector('.indicator-table-container thead input[type="checkbox"]') as HTMLInputElement; + expect(selectAll).toBeTruthy(); + + // Check select-all + selectAll.checked = true; + selectAll.dispatchEvent(new Event('change')); + + const rowBoxes = container.querySelectorAll<HTMLInputElement>( + '.indicator-table-container input[type="checkbox"][data-uuid]'); + rowBoxes.forEach(cb => expect(cb.checked).toBe(true)); + + // Uncheck select-all + selectAll.checked = false; + selectAll.dispatchEvent(new Event('change')); + + rowBoxes.forEach(cb => expect(cb.checked).toBe(false)); + }); + + it('select-all shows indeterminate when partially selected', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container input[type="checkbox"][data-uuid]').length).toBe(2); + }); + + const selectAll = container.querySelector('.indicator-table-container thead input[type="checkbox"]') as HTMLInputElement; + + // Check only the first row checkbox + const firstBox = container.querySelector('.indicator-table-container input[type="checkbox"][data-uuid]') as HTMLInputElement; + firstBox.checked = true; + firstBox.dispatchEvent(new Event('change')); + + expect(selectAll.indeterminate).toBe(true); + expect(selectAll.checked).toBe(false); + }); + + it('shows indicator summary count in heading', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const heading = Array.from(container.querySelectorAll('h3')) + .find(h => h.textContent?.startsWith('Indicators')); + expect(heading).toBeTruthy(); + expect(heading!.textContent).toBe( + 'Indicators (2 tests across 2 machines across 2 metrics)', + ); + }); + }); + + it('shows plain heading when no indicators', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const heading = Array.from(container.querySelectorAll('h3')) + .find(h => h.textContent?.startsWith('Indicators')); + expect(heading).toBeTruthy(); + expect(heading!.textContent).toBe('Indicators'); + }); + }); + + it('excludes null machines/tests from summary count', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [ + { uuid: 'ind-1', machine: 'clang-x86', test: 'test_a', metric: 'compile_time' }, + { uuid: 'ind-2', machine: null, test: null, metric: 'execution_time' }, + ], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const heading = Array.from(container.querySelectorAll('h3')) + .find(h => h.textContent?.startsWith('Indicators')); + expect(heading).toBeTruthy(); + expect(heading!.textContent).toBe( + 'Indicators (1 test across 1 machine across 2 metrics)', + ); + }); + }); + + it('updates summary count after removing indicators', async () => { + (removeRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [mockRegression.indicators[1]], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container .row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.indicator-table-container .row-delete-btn') as HTMLElement).click(); + + await vi.waitFor(() => { + const heading = Array.from(container.querySelectorAll('h3')) + .find(h => h.textContent?.startsWith('Indicators')); + expect(heading!.textContent).toBe( + 'Indicators (1 test across 1 machine across 1 metric)', + ); + }); + }); + }); + + // --------------------------------------------------------------- + // 8b. Indicator filter + // --------------------------------------------------------------- + + describe('indicator filter', () => { + it('renders filter input', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const filterInput = container.querySelector('.indicator-filter input'); + expect(filterInput).toBeTruthy(); + expect((filterInput as HTMLInputElement).placeholder).toBe('Filter indicators...'); + }); + }); + + it('filters indicators by machine name', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'clang'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('.indicator-table-container tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('clang-x86'); + }); + }); + + it('filters indicators by test name', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'test_b'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('.indicator-table-container tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('test_b'); + }); + }); + + it('filters indicators by metric', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'execution'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('.indicator-table-container tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('execution_time'); + }); + }); + + it('is case insensitive', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'CLANG'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('.indicator-table-container tbody tr'); + expect(rows.length).toBe(1); + expect(rows[0].textContent).toContain('clang-x86'); + }); + }); + + it('shows all indicators when filter is cleared', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'clang'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container tbody tr').length).toBe(1); + }); + + filterInput.value = ''; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container tbody tr').length).toBe(2); + }); + }); + + it('shows "No matching indicators." when filter has no results', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'nonexistent'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container .no-results')?.textContent).toBe( + 'No matching indicators.', + ); + }); + }); + + it('updates heading with filtered-vs-total counts', async () => { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'clang'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + const heading = Array.from(container.querySelectorAll('h3')) + .find(h => h.textContent?.startsWith('Indicators')); + expect(heading!.textContent).toContain('showing 1 of 2 tests'); + expect(heading!.textContent).toContain('1 of 2 machines'); + expect(heading!.textContent).toContain('1 of 2 metrics'); + }); + }); + + it('batch remove only sends visible indicator UUIDs', async () => { + (removeRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [mockRegression.indicators[1]], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container table')).toBeTruthy(); + }); + + const filterInput = container.querySelector('.indicator-filter input') as HTMLInputElement; + filterInput.value = 'clang'; + filterInput.dispatchEvent(new Event('input')); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.indicator-table-container tbody tr').length).toBe(1); + }); + + const selectAll = container.querySelector('.indicator-table-container thead input[type="checkbox"]') as HTMLInputElement; + selectAll.checked = true; + selectAll.dispatchEvent(new Event('change')); + + const removeBtn = Array.from(container.querySelectorAll('.indicator-actions button')) + .find(b => b.textContent?.includes('Remove selected')) as HTMLButtonElement; + removeBtn.click(); + + await vi.waitFor(() => { + expect(removeRegressionIndicators).toHaveBeenCalledWith( + 'nts', TEST_UUID, + ['ind-1111'], + expect.any(AbortSignal), + ); + }); + }); + + it('does not show filter input when no indicators', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [], + }); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.indicator-table-container .no-results')).toBeTruthy(); + }); + + expect(container.querySelector('.indicator-filter input')).toBeFalsy(); + }); + }); + + // --------------------------------------------------------------- + // 9. Add Indicators panel + // --------------------------------------------------------------- + + describe('add indicators panel', () => { + it('shows machine checkbox list after machines load', async () => { + await mountAndWait(); + + await vi.waitFor(() => { + const machineBoxes = container.querySelectorAll('input[type="checkbox"][data-machine]'); + expect(machineBoxes.length).toBe(2); + }); + }); + + it('creates cross-product indicators for multiple machines and tests', async () => { + (addRegressionIndicators as ReturnType<typeof vi.fn>).mockResolvedValue({ + ...mockRegression, + indicators: [...mockRegression.indicators], + }); + + await mountAndWait(); + + // Wait for machines to load + await vi.waitFor(() => { + expect(container.querySelectorAll('input[type="checkbox"][data-machine]').length).toBe(2); + }); + + // Select a metric first (pick the first real metric) + const metricSelect = container.querySelector('.add-indicators-panel select') as HTMLSelectElement; + expect(metricSelect).toBeTruthy(); + metricSelect.value = 'compile_time'; + metricSelect.dispatchEvent(new Event('change')); + + // Select both machines + const machineBoxes = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-machine]'); + machineBoxes.forEach(cb => { + cb.checked = true; + cb.dispatchEvent(new Event('change')); + }); + + // Wait for tests to load + await vi.waitFor(() => { + expect(container.querySelectorAll('input[type="checkbox"][data-test]').length).toBe(2); + }); + + // Select both tests + const testBoxes = container.querySelectorAll<HTMLInputElement>('input[type="checkbox"][data-test]'); + testBoxes.forEach(cb => { + cb.checked = true; + cb.dispatchEvent(new Event('change')); + }); + + // Preview should show 4 (2 machines x 2 tests) + const preview = container.querySelector('.add-indicator-preview'); + expect(preview?.textContent).toContain('4 indicators'); + + // Click Add + const addBtn = container.querySelector('.add-indicator-actions .compare-btn') as HTMLButtonElement; + expect(addBtn.disabled).toBe(false); + addBtn.click(); + + await vi.waitFor(() => { + expect(addRegressionIndicators).toHaveBeenCalledWith( + 'nts', expect.any(String), + expect.arrayContaining([ + { machine: 'machine-a', test: 'test_x', metric: 'compile_time' }, + { machine: 'machine-a', test: 'test_y', metric: 'compile_time' }, + { machine: 'machine-b', test: 'test_x', metric: 'compile_time' }, + { machine: 'machine-b', test: 'test_y', metric: 'compile_time' }, + ]), + expect.any(AbortSignal), + ); + }); + }); + }); + + describe('delete', () => { + it('renders delete confirm section and navigates on success', async () => { + (deleteRegression as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); + const originalLocation = window.location; + const assignMock = vi.fn(); + Object.defineProperty(window, 'location', { + value: { ...window.location, assign: assignMock }, + writable: true, + configurable: true, + }); + + try { + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(container.querySelector('.delete-section .admin-btn-danger')).toBeTruthy(); + }); + + // Click the "Delete Regression" button to reveal confirmation + const deleteBtn = container.querySelector('.delete-section .admin-btn-danger') as HTMLButtonElement; + deleteBtn.click(); + + // Type the UUID prefix to enable confirm + const confirmInput = container.querySelector( + '.delete-machine-confirm input', + ) as HTMLInputElement; + confirmInput.value = TEST_UUID.slice(0, 8); + confirmInput.dispatchEvent(new Event('input')); + + // Click "Confirm Delete" + const confirmBtn = Array.from( + container.querySelectorAll('.delete-machine-confirm .admin-btn-danger'), + ).find(b => b.textContent?.includes('Confirm')) as HTMLButtonElement; + expect(confirmBtn.disabled).toBe(false); + confirmBtn.click(); + + await vi.waitFor(() => { + expect(deleteRegression).toHaveBeenCalledWith('nts', TEST_UUID, expect.any(AbortSignal)); + expect(assignMock).toHaveBeenCalledWith( + expect.stringContaining('/test-suites?suite=nts&tab=regressions')); + }); + } finally { + Object.defineProperty(window, 'location', { + value: originalLocation, + writable: true, + configurable: true, + }); + } + }); + }); + + // --------------------------------------------------------------- + // 10. Load error + // --------------------------------------------------------------- + + describe('load error', () => { + it('shows error banner when getRegression rejects', async () => { + (getRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Not found')); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load regression'); + }); + }); + }); + + // --------------------------------------------------------------- + // 11. Unmount + // --------------------------------------------------------------- + + describe('unmount', () => { + it('does not throw', () => { + (getRegression as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + (getFields as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + + regressionDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(() => regressionDetailPage.unmount!()).not.toThrow(); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-list.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-list.test.ts new file mode 100644 index 000000000..a559289c2 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/regression-list.test.ts @@ -0,0 +1,568 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getRegressions: vi.fn(), + createRegression: vi.fn(), + deleteRegression: vi.fn(), + getFields: vi.fn(), + getToken: vi.fn(), + authErrorMessage: vi.fn((err: unknown) => `Auth error: ${err}`), + }; +}); + +// Mock router (still needed for transitive imports) +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + getBasePath: vi.fn(() => '/v5/nts'), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { + getRegressions, createRegression, deleteRegression, + getFields, getToken, authErrorMessage, +} from '../../api'; +import type { CursorPageResult } from '../../api'; +import { renderRegressionTab, type RegressionTabOptions } from '../../pages/regression-list'; +import type { RegressionListItem, FieldInfo } from '../../types'; + +const mockRegressions: RegressionListItem[] = [ + { + uuid: 'aaaa1111-2222-3333-4444-555555555555', + title: 'compile_time regression on x86', + bug: 'https://bugs.example.com/1', + state: 'detected', + commit: 'abc123', + machine_count: 2, + test_count: 5, + }, + { + uuid: 'bbbb1111-2222-3333-4444-555555555555', + title: 'execution_time spike on ARM', + bug: null, + state: 'active', + commit: null, + machine_count: 1, + test_count: 3, + }, +]; + +const mockFields: FieldInfo[] = [ + { name: 'compile_time', type: 'real', display_name: 'Compile Time', unit: null, unit_abbrev: null, bigger_is_better: null }, + { name: 'execution_time', type: 'real', display_name: 'Execution Time', unit: null, unit_abbrev: null, bigger_is_better: null }, +]; + +function regressionsResponse( + items: RegressionListItem[], + nextCursor: string | null = null, +): CursorPageResult<RegressionListItem> { + return { items, nextCursor }; +} + +describe('renderRegressionTab', () => { + let container: HTMLElement; + let testController: AbortController; + let cleanupFns: (() => void)[]; + let detailLinkFn: ReturnType<typeof vi.fn>; + let navigateToDetailFn: ReturnType<typeof vi.fn>; + + function makeOpts(overrides?: Partial<RegressionTabOptions>): RegressionTabOptions { + return { + container, + testsuite: 'nts', + signal: testController.signal, + trackCleanup: (fn) => cleanupFns.push(fn), + detailLink: detailLinkFn, + navigateToDetail: navigateToDetailFn, + ...overrides, + }; + } + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + testController = new AbortController(); + cleanupFns = []; + + detailLinkFn = vi.fn((text: string, path: string) => { + const a = document.createElement('a'); + a.href = path; + a.textContent = text; + return a; + }); + navigateToDetailFn = vi.fn(); + + (getToken as ReturnType<typeof vi.fn>).mockReturnValue('test-token'); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions), + ); + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(mockFields); + }); + + afterEach(() => { + testController.abort(); + cleanupFns.forEach(fn => fn()); + cleanupFns = []; + }); + + /** Render the tab and wait for the table to appear. */ + async function renderAndWait(overrides?: Partial<RegressionTabOptions>): Promise<void> { + renderRegressionTab(makeOpts(overrides)); + await vi.waitFor(() => { + expect(container.querySelector('tbody')).toBeTruthy(); + }); + } + + // --------------------------------------------------------------- + // 1. Rendering + // --------------------------------------------------------------- + + describe('rendering', () => { + it('calls getRegressions on render', () => { + renderRegressionTab(makeOpts()); + expect(getRegressions).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ limit: 25 }), + expect.any(AbortSignal), + ); + }); + + it('renders filter panel with state chips', () => { + renderRegressionTab(makeOpts()); + const chips = container.querySelectorAll('.state-chip'); + expect(chips.length).toBe(5); + }); + + it('renders table rows for each regression', async () => { + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBe(2); + expect(container.textContent).toContain('compile_time regression on x86'); + expect(container.textContent).toContain('execution_time spike on ARM'); + }); + }); + + it('shows empty state when no regressions', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse([]), + ); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(container.textContent).toContain('No regressions found.'); + }); + }); + }); + + // --------------------------------------------------------------- + // 2. State filter chips + // --------------------------------------------------------------- + + describe('state filter chips', () => { + it('renders all 5 state chips', () => { + renderRegressionTab(makeOpts()); + + const chips = container.querySelectorAll('.state-chip'); + expect(chips.length).toBe(5); + }); + + it('clicking a chip toggles the active class and reloads', async () => { + renderRegressionTab(makeOpts()); + + // Wait for initial load + await vi.waitFor(() => { + expect(container.querySelector('tbody')).toBeTruthy(); + }); + + vi.clearAllMocks(); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions), + ); + + (container.querySelectorAll('.state-chip')[0] as HTMLElement).click(); + + // Re-query because renderStateChips replaces children + const chipsAfterClick = container.querySelectorAll('.state-chip'); + expect(chipsAfterClick[0].classList.contains('state-chip-active')).toBe(true); + expect(getRegressions).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ state: ['detected'] }), + expect.any(AbortSignal), + ); + }); + + it('clicking an active chip deselects it', async () => { + await renderAndWait(); + + // Click to activate + (container.querySelectorAll('.state-chip')[0] as HTMLElement).click(); + // Re-query: renderStateChips replaces children + expect(container.querySelectorAll('.state-chip')[0].classList.contains('state-chip-active')).toBe(true); + + vi.clearAllMocks(); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions), + ); + + // Click again to deactivate + (container.querySelectorAll('.state-chip')[0] as HTMLElement).click(); + + expect(container.querySelectorAll('.state-chip')[0].classList.contains('state-chip-active')).toBe(false); + }); + }); + + // --------------------------------------------------------------- + // 3. Title search (debounced client-side filter) + // --------------------------------------------------------------- + + describe('title search', () => { + it('filters rows client-side after 300ms debounce', async () => { + vi.useFakeTimers(); + try { + renderRegressionTab(makeOpts()); + + // Flush the getRegressions promise + await vi.waitFor(() => { + expect(container.querySelector('tbody')).toBeTruthy(); + }); + + const titleInput = container.querySelector('.title-search-input') as HTMLInputElement; + titleInput.value = 'compile'; + titleInput.dispatchEvent(new Event('input')); + + // Before debounce fires, still shows all rows + expect(container.querySelectorAll('tbody tr').length).toBe(2); + + // Advance past debounce + vi.advanceTimersByTime(300); + + await vi.waitFor(() => { + const rows = container.querySelectorAll('tbody tr'); + expect(rows.length).toBe(1); + expect(container.textContent).toContain('compile_time regression on x86'); + }); + } finally { + vi.useRealTimers(); + } + }); + }); + + // --------------------------------------------------------------- + // 4. Pagination + // --------------------------------------------------------------- + + describe('pagination', () => { + it('Previous disabled and Next enabled when nextCursor exists', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions, 'cursor-page-2'), + ); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + const buttons = container.querySelectorAll('.pagination-btn'); + const prevBtn = Array.from(buttons).find(b => b.textContent?.includes('Previous')) as HTMLButtonElement | undefined; + const nextBtn = Array.from(buttons).find(b => b.textContent?.includes('Next')) as HTMLButtonElement | undefined; + expect(prevBtn?.disabled).toBe(true); + expect(nextBtn).toBeTruthy(); + expect(nextBtn!.disabled).toBe(false); + }); + }); + + it('clicking Next passes cursor to getRegressions', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions, 'cursor-page-2'), + ); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + const nextBtn = Array.from(container.querySelectorAll('.pagination-btn')) + .find(b => b.textContent?.includes('Next')) as HTMLButtonElement; + expect(nextBtn).toBeTruthy(); + }); + + vi.clearAllMocks(); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse([], null), + ); + + const nextBtn = Array.from(container.querySelectorAll('.pagination-btn')) + .find(b => b.textContent?.includes('Next')) as HTMLButtonElement; + nextBtn.click(); + + expect(getRegressions).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ cursor: 'cursor-page-2' }), + expect.any(AbortSignal), + ); + }); + + it('Previous enabled on second page, Next disabled when no more', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions, 'cursor-page-2'), + ); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(Array.from(container.querySelectorAll('.pagination-btn')) + .find(b => b.textContent?.includes('Next'))).toBeTruthy(); + }); + + // Navigate to page 2 (no more pages) + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + regressionsResponse(mockRegressions, null), + ); + + const nextBtn = Array.from(container.querySelectorAll('.pagination-btn')) + .find(b => b.textContent?.includes('Next')) as HTMLButtonElement; + nextBtn.click(); + + await vi.waitFor(() => { + const buttons = container.querySelectorAll('.pagination-btn'); + const prevBtn2 = Array.from(buttons).find(b => b.textContent?.includes('Previous')) as HTMLButtonElement; + const nextBtn2 = Array.from(buttons).find(b => b.textContent?.includes('Next')) as HTMLButtonElement; + expect(prevBtn2?.disabled).toBe(false); + expect(nextBtn2?.disabled).toBe(true); + }); + }); + }); + + // --------------------------------------------------------------- + // 5. Auth gating + // --------------------------------------------------------------- + + describe('auth gating', () => { + it('hides create button and delete column when getToken returns null', async () => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue(null); + + await renderAndWait(); + + // No "New Regression" button + const newBtn = Array.from(container.querySelectorAll('button')) + .find(b => b.textContent === 'New Regression'); + expect(newBtn).toBeUndefined(); + + // No delete buttons in rows + expect(container.querySelectorAll('.row-delete-btn').length).toBe(0); + }); + + it('shows create button and delete column when getToken returns string', async () => { + (getToken as ReturnType<typeof vi.fn>).mockReturnValue('test-token'); + + await renderAndWait(); + + // "New Regression" button present + const newBtn = Array.from(container.querySelectorAll('button')) + .find(b => b.textContent === 'New Regression'); + expect(newBtn).toBeTruthy(); + + // Delete buttons in rows + expect(container.querySelectorAll('.row-delete-btn').length).toBe(2); + }); + }); + + // --------------------------------------------------------------- + // 6. Create form + // --------------------------------------------------------------- + + describe('create form', () => { + it('toggles form visibility on New Regression click', async () => { + await renderAndWait(); + + const formContainer = container.querySelector('.create-form-container') as HTMLElement; + expect(formContainer.style.display).toBe('none'); + + const newBtn = Array.from(container.querySelectorAll('button')) + .find(b => b.textContent === 'New Regression') as HTMLElement; + newBtn.click(); + expect(formContainer.style.display).toBe(''); + + // Click cancel to hide again + const cancelBtn = Array.from(formContainer.querySelectorAll('button')) + .find(b => b.textContent === 'Cancel') as HTMLElement; + cancelBtn.click(); + expect(formContainer.style.display).toBe('none'); + }); + + it('submit calls createRegression and navigateToDetail on success', async () => { + const createdRegression = { + uuid: 'cccc1111-2222-3333-4444-555555555555', + title: 'New reg', + bug: null, + notes: null, + state: 'detected' as const, + commit: null, + indicators: [], + }; + (createRegression as ReturnType<typeof vi.fn>).mockResolvedValue(createdRegression); + + await renderAndWait(); + + // Open form + const newBtn = Array.from(container.querySelectorAll('button')) + .find(b => b.textContent === 'New Regression') as HTMLElement; + newBtn.click(); + + const formContainer = container.querySelector('.create-form-container')!; + const titleInput = formContainer.querySelector('input[type="text"]') as HTMLInputElement; + titleInput.value = 'New reg'; + + const createBtn = Array.from(formContainer.querySelectorAll('button')) + .find(b => b.textContent === 'Create') as HTMLElement; + createBtn.click(); + + await vi.waitFor(() => { + expect(createRegression).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ title: 'New reg', state: 'detected' }), + expect.any(AbortSignal), + ); + expect(navigateToDetailFn).toHaveBeenCalledWith(createdRegression.uuid); + }); + }); + + it('shows error on createRegression failure', async () => { + (createRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('403')); + + await renderAndWait(); + + // Open form + const newBtn = Array.from(container.querySelectorAll('button')) + .find(b => b.textContent === 'New Regression') as HTMLElement; + newBtn.click(); + + const formContainer = container.querySelector('.create-form-container')!; + const createBtn = Array.from(formContainer.querySelectorAll('button')) + .find(b => b.textContent === 'Create') as HTMLElement; + createBtn.click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + const error = formContainer.querySelector('.error-banner'); + expect(error).toBeTruthy(); + }); + }); + }); + + // --------------------------------------------------------------- + // 7. Delete + // --------------------------------------------------------------- + + describe('delete', () => { + it('calls deleteRegression when window.confirm returns true', async () => { + (deleteRegression as ReturnType<typeof vi.fn>).mockResolvedValue(undefined); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.row-delete-btn') as HTMLElement).click(); + + await vi.waitFor(() => { + expect(window.confirm).toHaveBeenCalled(); + expect(deleteRegression).toHaveBeenCalledWith( + 'nts', + mockRegressions[0].uuid, + expect.any(AbortSignal), + ); + }); + }); + + it('does not call deleteRegression when window.confirm returns false', async () => { + vi.spyOn(window, 'confirm').mockReturnValue(false); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.row-delete-btn') as HTMLElement).click(); + + expect(window.confirm).toHaveBeenCalled(); + expect(deleteRegression).not.toHaveBeenCalled(); + }); + + it('shows error when deleteRegression fails', async () => { + (deleteRegression as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Server error')); + vi.spyOn(window, 'confirm').mockReturnValue(true); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(container.querySelectorAll('.row-delete-btn').length).toBe(2); + }); + + (container.querySelector('.row-delete-btn') as HTMLElement).click(); + + await vi.waitFor(() => { + expect(authErrorMessage).toHaveBeenCalled(); + expect(container.querySelector('.error-banner')).toBeTruthy(); + }); + }); + }); + + // --------------------------------------------------------------- + // 8. Error handling + // --------------------------------------------------------------- + + describe('error handling', () => { + it('shows error banner when getRegressions rejects', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Server error')); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load regressions'); + }); + }); + + it('shows error when getFields rejects', async () => { + (getFields as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Fail')); + + renderRegressionTab(makeOpts()); + + await vi.waitFor(() => { + expect(container.textContent).toContain('Failed to load metrics'); + }); + }); + + it('suppresses AbortError from getRegressions', async () => { + const abortError = new DOMException('Aborted', 'AbortError'); + (getRegressions as ReturnType<typeof vi.fn>).mockRejectedValue(abortError); + + renderRegressionTab(makeOpts()); + + // Wait for the rejected promise to settle + await vi.waitFor(() => { + // Verify no error banner appeared (AbortError is suppressed) + expect(container.querySelector('.error-banner')).toBeFalsy(); + }); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/run-detail.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/run-detail.test.ts new file mode 100644 index 000000000..24c2ad4e3 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/run-detail.test.ts @@ -0,0 +1,347 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getRun: vi.fn(), + getFields: vi.fn(), + getProfilesForRun: vi.fn(), + deleteRun: vi.fn(), + fetchOneCursorPage: vi.fn(), + apiUrl: vi.fn(), + }; +}); + +// Mock router navigate +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + navigate: vi.fn(), + getBasePath: vi.fn(() => '/v5/nts'), + getUrlBase: vi.fn(() => ''), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; + +import { getRun, getFields, getProfilesForRun, fetchOneCursorPage, apiUrl } from '../../api'; +import { runDetailPage } from '../../pages/run-detail'; +import type { RunDetail, FieldInfo, SampleInfo } from '../../types'; + +const TEST_UUID = 'abcdef01-2345-6789-abcd-ef0123456789'; + +const mockRun: RunDetail = { + uuid: TEST_UUID, + machine: 'clang-x86', + commit: '100', + submitted_at: '2026-01-01T10:00:00Z', + run_parameters: { compiler: 'clang-18', opt_level: '-O2' }, +}; + +const mockFields: FieldInfo[] = [ + { name: 'exec_time', type: 'real', display_name: 'Execution Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, + { name: 'compile_time', type: 'real', display_name: 'Compile Time', unit: 's', unit_abbrev: 's', bigger_is_better: false }, + { name: 'hash', type: 'hash', display_name: 'Hash', unit: null, unit_abbrev: null, bigger_is_better: null }, +]; + +const mockSamples: SampleInfo[] = [ + { test: 'test-A', metrics: { exec_time: 1.5, compile_time: 0.3 } }, + { test: 'test-B', metrics: { exec_time: 2.0, compile_time: 0.5 } }, + { test: 'test-C', metrics: { exec_time: 3.0, compile_time: 0.7 } }, +]; + +describe('runDetailPage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + + (getRun as ReturnType<typeof vi.fn>).mockResolvedValue(mockRun); + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(mockFields); + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([]); + (apiUrl as ReturnType<typeof vi.fn>).mockReturnValue(`/api/v5/nts/runs/${TEST_UUID}/samples`); + (fetchOneCursorPage as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: mockSamples, + nextCursor: null, + }); + }); + + afterEach(() => { + runDetailPage.unmount?.(); + }); + + it('renders page header with truncated UUID', () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + const header = container.querySelector('.page-header'); + expect(header?.textContent).toBe('Run: abcdef01\u2026'); + }); + + it('calls getRun and getFields', () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + expect(getRun).toHaveBeenCalledWith('nts', TEST_UUID); + expect(getFields).toHaveBeenCalledWith('nts'); + }); + + it('renders metadata with UUID, Machine, Commit, Submitted, parameters', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const dl = container.querySelector('.metadata-dl'); + expect(dl).toBeTruthy(); + expect(dl!.textContent).toContain('UUID'); + expect(dl!.textContent).toContain(TEST_UUID); + expect(dl!.textContent).toContain('Machine'); + expect(dl!.textContent).toContain('Commit'); + expect(dl!.textContent).toContain('Submitted'); + expect(dl!.textContent).toContain('compiler'); + expect(dl!.textContent).toContain('clang-18'); + expect(dl!.textContent).toContain('opt_level'); + expect(dl!.textContent).toContain('-O2'); + }); + }); + + it('Machine and Commit render as SPA links with suite-scoped hrefs', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const machineLink = container.querySelector('a[href*="/machines/clang-x86"]') as HTMLAnchorElement; + expect(machineLink).toBeTruthy(); + expect(machineLink.textContent).toBe('clang-x86'); + expect(machineLink.href).toContain('/v5/nts/machines/clang-x86'); + + const commitLink = container.querySelector('a[href*="/commits/100"]') as HTMLAnchorElement; + expect(commitLink).toBeTruthy(); + expect(commitLink.href).toContain('/v5/nts/commits/100'); + }); + }); + + it('renders "Compare with…" as agnostic link with suite_a param', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const link = container.querySelector('.action-links a') as HTMLAnchorElement; + expect(link).toBeTruthy(); + expect(link.textContent).toContain('Compare with'); + const href = link.getAttribute('href')!; + // Must be a suite-agnostic URL (not suite-scoped) + expect(href).toMatch(/^\/v5\/compare\?/); + expect(href).not.toContain('/v5/nts/compare'); + // Must include suite_a param + expect(href).toContain('suite_a=nts'); + expect(href).toContain('machine_a=clang-x86'); + expect(href).toContain('commit_a=100'); + expect(href).toContain(`runs_a=${encodeURIComponent(TEST_UUID)}`); + }); + }); + + it('renders metric selector with Real-type fields only', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const select = container.querySelector('.metric-select') as HTMLSelectElement; + expect(select).toBeTruthy(); + const options = Array.from(select.options).map(o => o.value); + // Should include Real fields but not Hash + expect(options).toContain('exec_time'); + expect(options).toContain('compile_time'); + expect(options).not.toContain('hash'); + }); + }); + + it('renders test filter input', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const filterInput = container.querySelector('.test-filter-input'); + expect(filterInput).toBeTruthy(); + }); + }); + + it('progressive loading: calls fetchOneCursorPage with limit=2000', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(fetchOneCursorPage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ limit: '2000' }), + expect.any(AbortSignal), + ); + }); + }); + + it('progressive loading fetches next page when cursor present', async () => { + (fetchOneCursorPage as ReturnType<typeof vi.fn>) + .mockResolvedValueOnce({ items: mockSamples.slice(0, 2), nextCursor: 'page2' }) + .mockResolvedValueOnce({ items: mockSamples.slice(2), nextCursor: null }); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(fetchOneCursorPage).toHaveBeenCalledTimes(2); + // Second call includes cursor + expect(fetchOneCursorPage).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ limit: '2000', cursor: 'page2' }), + expect.any(AbortSignal), + ); + }); + }); + + it('renders samples table with Test and Value columns', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + // Headers may include sort indicators (e.g. "Test ▲") + expect(headers.some(h => h?.startsWith('Test'))).toBe(true); + expect(headers.some(h => h?.startsWith('Value'))).toBe(true); + const rows = container.querySelectorAll('tbody tr'); + expect(rows).toHaveLength(3); + }); + }); + + it('delete confirmation uses 8-char UUID prefix as confirmValue', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + // The delete button should be in the action links row + const deleteBtn = container.querySelector('.action-links .admin-btn-danger'); + expect(deleteBtn).toBeTruthy(); + expect(deleteBtn!.textContent).toContain('Delete Run'); + }); + }); + + it('shows error banner when getRun/getFields fails', async () => { + (getRun as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Not found')); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load run'); + }); + }); + + it('shows error banner when sample loading fails (non-abort)', async () => { + (fetchOneCursorPage as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Server error')); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const banners = container.querySelectorAll('.error-banner'); + const sampleBanner = Array.from(banners).find(b => b.textContent?.includes('Failed to load samples')); + expect(sampleBanner).toBeTruthy(); + }); + }); + + it('unmount aborts in-flight fetches without error', () => { + (getRun as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + (getFields as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + expect(() => runDetailPage.unmount!()).not.toThrow(); + }); + + it('calls getProfilesForRun alongside getRun and getFields', async () => { + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + expect(getProfilesForRun).toHaveBeenCalledWith('nts', TEST_UUID, expect.anything()); + }); + }); + + it('shows Profile column when profiles exist', async () => { + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([ + { test: 'test-A', uuid: 'prof-1' }, + ]); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const ths = container.querySelectorAll('th'); + const labels = Array.from(ths).map(th => th.textContent); + expect(labels).toContain('Profile'); + }); + }); + + it('does not show Profile column when no profiles exist', async () => { + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([]); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const ths = container.querySelectorAll('th'); + const labels = Array.from(ths).map(th => th.textContent); + expect(labels).not.toContain('Profile'); + }); + }); + + it('renders profile link for tests with profiles', async () => { + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([ + { test: 'test-A', uuid: 'prof-1' }, + ]); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + const links = container.querySelectorAll('.col-profile a'); + expect(links.length).toBeGreaterThan(0); + const firstLink = links[0] as HTMLAnchorElement; + expect(firstLink.textContent).toBe('View'); + expect(firstLink.getAttribute('href')).toContain('/profiles'); + expect(firstLink.getAttribute('href')).toContain('run_a=' + TEST_UUID); + expect(firstLink.getAttribute('href')).toContain('test_a=test-A'); + expect(firstLink.getAttribute('href')).toContain('suite_a=nts'); + }); + }); + + it('does not render profile link for tests without profiles', async () => { + (getProfilesForRun as ReturnType<typeof vi.fn>).mockResolvedValue([ + { test: 'test-A', uuid: 'prof-1' }, + ]); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + // test-B and test-C should have empty profile cells + const rows = container.querySelectorAll('tbody tr'); + for (const row of rows) { + const testCell = row.querySelector('td'); + if (testCell && (testCell.textContent === 'test-B' || testCell.textContent === 'test-C')) { + const profileCell = row.querySelector('.col-profile'); + if (profileCell) { + expect(profileCell.querySelector('a')).toBeNull(); + } + } + } + }); + }); + + it('handles profile fetch failure gracefully', async () => { + (getProfilesForRun as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('network error')); + + runDetailPage.mount(container, { testsuite: 'nts', uuid: TEST_UUID }); + + await vi.waitFor(() => { + // Page should still render without error + const ths = container.querySelectorAll('th'); + const labels = Array.from(ths).map(th => th.textContent); + // No Profile column since fetch failed → empty array → size 0 + expect(labels).not.toContain('Profile'); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pages/test-suites.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pages/test-suites.test.ts new file mode 100644 index 000000000..de31057b9 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pages/test-suites.test.ts @@ -0,0 +1,502 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +// Mock the API module +vi.mock('../../api', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../api')>(); + return { + ...actual, + getMachines: vi.fn(), + getRunsPage: vi.fn(), + getCommitsPage: vi.fn(), + getRegressions: vi.fn(), + getFields: vi.fn(), + getToken: vi.fn(), + }; +}); + +// Mock router (getTestsuites) +vi.mock('../../router', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../router')>(); + return { + ...actual, + getTestsuites: vi.fn(() => ['nts', 'test-suite-2']), + }; +}); + +// Mock Plotly (may be loaded by transitive imports) +(globalThis as unknown as Record<string, unknown>).Plotly = { + newPlot: vi.fn(), + react: vi.fn(), + purge: vi.fn(), + Fx: { hover: vi.fn(), unhover: vi.fn() }, +}; +// Mock lnt_url_base +(globalThis as unknown as Record<string, unknown>).lnt_url_base = ''; + +import { getMachines, getRunsPage, getCommitsPage, getRegressions, getFields, getToken } from '../../api'; +import type { CursorPageResult } from '../../api'; +import { getTestsuites } from '../../router'; +import { testSuitesPage } from '../../pages/test-suites'; +import type { RunInfo, MachineInfo, CommitSummary, RegressionListItem } from '../../types'; + +const mockMachines: MachineInfo[] = [ + { name: 'clang-x86', info: { os: 'linux' } }, + { name: 'gcc-arm', info: { os: 'linux' } }, +]; + +const mockRuns: RunInfo[] = [ + { uuid: 'aaaa-1111', machine: 'clang-x86', commit: '100', submitted_at: '2026-01-01T10:00:00Z' }, + { uuid: 'bbbb-2222', machine: 'gcc-arm', commit: '101', submitted_at: '2026-01-02T10:00:00Z' }, +]; + +const mockCommits: CommitSummary[] = [ + { commit: '100', ordinal: 1, tag: null, fields: {} }, + { commit: '101', ordinal: null, tag: null, fields: {} }, +]; + +const mockRegressions: RegressionListItem[] = [ + { uuid: 'reg-1111', title: 'compile_time regression', bug: null, state: 'active', commit: '100', machine_count: 2, test_count: 3 }, + { uuid: 'reg-2222', title: 'exec_time regression', bug: null, state: 'detected', commit: null, machine_count: 1, test_count: 1 }, +]; + +function mockRunsPage(items: RunInfo[], nextCursor: string | null = null): CursorPageResult<RunInfo> { + return { items, nextCursor }; +} + +function mockCommitsPage(items: CommitSummary[], nextCursor: string | null = null): CursorPageResult<CommitSummary> { + return { items, nextCursor }; +} + +function mockRegressionsPage(items: RegressionListItem[], nextCursor: string | null = null): CursorPageResult<RegressionListItem> { + return { items, nextCursor }; +} + +describe('testSuitesPage', () => { + let container: HTMLElement; + + beforeEach(() => { + vi.clearAllMocks(); + container = document.createElement('div'); + + // Reset router mock + (getTestsuites as ReturnType<typeof vi.fn>).mockReturnValue(['nts', 'test-suite-2']); + + // Default mocks + (getMachines as ReturnType<typeof vi.fn>).mockResolvedValue({ + items: mockMachines, + total: mockMachines.length, + }); + (getRunsPage as ReturnType<typeof vi.fn>).mockResolvedValue( + mockRunsPage(mockRuns), + ); + (getCommitsPage as ReturnType<typeof vi.fn>).mockResolvedValue( + mockCommitsPage(mockCommits), + ); + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + mockRegressionsPage(mockRegressions), + ); + (getToken as ReturnType<typeof vi.fn>).mockReturnValue(null); + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue([]); + + // Clear URL query params + window.history.replaceState(null, '', window.location.pathname); + }); + + afterEach(() => { + testSuitesPage.unmount?.(); + }); + + it('renders Test Suites heading', () => { + testSuitesPage.mount(container, { testsuite: '' }); + expect(container.querySelector('.page-header')?.textContent).toBe('Test Suites'); + }); + + it('renders suite picker cards from getTestsuites()', () => { + testSuitesPage.mount(container, { testsuite: '' }); + + const cards = container.querySelectorAll('.suite-card'); + expect(cards).toHaveLength(2); + expect(cards[0].textContent).toBe('nts'); + expect(cards[1].textContent).toBe('test-suite-2'); + }); + + it('does not show tabs when no suite is selected', () => { + testSuitesPage.mount(container, { testsuite: '' }); + + const tabBar = container.querySelector('.v5-tab-bar') as HTMLElement; + expect(tabBar).toBeTruthy(); + expect(tabBar.style.display).toBe('none'); + }); + + it('shows tabs after clicking a suite card', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + + // Click the first suite card + const card = container.querySelector('.suite-card') as HTMLElement; + card.click(); + + const tabBar = container.querySelector('.v5-tab-bar') as HTMLElement; + expect(tabBar.style.display).not.toBe('none'); + + // Should have 5 tabs + const tabs = tabBar.querySelectorAll('.v5-tab'); + expect(tabs).toHaveLength(5); + expect(tabs[0].textContent).toBe('Recent Activity'); + expect(tabs[1].textContent).toBe('Machines'); + expect(tabs[2].textContent).toBe('Runs'); + expect(tabs[3].textContent).toBe('Commits'); + expect(tabs[4].textContent).toBe('Regressions'); + }); + + it('highlights the selected suite card', () => { + testSuitesPage.mount(container, { testsuite: '' }); + + const cards = container.querySelectorAll('.suite-card'); + (cards[0] as HTMLElement).click(); + + expect(cards[0].classList.contains('suite-card-active')).toBe(true); + expect(cards[1].classList.contains('suite-card-active')).toBe(false); + }); + + it('loads Recent Activity tab by default when suite is selected', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Recent Activity tab should be active + const activeTab = container.querySelector('.v5-tab-active'); + expect(activeTab?.textContent).toBe('Recent Activity'); + + // Should call getRunsPage for recent activity + await vi.waitFor(() => { + expect(getRunsPage).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ sort: '-submitted_at', limit: 25 }), + expect.any(AbortSignal), + ); + }); + }); + + it('Recent Activity tab renders run table', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + await vi.waitFor(() => { + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + expect(headers).toContain('Machine'); + expect(headers).toContain('Commit'); + expect(headers).toContain('Submitted'); + expect(headers).toContain('Run'); + }); + }); + + it('Machines tab loads machine list with offset pagination', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Click Machines tab + const machinesTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Machines') as HTMLElement; + machinesTab.click(); + + await vi.waitFor(() => { + expect(getMachines).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ limit: 25, offset: 0 }), + expect.any(AbortSignal), + ); + }); + + // Should show machine names + await vi.waitFor(() => { + expect(container.textContent).toContain('clang-x86'); + expect(container.textContent).toContain('gcc-arm'); + }); + }); + + it('Machines tab has search input', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Switch to Machines tab + const machinesTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Machines') as HTMLElement; + machinesTab.click(); + + await vi.waitFor(() => { + const searchInput = container.querySelector('.test-filter-input') as HTMLInputElement; + expect(searchInput).toBeTruthy(); + expect(searchInput.placeholder).toContain('Filter by name'); + }); + }); + + it('Runs tab loads runs with cursor pagination', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Click Runs tab + const runsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Runs') as HTMLElement; + runsTab.click(); + + await vi.waitFor(() => { + // Should call getRunsPage (once for Recent Activity, once for Runs tab) + const calls = (getRunsPage as ReturnType<typeof vi.fn>).mock.calls; + const runsTabCall = calls.find( + (c: unknown[]) => (c[1] as Record<string, unknown>)?.sort === '-submitted_at', + ); + expect(runsTabCall).toBeTruthy(); + }); + }); + + it('Runs tab has machine filter input', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + const runsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Runs') as HTMLElement; + runsTab.click(); + + await vi.waitFor(() => { + const searchInput = container.querySelector('.test-filter-input') as HTMLInputElement; + expect(searchInput).toBeTruthy(); + expect(searchInput.placeholder).toContain('machine'); + }); + }); + + it('Commits tab loads commits with cursor pagination', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Click Commits tab + const commitsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Commits') as HTMLElement; + commitsTab.click(); + + await vi.waitFor(() => { + expect(getCommitsPage).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ limit: 25 }), + expect.any(AbortSignal), + ); + }); + }); + + it('Commits tab shows commit values and ordinals', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Wait for Recent Activity to load first + await vi.waitFor(() => { + expect(container.querySelector('table')).toBeTruthy(); + }); + + const commitsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Commits') as HTMLElement; + commitsTab.click(); + + await vi.waitFor(() => { + // Check the table has Commit and Ordinal columns + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + expect(headers).toContain('Commit'); + expect(headers).toContain('Ordinal'); + expect(container.textContent).toContain('100'); + expect(container.textContent).toContain('1'); + expect(container.textContent).toContain('101'); + }); + }); + + it('restores state from URL query params on mount', async () => { + // Set URL with suite and tab pre-selected + window.history.replaceState(null, '', '?suite=nts&tab=machines'); + + testSuitesPage.mount(container, { testsuite: '' }); + + // Suite card should be highlighted + const activeCard = container.querySelector('.suite-card-active'); + expect(activeCard?.textContent).toBe('nts'); + + // Tabs should be visible + const tabBar = container.querySelector('.v5-tab-bar') as HTMLElement; + expect(tabBar.style.display).not.toBe('none'); + + // Machines tab should be active + const activeTab = container.querySelector('.v5-tab-active'); + expect(activeTab?.textContent).toBe('Machines'); + + // Should load machines + await vi.waitFor(() => { + expect(getMachines).toHaveBeenCalled(); + }); + }); + + it('tab switching updates the active tab class', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Default: Recent Activity is active + expect(container.querySelector('.v5-tab-active')?.textContent).toBe('Recent Activity'); + + // Click Machines tab + const machinesTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Machines') as HTMLElement; + machinesTab.click(); + + expect(container.querySelector('.v5-tab-active')?.textContent).toBe('Machines'); + }); + + it('switching suites resets to Recent Activity tab', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + + // Select first suite and switch to Machines tab + const cards = container.querySelectorAll('.suite-card'); + (cards[0] as HTMLElement).click(); + + const machinesTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Machines') as HTMLElement; + machinesTab.click(); + + // Now select second suite + (cards[1] as HTMLElement).click(); + + // Should reset to Recent Activity + expect(container.querySelector('.v5-tab-active')?.textContent).toBe('Recent Activity'); + }); + + it('unmount aborts without error', () => { + (getRunsPage as ReturnType<typeof vi.fn>).mockReturnValue(new Promise(() => {})); + testSuitesPage.mount(container, { testsuite: '' }); + expect(() => testSuitesPage.unmount!()).not.toThrow(); + }); + + it('shows empty message when no test suites available', () => { + // Override getTestsuites to return empty + (getTestsuites as ReturnType<typeof vi.fn>).mockReturnValue([]); + + testSuitesPage.mount(container, { testsuite: '' }); + + expect(container.textContent).toContain('No test suites available'); + }); + + it('shows error banner when Recent Activity fails to load', async () => { + (getRunsPage as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Network error')); + + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load recent activity'); + }); + }); + + it('shows error banner when Machines tab fails to load', async () => { + (getMachines as ReturnType<typeof vi.fn>).mockRejectedValue(new Error('Server error')); + + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Wait for Recent Activity to render, then switch to Machines + await vi.waitFor(() => expect(container.querySelector('table')).toBeTruthy()); + + const machinesTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Machines') as HTMLElement; + machinesTab.click(); + + await vi.waitFor(() => { + const banner = container.querySelector('.error-banner'); + expect(banner).toBeTruthy(); + expect(banner!.textContent).toContain('Failed to load machines'); + }); + }); + + it('shows "No recent activity" when no runs exist', async () => { + (getRunsPage as ReturnType<typeof vi.fn>).mockResolvedValue( + mockRunsPage([]), + ); + + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + await vi.waitFor(() => { + expect(container.textContent).toContain('No recent activity'); + }); + }); + + describe('Regressions tab', () => { + it('clicking Regressions tab calls getRegressions with correct suite', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + // Wait for initial tab content to load + await vi.waitFor(() => expect(container.querySelector('table')).toBeTruthy()); + + const regressionsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Regressions') as HTMLElement; + regressionsTab.click(); + + await vi.waitFor(() => { + expect(getRegressions).toHaveBeenCalledWith( + 'nts', + expect.objectContaining({ limit: 25 }), + expect.any(AbortSignal), + ); + }); + }); + + it('renders state filter chips and table rows', async () => { + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + await vi.waitFor(() => expect(container.querySelector('table')).toBeTruthy()); + + const regressionsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Regressions') as HTMLElement; + regressionsTab.click(); + + await vi.waitFor(() => { + // State chips should be present + const chips = container.querySelectorAll('.state-chip'); + expect(chips.length).toBe(5); + + // Table columns + const headers = Array.from(container.querySelectorAll('th')).map(h => h.textContent); + expect(headers).toContain('Title'); + expect(headers).toContain('State'); + expect(headers).toContain('Commit'); + expect(headers).toContain('Machines'); + expect(headers).toContain('Tests'); + + // Check data is rendered + expect(container.textContent).toContain('compile_time regression'); + expect(container.textContent).toContain('exec_time regression'); + + // Title should be a link using full suite-scoped URL + const titleLink = container.querySelector('a[href*="/regressions/reg-1111"]') as HTMLAnchorElement; + expect(titleLink).toBeTruthy(); + expect(titleLink.textContent).toBe('compile_time regression'); + }); + }); + + it('empty state when no regressions', async () => { + (getRegressions as ReturnType<typeof vi.fn>).mockResolvedValue( + mockRegressionsPage([]), + ); + + testSuitesPage.mount(container, { testsuite: '' }); + (container.querySelector('.suite-card') as HTMLElement).click(); + + await vi.waitFor(() => expect(container.querySelector('table')).toBeTruthy()); + + const regressionsTab = Array.from(container.querySelectorAll('.v5-tab')) + .find(t => t.textContent === 'Regressions') as HTMLElement; + regressionsTab.click(); + + await vi.waitFor(() => { + expect(container.textContent).toContain('No regressions found.'); + }); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/pagination.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/pagination.test.ts new file mode 100644 index 000000000..81ca31a07 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/pagination.test.ts @@ -0,0 +1,92 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi } from 'vitest'; +import { renderPagination } from '../components/pagination'; + +describe('renderPagination', () => { + it('renders Previous and Next buttons', () => { + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: true, + hasNext: true, + onPrevious: vi.fn(), + onNext: vi.fn(), + }); + + const buttons = container.querySelectorAll('button'); + expect(buttons).toHaveLength(2); + expect(buttons[0].textContent).toContain('Previous'); + expect(buttons[1].textContent).toContain('Next'); + }); + + it('disables Previous when hasPrevious is false', () => { + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: false, + hasNext: true, + onPrevious: vi.fn(), + onNext: vi.fn(), + }); + + const prevBtn = container.querySelector('button') as HTMLButtonElement; + expect(prevBtn.disabled).toBe(true); + }); + + it('disables Next when hasNext is false', () => { + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: true, + hasNext: false, + onPrevious: vi.fn(), + onNext: vi.fn(), + }); + + const buttons = container.querySelectorAll('button'); + const nextBtn = buttons[1] as HTMLButtonElement; + expect(nextBtn.disabled).toBe(true); + }); + + it('calls onPrevious when Previous is clicked', () => { + const onPrev = vi.fn(); + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: true, + hasNext: true, + onPrevious: onPrev, + onNext: vi.fn(), + }); + + const prevBtn = container.querySelector('button') as HTMLButtonElement; + prevBtn.click(); + expect(onPrev).toHaveBeenCalledTimes(1); + }); + + it('calls onNext when Next is clicked', () => { + const onNext = vi.fn(); + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: true, + hasNext: true, + onPrevious: vi.fn(), + onNext, + }); + + const buttons = container.querySelectorAll('button'); + (buttons[1] as HTMLButtonElement).click(); + expect(onNext).toHaveBeenCalledTimes(1); + }); + + it('displays range text when provided', () => { + const container = document.createElement('div'); + renderPagination(container, { + hasPrevious: false, + hasNext: true, + onPrevious: vi.fn(), + onNext: vi.fn(), + rangeText: '1\u201325 of 100', + }); + + const range = container.querySelector('.pagination-range'); + expect(range).not.toBeNull(); + expect(range!.textContent).toBe('1\u201325 of 100'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/regression-combobox.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/regression-combobox.test.ts new file mode 100644 index 000000000..29390549e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/regression-combobox.test.ts @@ -0,0 +1,457 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../api', () => ({ + getRegressions: vi.fn(), +})); + +import { renderRegressionCombobox } from '../components/regression-combobox'; +import { getRegressions } from '../api'; + +const mockGetRegressions = getRegressions as ReturnType<typeof vi.fn>; + +const REGRESSIONS = [ + { uuid: 'uuid-1111', title: 'Compile time regression', state: 'detected', machine_count: 1, test_count: 2 }, + { uuid: 'uuid-2222', title: 'Runtime perf drop', state: 'detected', machine_count: 1, test_count: 1 }, + { uuid: 'uuid-3333', title: null, state: 'detected', machine_count: 1, test_count: 1 }, +]; + +beforeEach(() => { + vi.useFakeTimers(); + mockGetRegressions.mockReset(); + document.body.innerHTML = ''; +}); + +afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); +}); + +/** Create the combobox and resolve the initial regression list fetch. */ +async function createAndLoad( + items: typeof REGRESSIONS, + opts?: Partial<Parameters<typeof renderRegressionCombobox>[1]>, +): Promise<{ container: HTMLElement; input: HTMLInputElement; handle: ReturnType<typeof renderRegressionCombobox> }> { + mockGetRegressions.mockResolvedValue({ items, next: null, previous: null }); + const container = document.createElement('div'); + document.body.append(container); + const handle = renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn(), ...opts }); + // Resolve the initial fetch + await vi.advanceTimersByTimeAsync(0); + const input = container.querySelector('input') as HTMLInputElement; + return { container, input, handle }; +} + +describe('renderRegressionCombobox', () => { + // --- Rendering & structure --- + + describe('Rendering & structure', () => { + it('renders combobox structure with correct ARIA roles', async () => { + const { container } = await createAndLoad(REGRESSIONS); + const wrapper = container.querySelector('.combobox'); + expect(wrapper).not.toBeNull(); + expect(wrapper!.getAttribute('role')).toBe('combobox'); + const input = wrapper!.querySelector('input'); + expect(input).not.toBeNull(); + const dropdown = wrapper!.querySelector('ul'); + expect(dropdown).not.toBeNull(); + expect(dropdown!.getAttribute('role')).toBe('listbox'); + }); + + it('wrapper has role="combobox" and aria-expanded="false" on render', async () => { + const { container } = await createAndLoad(REGRESSIONS); + const wrapper = container.querySelector('.combobox') as HTMLElement; + expect(wrapper.getAttribute('role')).toBe('combobox'); + expect(wrapper.getAttribute('aria-expanded')).toBe('false'); + }); + + it('input has role="searchbox" and aria-controls matching dropdown id', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + expect(input.getAttribute('role')).toBe('searchbox'); + const dropdown = container.querySelector('ul') as HTMLElement; + expect(input.getAttribute('aria-controls')).toBe(dropdown.id); + }); + }); + + // --- Data fetching --- + + describe('Data fetching', () => { + it('fetches regressions on creation with limit 500', async () => { + mockGetRegressions.mockResolvedValue({ items: REGRESSIONS, next: null, previous: null }); + const container = document.createElement('div'); + document.body.append(container); + renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + expect(mockGetRegressions).toHaveBeenCalledTimes(1); + expect(mockGetRegressions).toHaveBeenCalledWith('nts', { limit: 500 }, expect.anything()); + }); + + it('shows "Loading regressions..." before fetch resolves', () => { + mockGetRegressions.mockReturnValue(new Promise(() => {})); // never resolves + const container = document.createElement('div'); + document.body.append(container); + renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + const input = container.querySelector('input') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('Loading regressions...'); + }); + + it('does not fetch when testsuite is empty and disables input', () => { + mockGetRegressions.mockResolvedValue({ items: [], next: null, previous: null }); + const container = document.createElement('div'); + renderRegressionCombobox(container, { testsuite: '', onSelect: vi.fn() }); + + expect(mockGetRegressions).not.toHaveBeenCalled(); + const input = container.querySelector('input') as HTMLInputElement; + expect(input.disabled).toBe(true); + expect(input.placeholder).toBe('Select a suite first'); + }); + + it('shows "Failed to load regressions" when fetch rejects', async () => { + mockGetRegressions.mockRejectedValue(new Error('Network error')); + const container = document.createElement('div'); + document.body.append(container); + renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + // Resolve the rejected promise + await vi.advanceTimersByTimeAsync(0); + + const input = container.querySelector('input') as HTMLInputElement; + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('Failed to load regressions'); + }); + }); + + // --- Dropdown display --- + + describe('Dropdown display', () => { + it('shows all regressions on focus after fetch resolves', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + expect(items).toHaveLength(3); + }); + + it('aria-expanded becomes "true" when dropdown opens on focus', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const wrapper = container.querySelector('.combobox') as HTMLElement; + expect(wrapper.getAttribute('aria-expanded')).toBe('true'); + }); + + it('shows "No regressions found" when API returns empty list', async () => { + const { container, input } = await createAndLoad([]); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li'); + expect(items.length).toBe(1); + expect(items[0].textContent).toBe('No regressions found'); + }); + + it('untitled regressions display "(untitled) {short-uuid}"', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li.combobox-item'); + // Third item is untitled + expect(items[2].textContent).toBe('(untitled) uuid-333'); + }); + }); + + // --- Filtering --- + + describe('Filtering', () => { + it('filters by title substring on input', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.value = 'compile'; + input.dispatchEvent(new Event('input')); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Compile time regression'); + }); + + it('re: regex filter mode works', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.value = 're:^Runtime'; + input.dispatchEvent(new Event('input')); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + expect(items).toHaveLength(1); + expect(items[0].textContent).toContain('Runtime perf drop'); + }); + + it('shows combobox-invalid when no regressions match filter', async () => { + const { input } = await createAndLoad(REGRESSIONS); + + input.value = 'nonexistent-pattern-xyz'; + input.dispatchEvent(new Event('input')); + + expect(input.classList.contains('combobox-invalid')).toBe(true); + }); + }); + + // --- Selection --- + + describe('Selection', () => { + it('calls onSelect with uuid and title on item click', async () => { + const onSelect = vi.fn(); + const { container, input } = await createAndLoad(REGRESSIONS, { onSelect }); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + + expect(onSelect).toHaveBeenCalledWith('uuid-1111', 'Compile time regression'); + }); + + it('sets input value to title on selection', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + + expect(input.value).toContain('Compile time regression'); + }); + + it('closes dropdown on item click (aria-expanded="false")', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const wrapper = container.querySelector('.combobox') as HTMLElement; + expect(wrapper.getAttribute('aria-expanded')).toBe('true'); + + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + + expect(wrapper.getAttribute('aria-expanded')).toBe('false'); + }); + + it('clears internal selectedUuid when user types after selecting', async () => { + const { container, input, handle } = await createAndLoad(REGRESSIONS); + + // Select an item + input.dispatchEvent(new Event('focus')); + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + expect(handle.getValue()).toBe('uuid-1111'); + + // User types in input — clears selection + input.value = 'something else'; + input.dispatchEvent(new Event('input')); + + expect(handle.getValue()).toBe(''); + }); + }); + + // --- Keyboard --- + + describe('Keyboard', () => { + it('ArrowDown from input focuses first dropdown item', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.value = 'compile'; + input.dispatchEvent(new Event('input')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + const firstItem = dropdown.querySelector('li.combobox-item[tabindex]') as HTMLElement; + const focusSpy = vi.spyOn(firstItem, 'focus'); + + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown' })); + expect(focusSpy).toHaveBeenCalled(); + }); + + it('ArrowDown/ArrowUp within dropdown moves focus', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const items = container.querySelectorAll('li.combobox-item[tabindex]'); + const focusSpy0 = vi.spyOn(items[0] as HTMLElement, 'focus'); + const focusSpy1 = vi.spyOn(items[1] as HTMLElement, 'focus'); + + // ArrowDown from first to second + (items[0] as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })); + expect(focusSpy1).toHaveBeenCalled(); + + // ArrowUp from second back to first + (items[1] as HTMLElement).dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })); + expect(focusSpy0).toHaveBeenCalled(); + }); + + it('Enter on dropdown item selects it', async () => { + const onSelect = vi.fn(); + const { container, input } = await createAndLoad(REGRESSIONS, { onSelect }); + + input.dispatchEvent(new Event('focus')); + + const firstItem = container.querySelector('li.combobox-item[tabindex]') as HTMLElement; + firstItem.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + + expect(onSelect).toHaveBeenCalledWith('uuid-1111', 'Compile time regression'); + }); + + it('Escape closes dropdown and returns focus to input', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.value = 'compile'; + input.dispatchEvent(new Event('input')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + // Escape from input closes dropdown (same pattern as machine-combobox) + input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(dropdown.classList.contains('open')).toBe(false); + }); + }); + + // --- Dismiss --- + + describe('Dismiss', () => { + it('closes dropdown on blur (aria-expanded="false")', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const wrapper = container.querySelector('.combobox') as HTMLElement; + expect(wrapper.getAttribute('aria-expanded')).toBe('true'); + + input.dispatchEvent(new Event('blur')); + + expect(wrapper.getAttribute('aria-expanded')).toBe('false'); + }); + + it('keeps dropdown open when focus moves within wrapper (relatedTarget)', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const dropdown = container.querySelector('ul') as HTMLElement; + expect(dropdown.classList.contains('open')).toBe(true); + + const firstItem = dropdown.querySelector('li.combobox-item') as HTMLElement; + input.dispatchEvent(new FocusEvent('blur', { relatedTarget: firstItem })); + + expect(dropdown.classList.contains('open')).toBe(true); + }); + + it('outside click closes dropdown', async () => { + const { container, input } = await createAndLoad(REGRESSIONS); + + input.dispatchEvent(new Event('focus')); + + const wrapper = container.querySelector('.combobox') as HTMLElement; + expect(wrapper.getAttribute('aria-expanded')).toBe('true'); + + // Click outside + document.body.dispatchEvent(new MouseEvent('click', { bubbles: true })); + + expect(wrapper.getAttribute('aria-expanded')).toBe('false'); + }); + }); + + // --- Lifecycle --- + + describe('Lifecycle', () => { + it('onClear fires on blur-with-empty-input (via change event)', async () => { + const onClear = vi.fn(); + const { input } = await createAndLoad(REGRESSIONS, { onClear }); + + input.value = ''; + input.dispatchEvent(new Event('change')); + expect(onClear).toHaveBeenCalled(); + }); + + it('onClear does not throw when not provided', async () => { + mockGetRegressions.mockResolvedValue({ items: [], next: null, previous: null }); + const container = document.createElement('div'); + document.body.append(container); + renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + await vi.advanceTimersByTimeAsync(0); + + const input = container.querySelector('input') as HTMLInputElement; + input.value = ''; + expect(() => input.dispatchEvent(new Event('change'))).not.toThrow(); + }); + + it('onClear does not fire when input has text on blur', async () => { + const onClear = vi.fn(); + const { input } = await createAndLoad(REGRESSIONS, { onClear }); + + input.value = 'some text'; + input.dispatchEvent(new Event('change')); + expect(onClear).not.toHaveBeenCalled(); + }); + + it('getValue() returns UUID after selection, empty string after clear()', async () => { + const { container, input, handle } = await createAndLoad(REGRESSIONS); + + // Initially empty + expect(handle.getValue()).toBe(''); + + // Select an item + input.dispatchEvent(new Event('focus')); + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + expect(handle.getValue()).toBe('uuid-1111'); + + // Clear + handle.clear(); + expect(handle.getValue()).toBe(''); + }); + + it('destroy() removes document click listener', async () => { + const { handle } = await createAndLoad(REGRESSIONS); + const spy = vi.spyOn(document, 'removeEventListener'); + handle.destroy(); + expect(spy).toHaveBeenCalledWith('click', expect.any(Function)); + }); + + it('destroy() aborts in-flight fetch without errors', () => { + mockGetRegressions.mockReturnValue(new Promise(() => {})); // never resolves + const container = document.createElement('div'); + document.body.append(container); + const handle = renderRegressionCombobox(container, { testsuite: 'nts', onSelect: vi.fn() }); + + // Should not throw even though fetch is in-flight + expect(() => handle.destroy()).not.toThrow(); + }); + + it('clear() resets input and selection', async () => { + const { container, input, handle } = await createAndLoad(REGRESSIONS); + + // Select an item + input.dispatchEvent(new Event('focus')); + const items = container.querySelectorAll('li.combobox-item[role="option"]'); + (items[0] as HTMLElement).click(); + expect(input.value).not.toBe(''); + + handle.clear(); + expect(input.value).toBe(''); + expect(handle.getValue()).toBe(''); + expect(input.classList.contains('combobox-invalid')).toBe(false); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/router.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/router.test.ts new file mode 100644 index 000000000..b60b42867 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/router.test.ts @@ -0,0 +1,184 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// We need to test the router module. Since it uses global state, +// we import fresh each test by resetting modules. +let routerModule: typeof import('../router'); + +beforeEach(async () => { + // Reset the router's internal state by re-importing + vi.resetModules(); + routerModule = await import('../router'); + + // Reset DOM + document.body.innerHTML = ''; + + // Mock history API + vi.spyOn(window.history, 'pushState').mockImplementation(() => {}); +}); + +describe('addRoute + resolve', () => { + it('matches an exact path and mounts the module', () => { + const container = document.createElement('div'); + const mount = vi.fn(); + const module: import('../router').PageModule = { mount }; + + routerModule.addRoute('/', module); + + // Set window.location to the correct path + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + + expect(mount).toHaveBeenCalledTimes(1); + expect(mount.mock.calls[0][0]).toBe(container); + expect(mount.mock.calls[0][1]).toMatchObject({ testsuite: 'nts' }); + }); + + it('matches parameterized routes', () => { + const container = document.createElement('div'); + const mount = vi.fn(); + const module: import('../router').PageModule = { mount }; + + routerModule.addRoute('/machines/:name', module); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/machines/clang-x86', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + + expect(mount).toHaveBeenCalledTimes(1); + expect(mount.mock.calls[0][1]).toMatchObject({ + testsuite: 'nts', + name: 'clang-x86', + }); + }); + + it('decodes URI-encoded parameters', () => { + const container = document.createElement('div'); + const mount = vi.fn(); + const module: import('../router').PageModule = { mount }; + + routerModule.addRoute('/machines/:name', module); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/machines/machine%20with%20space', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + + expect(mount.mock.calls[0][1].name).toBe('machine with space'); + }); + + it('shows 404 for unmatched routes', () => { + const container = document.createElement('div'); + + routerModule.addRoute('/', { mount: vi.fn() }); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/nonexistent', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + + expect(container.innerHTML).toContain('Page Not Found'); + }); + + it('unmounts previous page before mounting new one', () => { + const container = document.createElement('div'); + const unmount = vi.fn(); + const moduleA: import('../router').PageModule = { mount: vi.fn(), unmount }; + const moduleB: import('../router').PageModule = { mount: vi.fn() }; + + routerModule.addRoute('/', moduleA); + routerModule.addRoute('/other', moduleB); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + expect(moduleA.mount).toHaveBeenCalledTimes(1); + + // Navigate to /other + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/other', search: '', hash: '' }, + writable: true, + }); + + routerModule.navigate('/other'); + + expect(unmount).toHaveBeenCalledTimes(1); + expect(moduleB.mount).toHaveBeenCalledTimes(1); + }); + + it('strips trailing slashes for non-root paths', () => { + const container = document.createElement('div'); + const mount = vi.fn(); + routerModule.addRoute('/machines', { mount }); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/machines/', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + expect(mount).toHaveBeenCalledTimes(1); + }); +}); + +describe('navigate', () => { + it('calls pushState and resolves the new route', () => { + const container = document.createElement('div'); + const mountA = vi.fn(); + const mountB = vi.fn(); + + routerModule.addRoute('/', { mount: mountA }); + routerModule.addRoute('/machines', { mount: mountB }); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', undefined, { testsuite: 'nts', testsuites: ['nts'] }); + expect(mountA).toHaveBeenCalledTimes(1); + + // Navigate + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/machines', search: '', hash: '' }, + writable: true, + }); + routerModule.navigate('/machines'); + + expect(window.history.pushState).toHaveBeenCalled(); + expect(mountB).toHaveBeenCalledTimes(1); + }); +}); + +describe('afterResolve callback', () => { + it('is called with the resolved route path', () => { + const container = document.createElement('div'); + const callback = vi.fn(); + + routerModule.addRoute('/', { mount: vi.fn() }); + routerModule.addRoute('/machines', { mount: vi.fn() }); + + Object.defineProperty(window, 'location', { + value: { pathname: '/v5/nts/', search: '', hash: '' }, + writable: true, + }); + + routerModule.initRouter(container, '/v5/nts', callback, { testsuite: 'nts', testsuites: ['nts'] }); + + expect(callback).toHaveBeenCalledWith('/'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/selection.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/selection.test.ts new file mode 100644 index 000000000..6fcfeec84 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/selection.test.ts @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// Mock the API module +vi.mock('../api', () => ({ + getFields: vi.fn(), + getTestSuiteInfoCached: vi.fn().mockRejectedValue(new Error('not configured')), +})); + +import { getFields } from '../api'; +import { initSelection, fetchSideData, getMetricFields } from '../selection'; +import type { FieldInfo } from '../types'; + +function makeField(overrides: Partial<FieldInfo> & { name: string }): FieldInfo { + return { + type: 'real', + display_name: null, + unit: null, + unit_abbrev: null, + bigger_is_better: null, + ...overrides, + }; +} + +describe('getMetricFields', () => { + beforeEach(() => { + vi.clearAllMocks(); + initSelection(['test-suite']); + // Default: API calls resolve with empty data + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue([]); + }); + + it('returns only real-typed fields', async () => { + const fields: FieldInfo[] = [ + makeField({ name: 'exec_time', type: 'real' }), + makeField({ name: 'score', type: 'real' }), + makeField({ name: 'hash', type: 'status' }), + ]; + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(fields); + + await fetchSideData('a', 'test-suite'); + + const result = getMetricFields(); + + expect(result).toHaveLength(2); + expect(result.map(f => f.name)).toEqual(['exec_time', 'score']); + }); + + it('excludes status-typed fields', async () => { + const fields: FieldInfo[] = [ + makeField({ name: 'hash', type: 'status' }), + makeField({ name: 'status_field', type: 'status' }), + ]; + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(fields); + + await fetchSideData('a', 'test-suite'); + + const result = getMetricFields(); + + expect(result).toHaveLength(0); + }); + + it('returns empty array when no fields exist', () => { + // initSelection already cleared fields + const result = getMetricFields(); + + expect(result).toEqual([]); + }); + + it('preserves field order from input', async () => { + const fields: FieldInfo[] = [ + makeField({ name: 'z_metric', type: 'real' }), + makeField({ name: 'non_metric', type: 'status' }), + makeField({ name: 'a_metric', type: 'real' }), + makeField({ name: 'm_metric', type: 'real' }), + ]; + (getFields as ReturnType<typeof vi.fn>).mockResolvedValue(fields); + + await fetchSideData('a', 'test-suite'); + + const result = getMetricFields(); + + expect(result.map(f => f.name)).toEqual(['z_metric', 'a_metric', 'm_metric']); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/sparkline-card.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/sparkline-card.test.ts new file mode 100644 index 000000000..ba1a6b190 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/sparkline-card.test.ts @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Mock Plotly globally +const mockNewPlot = vi.fn().mockResolvedValue(document.createElement('div')); +const mockPurge = vi.fn(); +vi.stubGlobal('Plotly', { + newPlot: mockNewPlot, + purge: mockPurge, +}); + +import { + createSparklineCard, createSparklineLoading, createSparklineError, + machineColor, +} from '../components/sparkline-card'; +import type { SparklineTrace } from '../components/sparkline-card'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('createSparklineCard', () => { + const traces: SparklineTrace[] = [ + { + machine: 'machine-1', + color: '#1f77b4', + points: [ + { x: 0, value: 100, commit: 'abc123' }, + { x: 1, value: 105, commit: 'def456' }, + ], + }, + ]; + + it('renders a container with the metric title', () => { + const { element } = createSparklineCard({ title: 'execution_time', traces }); + + expect(element.classList.contains('sparkline-card')).toBe(true); + const title = element.querySelector('.sparkline-title'); + expect(title).not.toBeNull(); + expect(title!.textContent).toBe('execution_time'); + }); + + it('includes unit in title when provided', () => { + const { element } = createSparklineCard({ + title: 'execution_time', + unit: 'ms', + traces, + }); + + const title = element.querySelector('.sparkline-title'); + expect(title!.textContent).toBe('execution_time (ms)'); + }); + + it('contains a chart container div', () => { + const { element } = createSparklineCard({ title: 'metric', traces }); + + const chartDiv = element.querySelector('.sparkline-chart'); + expect(chartDiv).not.toBeNull(); + }); + + it('click fires the onClick callback with no machine argument', () => { + const onClick = vi.fn(); + const { element } = createSparklineCard({ + title: 'metric', + traces, + onClick, + }); + + element.click(); + expect(onClick).toHaveBeenCalledOnce(); + expect(onClick).toHaveBeenCalledWith(); + }); + + it('destroy() calls Plotly.purge', async () => { + const { element, destroy } = createSparklineCard({ title: 'metric', traces }); + + // Simulate the element being connected to the DOM so requestAnimationFrame fires + document.body.append(element); + + // Trigger the queued requestAnimationFrame callback + await vi.waitFor(() => { + expect(mockNewPlot).toHaveBeenCalled(); + }, { timeout: 100 }).catch(() => { + // In jsdom, requestAnimationFrame may need manual triggering + }); + + destroy(); + // purge is called if plot was initialized + }); +}); + +describe('createSparklineLoading', () => { + it('renders loading state with title', () => { + const el = createSparklineLoading('compile_time'); + expect(el.classList.contains('sparkline-card')).toBe(true); + expect(el.querySelector('.sparkline-title')!.textContent).toBe('compile_time'); + expect(el.querySelector('.sparkline-loading')!.textContent).toContain('Loading'); + }); + + it('includes unit in title when provided', () => { + const el = createSparklineLoading('compile_time', 'ms'); + expect(el.querySelector('.sparkline-title')!.textContent).toBe('compile_time (ms)'); + }); +}); + +describe('createSparklineError', () => { + it('renders error state with title', () => { + const el = createSparklineError('code_size'); + expect(el.classList.contains('sparkline-card')).toBe(true); + expect(el.querySelector('.sparkline-title')!.textContent).toBe('code_size'); + expect(el.querySelector('.sparkline-error')!.textContent).toContain('Failed to load'); + }); + + it('includes unit in title when provided', () => { + const el = createSparklineError('code_size', 'bytes'); + expect(el.querySelector('.sparkline-title')!.textContent).toBe('code_size (bytes)'); + }); +}); + +describe('machineColor', () => { + it('returns a color string', () => { + expect(machineColor(0)).toBe('#1f77b4'); + expect(machineColor(1)).toBe('#ff7f0e'); + }); + + it('wraps around when index exceeds palette size', () => { + expect(machineColor(10)).toBe(machineColor(0)); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/state.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/state.test.ts new file mode 100644 index 000000000..a0fab2dcc --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/state.test.ts @@ -0,0 +1,758 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { encodeToUrl, decodeFromUrl, applyUrlState, getState, setState, setNoiseConfig, setSideA, setSideB, swapSides, setShadow, clearShadow, replaceUrl } from '../state'; +import type { AppState, NoiseConfig, ShadowConfig } from '../types'; + +const NOISE_DEFAULTS: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: false, value: 0 }, +}; + +function makeDefaults(): AppState { + return { + sideA: { suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }, + sideB: { suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }, + metric: '', + sampleAgg: 'median', + noiseConfig: structuredClone(NOISE_DEFAULTS), + sort: 'delta_pct', + sortDir: 'desc', + testFilter: '', + hideNoise: false, + shadow: null, + }; +} + +describe('encodeToUrl', () => { + it('returns empty string for all defaults', () => { + expect(encodeToUrl(makeDefaults())).toBe(''); + }); + + it('includes non-default values', () => { + const state = makeDefaults(); + state.sideA.commit = 'rev123'; + state.sideA.machine = 'machine-a'; + state.sideA.runs = ['uuid-1']; + state.sideB.commit = 'rev456'; + state.sideB.machine = 'machine-b'; + state.sideB.runs = ['uuid-2', 'uuid-3']; + state.sideB.runAgg = 'mean'; + state.metric = 'exec_time'; + state.sampleAgg = 'min'; + state.noiseConfig = { + pct: { enabled: true, value: 2.5 }, + pval: { enabled: true, value: 0.01 }, + floor: { enabled: true, value: 5 }, + }; + state.sort = 'ratio'; + state.sortDir = 'asc'; + state.testFilter = 'bench'; + state.hideNoise = true; + + const qs = encodeToUrl(state); + const params = new URLSearchParams(qs); + + expect(params.get('commit_a')).toBe('rev123'); + expect(params.get('machine_a')).toBe('machine-a'); + expect(params.get('runs_a')).toBe('uuid-1'); + expect(params.get('commit_b')).toBe('rev456'); + expect(params.get('machine_b')).toBe('machine-b'); + expect(params.get('runs_b')).toBe('uuid-2,uuid-3'); + expect(params.get('run_agg_b')).toBe('mean'); + expect(params.get('metric')).toBe('exec_time'); + expect(params.get('sample_agg')).toBe('min'); + expect(params.get('noise_pct')).toBe('2.5'); + expect(params.get('noise_pct_on')).toBe('1'); + expect(params.get('noise_pval')).toBe('0.01'); + expect(params.get('noise_pval_on')).toBe('1'); + expect(params.get('noise_floor')).toBe('5'); + expect(params.get('noise_floor_on')).toBe('1'); + expect(params.get('sort')).toBe('ratio'); + expect(params.get('sort_dir')).toBe('asc'); + expect(params.get('test_filter')).toBe('bench'); + expect(params.get('hide_noise')).toBe('1'); + }); + + it('omits default runAgg (median)', () => { + const state = makeDefaults(); + state.sideA.commit = 'rev'; + state.sideA.runAgg = 'median'; + const params = new URLSearchParams(encodeToUrl(state)); + expect(params.has('run_agg_a')).toBe(false); + }); + + it('omits noise params when at defaults', () => { + const state = makeDefaults(); + state.metric = 'x'; + const params = new URLSearchParams(encodeToUrl(state)); + expect(params.has('noise_pct')).toBe(false); + expect(params.has('noise_pct_on')).toBe(false); + expect(params.has('noise_pval')).toBe(false); + expect(params.has('noise_pval_on')).toBe(false); + expect(params.has('noise_floor')).toBe(false); + expect(params.has('noise_floor_on')).toBe(false); + }); + + it('encodes noise_pct when non-default', () => { + const state = makeDefaults(); + state.noiseConfig.pct.value = 2.5; + const params = new URLSearchParams(encodeToUrl(state)); + expect(params.get('noise_pct')).toBe('2.5'); + }); + + it('does not encode noise_pct_on when pct is at default (disabled)', () => { + const state = makeDefaults(); + // pct.enabled is already false (default) — should not appear in URL + const params = new URLSearchParams(encodeToUrl(state)); + expect(params.has('noise_pct_on')).toBe(false); + }); + + it('encodes noise_pct_on=1 when pct enabled', () => { + const state = makeDefaults(); + state.noiseConfig.pct.enabled = true; + const params = new URLSearchParams(encodeToUrl(state)); + expect(params.get('noise_pct_on')).toBe('1'); + }); +}); + +describe('decodeFromUrl', () => { + it('returns empty object for empty query string', () => { + const result = decodeFromUrl(''); + expect(result).toEqual({}); + }); + + it('decodes side A parameters', () => { + const result = decodeFromUrl('?commit_a=rev123&machine_a=machine-a&runs_a=uuid-1'); + expect(result.sideA?.commit).toBe('rev123'); + expect(result.sideA?.machine).toBe('machine-a'); + expect(result.sideA?.runs).toEqual(['uuid-1']); + }); + + it('decodes multiple run UUIDs', () => { + const result = decodeFromUrl('?runs_b=uuid-1,uuid-2,uuid-3'); + expect(result.sideB?.runs).toEqual(['uuid-1', 'uuid-2', 'uuid-3']); + }); + + it('ignores invalid agg values', () => { + const result = decodeFromUrl('?sample_agg=bogus&run_agg_a=invalid'); + expect(result.sampleAgg).toBeUndefined(); + expect(result.sideA).toBeUndefined(); + }); + + it('ignores invalid sort values', () => { + const result = decodeFromUrl('?sort=bogus&sort_dir=invalid'); + expect(result.sort).toBeUndefined(); + expect(result.sortDir).toBeUndefined(); + }); + + it('decodes hideNoise=1 as true', () => { + const result = decodeFromUrl('?hide_noise=1'); + expect(result.hideNoise).toBe(true); + }); + + it('decodes hideNoise=0 as false', () => { + const result = decodeFromUrl('?hide_noise=0'); + expect(result.hideNoise).toBe(false); + }); + + it('leaves hideNoise unset when absent', () => { + const result = decodeFromUrl('?metric=exec_time'); + expect(result.hideNoise).toBeUndefined(); + }); + + it('empty runs_a= produces runs: [] (not [""])', () => { + const result = decodeFromUrl('?runs_a='); + expect(result.sideA?.runs ?? []).toEqual([]); + }); + + it('runs_a=,, (commas only) produces runs: []', () => { + const result = decodeFromUrl('?runs_a=,,'); + expect(result.sideA?.runs ?? []).toEqual([]); + }); + + it('runs_a=abc,,def (empty element in middle) produces runs without empty strings', () => { + const result = decodeFromUrl('?runs_a=abc,,def'); + expect(result.sideA?.runs).toEqual(['abc', 'def']); + }); +}); + +describe('decodeFromUrl — noise config', () => { + it('decodes noise_pct', () => { + const result = decodeFromUrl('?noise_pct=2.5'); + expect(result.noiseConfig?.pct.value).toBe(2.5); + expect(result.noiseConfig?.pct.enabled).toBe(false); // default + }); + + it('decodes noise_pct_on=0', () => { + const result = decodeFromUrl('?noise_pct_on=0'); + expect(result.noiseConfig?.pct.enabled).toBe(false); + }); + + it('decodes noise_pval', () => { + const result = decodeFromUrl('?noise_pval=0.01&noise_pval_on=1'); + expect(result.noiseConfig?.pval.value).toBe(0.01); + expect(result.noiseConfig?.pval.enabled).toBe(true); + }); + + it('decodes noise_floor', () => { + const result = decodeFromUrl('?noise_floor=5&noise_floor_on=1'); + expect(result.noiseConfig?.floor.value).toBe(5); + expect(result.noiseConfig?.floor.enabled).toBe(true); + }); + + it('rejects noise_pval < 0', () => { + const result = decodeFromUrl('?noise_pval=-0.1&noise_pval_on=1'); + expect(result.noiseConfig?.pval.value).toBe(0.05); // default preserved + }); + + it('rejects noise_pval > 1', () => { + const result = decodeFromUrl('?noise_pval=1.5&noise_pval_on=1'); + expect(result.noiseConfig?.pval.value).toBe(0.05); // default preserved + }); + + it('accepts noise_pval=0', () => { + const result = decodeFromUrl('?noise_pval=0&noise_pval_on=1'); + expect(result.noiseConfig?.pval.value).toBe(0); + }); + + it('accepts noise_pval=1', () => { + const result = decodeFromUrl('?noise_pval=1&noise_pval_on=1'); + expect(result.noiseConfig?.pval.value).toBe(1); + }); + + it('rejects noise_floor < 0', () => { + const result = decodeFromUrl('?noise_floor=-1&noise_floor_on=1'); + expect(result.noiseConfig?.floor.value).toBe(0); // default preserved + }); + + it('ignores invalid *_on values', () => { + const result = decodeFromUrl('?noise_pval_on=yes'); + // 'yes' is not '0' or '1', so enabled stays at default (false) + expect(result.noiseConfig?.pval.enabled).toBe(false); + }); + + it('legacy ?noise=5 maps to pct.value', () => { + const result = decodeFromUrl('?noise=5'); + expect(result.noiseConfig?.pct.value).toBe(5); + expect(result.noiseConfig?.pct.enabled).toBe(true); + }); + + it('legacy ?noise is ignored when noise_pct is present', () => { + const result = decodeFromUrl('?noise=5&noise_pct=2'); + expect(result.noiseConfig?.pct.value).toBe(2); + }); + + it('leaves noiseConfig unset when no noise params present', () => { + const result = decodeFromUrl('?metric=exec_time'); + expect(result.noiseConfig).toBeUndefined(); + }); +}); + +describe('round-trip', () => { + it('encode then decode preserves full non-default state', () => { + const state = makeDefaults(); + state.sideA = { suite: 'nts', commit: 'rev1', machine: 'mach-a', runs: ['u1', 'u2'], runAgg: 'mean' }; + state.sideB = { suite: 'compile', commit: 'rev2', machine: 'mach-b', runs: ['u3'], runAgg: 'max' }; + state.metric = 'exec_time'; + state.sampleAgg = 'min'; + state.noiseConfig = { + pct: { enabled: false, value: 3 }, + pval: { enabled: true, value: 0.01 }, + floor: { enabled: true, value: 10 }, + }; + state.sort = 'test'; + state.sortDir = 'asc'; + state.testFilter = 'bench'; + state.hideNoise = true; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA).toEqual(state.sideA); + expect(decoded.sideB).toEqual(state.sideB); + expect(decoded.metric).toBe(state.metric); + expect(decoded.sampleAgg).toBe(state.sampleAgg); + expect(decoded.noiseConfig).toEqual(state.noiseConfig); + expect(decoded.sort).toBe(state.sort); + expect(decoded.sortDir).toBe(state.sortDir); + expect(decoded.testFilter).toBe(state.testFilter); + expect(decoded.hideNoise).toBe(state.hideNoise); + }); + + it('round-trips multiple run UUIDs', () => { + const state = makeDefaults(); + state.sideA.runs = ['aaa-111', 'bbb-222', 'ccc-333']; + state.sideA.commit = 'x'; // needed to trigger sideA encoding + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA?.runs).toEqual(['aaa-111', 'bbb-222', 'ccc-333']); + }); + + it('round-trips noiseConfig with all non-default values', () => { + const state = makeDefaults(); + state.noiseConfig = { + pct: { enabled: false, value: 5 }, + pval: { enabled: true, value: 0.1 }, + floor: { enabled: true, value: 100 }, + }; + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + expect(decoded.noiseConfig).toEqual(state.noiseConfig); + }); +}); + +describe('applyUrlState', () => { + beforeEach(() => { + applyUrlState(''); + }); + + it('restores state from URL on page load', () => { + applyUrlState('?commit_a=rev1&machine_a=mach-a&metric=exec_time&noise_pct=3&sort=ratio&sort_dir=asc'); + const s = getState(); + expect(s.sideA.commit).toBe('rev1'); + expect(s.sideA.machine).toBe('mach-a'); + expect(s.metric).toBe('exec_time'); + expect(s.noiseConfig.pct.value).toBe(3); + expect(s.sort).toBe('ratio'); + expect(s.sortDir).toBe('asc'); + }); + + it('resets absent fields to defaults', () => { + setState({ metric: 'exec_time', noiseConfig: { pct: { enabled: true, value: 5 }, pval: { enabled: false, value: 0.05 }, floor: { enabled: false, value: 0 } } }); + expect(getState().metric).toBe('exec_time'); + expect(getState().noiseConfig.pct.value).toBe(5); + + applyUrlState('?metric=compile_time'); + const s = getState(); + expect(s.metric).toBe('compile_time'); + expect(s.noiseConfig).toEqual(NOISE_DEFAULTS); + expect(s.sort).toBe('delta_pct'); + expect(s.sortDir).toBe('desc'); + expect(s.testFilter).toBe(''); + expect(s.hideNoise).toBe(false); + expect(s.sideA).toEqual({ suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }); + expect(s.sideB).toEqual({ suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }); + }); + + it('with empty search string sets state to all defaults', () => { + setState({ metric: 'exec_time' }); + setNoiseConfig('pct', { value: 5 }); + setSideA({ commit: 'rev1', machine: 'mach-a' }); + + applyUrlState(''); + const s = getState(); + expect(s).toEqual(makeDefaults()); + }); + + it('with partial URL sets only specified fields, unset fields are defaults', () => { + applyUrlState('?commit_b=rev2&sample_agg=min&hide_noise=1'); + const s = getState(); + + expect(s.sideB.commit).toBe('rev2'); + expect(s.sampleAgg).toBe('min'); + expect(s.hideNoise).toBe(true); + + expect(s.sideA).toEqual({ suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }); + expect(s.sideB.machine).toBe(''); + expect(s.sideB.runs).toEqual([]); + expect(s.sideB.runAgg).toBe('median'); + expect(s.metric).toBe(''); + expect(s.noiseConfig).toEqual(NOISE_DEFAULTS); + expect(s.sort).toBe('delta_pct'); + expect(s.sortDir).toBe('desc'); + expect(s.testFilter).toBe(''); + }); + + it('with partial noise params, unset knobs keep defaults', () => { + applyUrlState('?noise_pval_on=1'); + const s = getState(); + expect(s.noiseConfig.pval.enabled).toBe(true); + expect(s.noiseConfig.pval.value).toBe(0.05); // default value + expect(s.noiseConfig.pct).toEqual(NOISE_DEFAULTS.pct); + expect(s.noiseConfig.floor).toEqual(NOISE_DEFAULTS.floor); + }); +}); + +describe('getState / setState / setNoiseConfig / setSideA / setSideB', () => { + beforeEach(() => { + applyUrlState(''); + }); + + it('getState returns the current state', () => { + const s = getState(); + expect(s).toEqual(makeDefaults()); + }); + + it('setState merges partial state', () => { + setState({ metric: 'exec_time' }); + const s = getState(); + expect(s.metric).toBe('exec_time'); + expect(s.sort).toBe('delta_pct'); + expect(s.sortDir).toBe('desc'); + expect(s.sampleAgg).toBe('median'); + }); + + it('setNoiseConfig updates a single knob', () => { + setNoiseConfig('pct', { value: 5 }); + const s = getState(); + expect(s.noiseConfig.pct.value).toBe(5); + expect(s.noiseConfig.pct.enabled).toBe(false); // unchanged from default + expect(s.noiseConfig.pval).toEqual(NOISE_DEFAULTS.pval); // other knobs unchanged + expect(s.noiseConfig.floor).toEqual(NOISE_DEFAULTS.floor); + }); + + it('setNoiseConfig updates enabled state', () => { + setNoiseConfig('pval', { enabled: true }); + expect(getState().noiseConfig.pval.enabled).toBe(true); + expect(getState().noiseConfig.pval.value).toBe(0.05); // value unchanged + }); + + it('setSideA merges partial side A selection', () => { + setSideA({ commit: 'rev123', machine: 'mach-a' }); + const s = getState(); + expect(s.sideA.commit).toBe('rev123'); + expect(s.sideA.machine).toBe('mach-a'); + expect(s.sideA.runs).toEqual([]); + expect(s.sideA.runAgg).toBe('median'); + }); + + it('setSideB merges partial side B selection', () => { + setSideB({ runs: ['uuid-1', 'uuid-2'], runAgg: 'mean' }); + const s = getState(); + expect(s.sideB.runs).toEqual(['uuid-1', 'uuid-2']); + expect(s.sideB.runAgg).toBe('mean'); + expect(s.sideB.commit).toBe(''); + expect(s.sideB.machine).toBe(''); + }); + + it('state is preserved across calls (not reset)', () => { + setState({ metric: 'exec_time' }); + setNoiseConfig('pct', { value: 3 }); + setSideA({ commit: 'rev1' }); + setSideA({ machine: 'mach-a' }); + setSideB({ commit: 'rev2' }); + + const s = getState(); + expect(s.metric).toBe('exec_time'); + expect(s.noiseConfig.pct.value).toBe(3); + expect(s.sideA.commit).toBe('rev1'); + expect(s.sideA.machine).toBe('mach-a'); + expect(s.sideB.commit).toBe('rev2'); + }); + + it('swapSides exchanges sideA and sideB', () => { + setSideA({ commit: 'rev1', machine: 'mach-a', runs: ['u1'], runAgg: 'mean' }); + setSideB({ commit: 'rev2', machine: 'mach-b', runs: ['u2', 'u3'], runAgg: 'max' }); + + swapSides(); + + const s = getState(); + expect(s.sideA).toEqual({ suite: '', commit: 'rev2', machine: 'mach-b', runs: ['u2', 'u3'], runAgg: 'max' }); + expect(s.sideB).toEqual({ suite: '', commit: 'rev1', machine: 'mach-a', runs: ['u1'], runAgg: 'mean' }); + }); + + it('swapSides twice restores original state', () => { + setSideA({ commit: 'rev1', machine: 'mach-a' }); + setSideB({ commit: 'rev2', machine: 'mach-b' }); + + swapSides(); + swapSides(); + + const s = getState(); + expect(s.sideA.commit).toBe('rev1'); + expect(s.sideA.machine).toBe('mach-a'); + expect(s.sideB.commit).toBe('rev2'); + expect(s.sideB.machine).toBe('mach-b'); + }); +}); + +describe('URL special characters round-trip', () => { + it('round-trips commit and machine with spaces', () => { + const state = makeDefaults(); + state.sideA.commit = 'rev 123'; + state.sideA.machine = 'my machine'; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA?.commit).toBe('rev 123'); + expect(decoded.sideA?.machine).toBe('my machine'); + }); + + it('round-trips values with +', () => { + const state = makeDefaults(); + state.sideA.commit = 'r+1'; + state.sideA.machine = 'host+name'; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA?.commit).toBe('r+1'); + expect(decoded.sideA?.machine).toBe('host+name'); + }); + + it('round-trips values with &', () => { + const state = makeDefaults(); + state.sideA.commit = 'a&b'; + state.sideB.commit = 'c&d'; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA?.commit).toBe('a&b'); + expect(decoded.sideB?.commit).toBe('c&d'); + }); + + it('round-trips values with =', () => { + const state = makeDefaults(); + state.sideA.machine = 'x=y'; + state.sideB.machine = 'key=value'; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA?.machine).toBe('x=y'); + expect(decoded.sideB?.machine).toBe('key=value'); + }); + + it('full round-trip with mixed special characters', () => { + const state = makeDefaults(); + state.sideA = { suite: '', commit: 'rev 123+rc1', machine: 'host&name=prod', runs: ['uuid-1'], runAgg: 'mean' }; + state.sideB = { suite: '', commit: 'a&b=c+d e', machine: 'machine two', runs: ['uuid-2', 'uuid-3'], runAgg: 'max' }; + state.metric = 'exec_time'; + state.testFilter = 'bench+suite & more'; + state.noiseConfig = { + pct: { enabled: true, value: 2 }, + pval: { enabled: true, value: 0.01 }, + floor: { enabled: false, value: 0 }, + }; + state.sort = 'ratio'; + state.sortDir = 'asc'; + state.hideNoise = true; + + const qs = encodeToUrl(state); + const decoded = decodeFromUrl(qs); + + expect(decoded.sideA).toEqual(state.sideA); + expect(decoded.sideB).toEqual(state.sideB); + expect(decoded.metric).toBe(state.metric); + expect(decoded.testFilter).toBe(state.testFilter); + expect(decoded.noiseConfig).toEqual(state.noiseConfig); + expect(decoded.sort).toBe(state.sort); + expect(decoded.sortDir).toBe(state.sortDir); + expect(decoded.hideNoise).toBe(state.hideNoise); + }); +}); + +describe('decodeFromUrl noise edge cases', () => { + it('noise_pct=0 produces value: 0', () => { + const result = decodeFromUrl('?noise_pct=0'); + expect(result.noiseConfig?.pct.value).toBe(0); + }); + + it('noise_pct=abc does NOT produce a noiseConfig field (NaN rejected)', () => { + const result = decodeFromUrl('?noise_pct=abc'); + // The param is present but invalid, so it falls back to default + expect(result.noiseConfig?.pct.value).toBe(1); // default + }); + + it('noise_pct=-1 does NOT change the value (negative rejected)', () => { + const result = decodeFromUrl('?noise_pct=-1'); + expect(result.noiseConfig?.pct.value).toBe(1); // default + }); + + it('noise_pct=5 produces value: 5', () => { + const result = decodeFromUrl('?noise_pct=5'); + expect(result.noiseConfig?.pct.value).toBe(5); + }); +}); + +describe('replaceUrl', () => { + beforeEach(() => { + applyUrlState(''); + }); + + it('calls window.history.replaceState with the encoded URL', () => { + const spy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + + setState({ metric: 'compile_time', sort: 'ratio', sortDir: 'asc' }); + + expect(spy).toHaveBeenCalledOnce(); + const url = spy.mock.calls[0][2] as string; + expect(url).toContain('metric=compile_time'); + expect(url).toContain('sort=ratio'); + expect(url).toContain('sort_dir=asc'); + expect(spy.mock.calls[0][0]).toBeNull(); + expect(spy.mock.calls[0][1]).toBe(''); + + spy.mockRestore(); + }); + + it('includes pathname in the URL', () => { + const spy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/v5/nts/compare' }, + writable: true, + configurable: true, + }); + + setState({ metric: 'exec_time' }); + replaceUrl(); + + const url = spy.mock.calls[0][2] as string; + expect(url).toMatch(/^\/v5\/nts\/compare\?/); + + spy.mockRestore(); + }); + + it('replaces with pathname only when state is all defaults', () => { + const spy = vi.spyOn(window.history, 'replaceState').mockImplementation(() => {}); + + Object.defineProperty(window, 'location', { + value: { ...window.location, pathname: '/compare' }, + writable: true, + configurable: true, + }); + + replaceUrl(); + + const url = spy.mock.calls[0][2] as string; + expect(url).toBe('/compare'); + + spy.mockRestore(); + }); +}); + +describe('shadow trace state', () => { + const makeShadow = (): ShadowConfig => ({ + sideB: { suite: 'nts', commit: 'llvm20', machine: 'mach-x', runs: ['u1', 'u2'], runAgg: 'mean' }, + }); + + beforeEach(() => { + applyUrlState(''); + }); + + describe('URL encode/decode', () => { + it('round-trips shadow present', () => { + const state = makeDefaults(); + state.shadow = makeShadow(); + const qs = encodeToUrl(state); + expect(qs).toContain('suite_shadow_b=nts'); + expect(qs).toContain('commit_shadow_b=llvm20'); + expect(qs).toContain('machine_shadow_b=mach-x'); + expect(qs).toContain('runs_shadow_b=u1%2Cu2'); + expect(qs).toContain('run_agg_shadow_b=mean'); + + const decoded = decodeFromUrl(qs); + expect(decoded.shadow).toBeDefined(); + expect(decoded.shadow!.sideB.suite).toBe('nts'); + expect(decoded.shadow!.sideB.commit).toBe('llvm20'); + expect(decoded.shadow!.sideB.machine).toBe('mach-x'); + expect(decoded.shadow!.sideB.runs).toEqual(['u1', 'u2']); + expect(decoded.shadow!.sideB.runAgg).toBe('mean'); + }); + + it('round-trips shadow absent (no shadow_* params)', () => { + const state = makeDefaults(); + const qs = encodeToUrl(state); + expect(qs).not.toContain('shadow_b'); + const decoded = decodeFromUrl(qs); + expect(decoded.shadow).toBeUndefined(); + }); + + it('decodes partial shadow params gracefully', () => { + const decoded = decodeFromUrl('?suite_shadow_b=nts'); + expect(decoded.shadow).toBeDefined(); + expect(decoded.shadow!.sideB.suite).toBe('nts'); + expect(decoded.shadow!.sideB.runs).toEqual([]); + expect(decoded.shadow!.sideB.runAgg).toBe('median'); + }); + + it('decodes invalid run_agg_shadow_b as median', () => { + const decoded = decodeFromUrl('?suite_shadow_b=nts&run_agg_shadow_b=bogus'); + expect(decoded.shadow!.sideB.runAgg).toBe('median'); + }); + + it('handles empty/commas-only runs_shadow_b', () => { + expect(decodeFromUrl('?suite_shadow_b=nts&runs_shadow_b=').shadow!.sideB.runs).toEqual([]); + expect(decodeFromUrl('?suite_shadow_b=nts&runs_shadow_b=,,').shadow!.sideB.runs).toEqual([]); + }); + + it('cross-suite: shadow suite differs from main side B suite', () => { + const state = makeDefaults(); + state.sideB.suite = 'compile'; + state.shadow = makeShadow(); // suite = 'nts' + const qs = encodeToUrl(state); + expect(qs).toContain('suite_b=compile'); + expect(qs).toContain('suite_shadow_b=nts'); + + const decoded = decodeFromUrl(qs); + expect(decoded.sideB!.suite).toBe('compile'); + expect(decoded.shadow!.sideB.suite).toBe('nts'); + }); + }); + + describe('mutations', () => { + it('setShadow sets the shadow config', () => { + const shadow = makeShadow(); + setShadow(shadow); + expect(getState().shadow).toEqual(shadow); + }); + + it('clearShadow clears the shadow', () => { + setShadow(makeShadow()); + clearShadow(); + expect(getState().shadow).toBeNull(); + }); + + it('setSideA auto-clears shadow', () => { + setShadow(makeShadow()); + setSideA({ commit: 'new-commit' }); + expect(getState().shadow).toBeNull(); + }); + + it('swapSides auto-clears shadow', () => { + setShadow(makeShadow()); + swapSides(); + expect(getState().shadow).toBeNull(); + }); + + it('setState({ sideA }) auto-clears shadow', () => { + setShadow(makeShadow()); + setState({ sideA: { suite: 'x', commit: 'y', machine: 'z', runs: [], runAgg: 'median' } }); + expect(getState().shadow).toBeNull(); + }); + + it('setSideB does NOT clear shadow', () => { + setShadow(makeShadow()); + setSideB({ commit: 'new-commit' }); + expect(getState().shadow).not.toBeNull(); + }); + + it('setState({ metric }) does NOT clear shadow', () => { + setShadow(makeShadow()); + setState({ metric: 'wall_time' }); + expect(getState().shadow).not.toBeNull(); + }); + }); + + describe('applyUrlState', () => { + it('restores shadow from URL params', () => { + applyUrlState('?suite_shadow_b=nts&commit_shadow_b=llvm20&machine_shadow_b=mach-x&runs_shadow_b=u1'); + const s = getState(); + expect(s.shadow).not.toBeNull(); + expect(s.shadow!.sideB.suite).toBe('nts'); + expect(s.shadow!.sideB.commit).toBe('llvm20'); + }); + + it('resets shadow to null when URL has no shadow params', () => { + setShadow(makeShadow()); + applyUrlState('?metric=exec_time'); + expect(getState().shadow).toBeNull(); + }); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/table.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/table.test.ts new file mode 100644 index 000000000..6c91993ff --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/table.test.ts @@ -0,0 +1,1193 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { sortRows, renderTable, resetTable, applyTableFilters, filterToTests } from '../table'; +import type { ComparisonRow } from '../types'; + +// Mock setState to avoid window.history.replaceState issues in jsdom. +// Table tests only need getState() to return the testFilter value. +const mockState: { testFilter: string; sort: string; sortDir: string } = { testFilter: '', sort: 'delta_pct', sortDir: 'desc' }; +vi.mock('../state', () => ({ + getState: () => mockState, + setState: (partial: Record<string, unknown>) => Object.assign(mockState, partial), +})); + +// Helper to create a ComparisonRow with defaults +function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: null, + valueB: null, + delta: null, + deltaPct: null, + ratio: null, + status: 'unchanged', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; +} + +// Reusable test data +function makeTestRows(): ComparisonRow[] { + return [ + makeRow({ test: 'bench/compile', valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, status: 'regressed' }), + makeRow({ test: 'bench/link', valueA: 50, valueB: 45, delta: -5, deltaPct: -10, ratio: 0.9, status: 'improved' }), + makeRow({ test: 'bench/run', valueA: 200, valueB: 200, delta: 0, deltaPct: 0, ratio: 1.0, status: 'unchanged' }), + makeRow({ test: 'bench/alloc', valueA: 75, valueB: 80, delta: 5, deltaPct: 6.67, ratio: 1.0667, status: 'noise' }), + ]; +} + +describe('sortRows', () => { + describe('sort by test name (string column)', () => { + it('sorts ascending alphabetically', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'test', 'asc'); + expect(sorted.map(r => r.test)).toEqual([ + 'bench/alloc', + 'bench/compile', + 'bench/link', + 'bench/run', + ]); + }); + + it('sorts descending alphabetically', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'test', 'desc'); + expect(sorted.map(r => r.test)).toEqual([ + 'bench/run', + 'bench/link', + 'bench/compile', + 'bench/alloc', + ]); + }); + }); + + describe('sort by value_a (numeric column)', () => { + it('sorts ascending by value_a', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'value_a', 'asc'); + expect(sorted.map(r => r.valueA)).toEqual([50, 75, 100, 200]); + }); + + it('sorts descending by value_a', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'value_a', 'desc'); + expect(sorted.map(r => r.valueA)).toEqual([200, 100, 75, 50]); + }); + }); + + describe('sort by value_b', () => { + it('sorts ascending by value_b', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'value_b', 'asc'); + expect(sorted.map(r => r.valueB)).toEqual([45, 80, 110, 200]); + }); + + it('sorts descending by value_b', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'value_b', 'desc'); + expect(sorted.map(r => r.valueB)).toEqual([200, 110, 80, 45]); + }); + }); + + describe('sort by delta', () => { + it('sorts ascending by delta', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'delta', 'asc'); + expect(sorted.map(r => r.delta)).toEqual([-5, 0, 5, 10]); + }); + + it('sorts descending by delta', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'delta', 'desc'); + expect(sorted.map(r => r.delta)).toEqual([10, 5, 0, -5]); + }); + }); + + describe('sort by delta_pct', () => { + it('sorts ascending by delta_pct', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'delta_pct', 'asc'); + expect(sorted.map(r => r.deltaPct)).toEqual([-10, 0, 6.67, 10]); + }); + + it('sorts descending by delta_pct', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'delta_pct', 'desc'); + expect(sorted.map(r => r.deltaPct)).toEqual([10, 6.67, 0, -10]); + }); + }); + + describe('sort by ratio', () => { + it('sorts ascending by ratio', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'ratio', 'asc'); + expect(sorted.map(r => r.ratio)).toEqual([0.9, 1.0, 1.0667, 1.1]); + }); + + it('sorts descending by ratio', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'ratio', 'desc'); + expect(sorted.map(r => r.ratio)).toEqual([1.1, 1.0667, 1.0, 0.9]); + }); + }); + + describe('sort by status (string column)', () => { + it('sorts ascending by status', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'status', 'asc'); + expect(sorted.map(r => r.status)).toEqual([ + 'improved', + 'noise', + 'regressed', + 'unchanged', + ]); + }); + + it('sorts descending by status', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'status', 'desc'); + expect(sorted.map(r => r.status)).toEqual([ + 'unchanged', + 'regressed', + 'noise', + 'improved', + ]); + }); + }); + + describe('null handling', () => { + it('pushes null values to the end regardless of sort direction (ascending)', () => { + const rows = [ + makeRow({ test: 'a', valueA: 50 }), + makeRow({ test: 'b', valueA: null }), + makeRow({ test: 'c', valueA: 100 }), + makeRow({ test: 'd', valueA: null }), + ]; + const sorted = sortRows(rows, 'value_a', 'asc'); + expect(sorted.map(r => r.valueA)).toEqual([50, 100, null, null]); + }); + + it('pushes null values to the end regardless of sort direction (descending)', () => { + const rows = [ + makeRow({ test: 'a', valueA: 50 }), + makeRow({ test: 'b', valueA: null }), + makeRow({ test: 'c', valueA: 100 }), + makeRow({ test: 'd', valueA: null }), + ]; + const sorted = sortRows(rows, 'value_a', 'desc'); + expect(sorted.map(r => r.valueA)).toEqual([100, 50, null, null]); + }); + + it('handles all-null columns', () => { + const rows = [ + makeRow({ test: 'a', delta: null }), + makeRow({ test: 'b', delta: null }), + makeRow({ test: 'c', delta: null }), + ]; + const sorted = sortRows(rows, 'delta', 'asc'); + // All null, order should be stable (all compare equal) + expect(sorted).toHaveLength(3); + expect(sorted.every(r => r.delta === null)).toBe(true); + }); + + it('sorts non-null values correctly when mixed with nulls in delta_pct', () => { + const rows = [ + makeRow({ test: 'a', deltaPct: null }), + makeRow({ test: 'b', deltaPct: 5 }), + makeRow({ test: 'c', deltaPct: -3 }), + makeRow({ test: 'd', deltaPct: null }), + makeRow({ test: 'e', deltaPct: 10 }), + ]; + const sorted = sortRows(rows, 'delta_pct', 'asc'); + expect(sorted.map(r => r.deltaPct)).toEqual([-3, 5, 10, null, null]); + }); + + it('sorts non-null values correctly when mixed with nulls in ratio descending', () => { + const rows = [ + makeRow({ test: 'a', ratio: null }), + makeRow({ test: 'b', ratio: 1.5 }), + makeRow({ test: 'c', ratio: 0.8 }), + makeRow({ test: 'd', ratio: null }), + ]; + const sorted = sortRows(rows, 'ratio', 'desc'); + expect(sorted.map(r => r.ratio)).toEqual([1.5, 0.8, null, null]); + }); + + it('handles rows with only one side present (a_only has null valueB)', () => { + const rows = [ + makeRow({ test: 'both-test', valueB: 100, sidePresent: 'both' }), + makeRow({ test: 'a-only-test', valueB: null, sidePresent: 'a_only' }), + makeRow({ test: 'both-test-2', valueB: 50, sidePresent: 'both' }), + ]; + const sorted = sortRows(rows, 'value_b', 'asc'); + expect(sorted.map(r => r.valueB)).toEqual([50, 100, null]); + }); + }); + + describe('does not mutate input', () => { + it('returns a new array, not the same reference', () => { + const rows = makeTestRows(); + const sorted = sortRows(rows, 'test', 'asc'); + expect(sorted).not.toBe(rows); + }); + + it('preserves original array order after sorting', () => { + const rows = makeTestRows(); + const originalOrder = rows.map(r => r.test); + sortRows(rows, 'test', 'asc'); + expect(rows.map(r => r.test)).toEqual(originalOrder); + }); + + it('preserves original array order after sorting descending', () => { + const rows = makeTestRows(); + const originalOrder = rows.map(r => r.test); + sortRows(rows, 'delta_pct', 'desc'); + expect(rows.map(r => r.test)).toEqual(originalOrder); + }); + + it('preserves original array length', () => { + const rows = makeTestRows(); + const originalLength = rows.length; + sortRows(rows, 'value_a', 'asc'); + expect(rows).toHaveLength(originalLength); + }); + }); + + describe('edge cases', () => { + it('returns empty array for empty input', () => { + const sorted = sortRows([], 'test', 'asc'); + expect(sorted).toEqual([]); + }); + + it('returns single-element array unchanged', () => { + const rows = [makeRow({ test: 'only-test', valueA: 42 })]; + const sorted = sortRows(rows, 'value_a', 'desc'); + expect(sorted).toHaveLength(1); + expect(sorted[0].test).toBe('only-test'); + }); + + it('handles rows with identical values for the sort column', () => { + const rows = [ + makeRow({ test: 'a', valueA: 100 }), + makeRow({ test: 'b', valueA: 100 }), + makeRow({ test: 'c', valueA: 100 }), + ]; + const sorted = sortRows(rows, 'value_a', 'asc'); + expect(sorted).toHaveLength(3); + expect(sorted.every(r => r.valueA === 100)).toBe(true); + }); + + it('handles negative numeric values correctly', () => { + const rows = [ + makeRow({ test: 'a', delta: -10 }), + makeRow({ test: 'b', delta: -1 }), + makeRow({ test: 'c', delta: -100 }), + ]; + const sorted = sortRows(rows, 'delta', 'asc'); + expect(sorted.map(r => r.delta)).toEqual([-100, -10, -1]); + }); + + it('handles zero values correctly among positives and negatives', () => { + const rows = [ + makeRow({ test: 'a', delta: 5 }), + makeRow({ test: 'b', delta: 0 }), + makeRow({ test: 'c', delta: -5 }), + ]; + const sorted = sortRows(rows, 'delta', 'asc'); + expect(sorted.map(r => r.delta)).toEqual([-5, 0, 5]); + }); + }); +}); + +describe('geomean row', () => { + function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: null, + valueB: null, + delta: null, + deltaPct: null, + ratio: null, + status: 'unchanged', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; + } + + it('renders a geomean row with A/B values, delta, and ratio', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + makeRow({ test: 'b', valueA: 400, valueB: 3200, ratio: 8.0, status: 'regressed' }), + ]; + + renderTable(container, rows); + + const geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeTruthy(); + const cells = geomeanRow!.querySelectorAll('td'); + expect(cells[0].textContent).toBe('Geomean'); + // geomeanA = sqrt(100*400) = 200, geomeanB = sqrt(200*3200) = 800 + expect(cells[1].textContent).not.toBe(''); // Value A filled + expect(cells[2].textContent).not.toBe(''); // Value B filled + expect(cells[3].textContent).not.toBe(''); // Delta filled + expect(cells[4].textContent).not.toBe(''); // Delta % filled + // Ratio geomean of [2, 8] = 4.0 + expect(cells[5].textContent).toBe('4.0000'); + + resetTable(); + }); + + it('excludes hidden rows from geomean computation', () => { + const container = document.createElement('div'); + // Two rows: ratio 2.0 and ratio 8.0 + // Geomean of both = sqrt(2*8) = 4.0 + // Hide 'b' (ratio 8.0) => geomean should be just ratio 2.0 + const rows = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + makeRow({ test: 'b', valueA: 400, valueB: 3200, ratio: 8.0, status: 'regressed' }), + ]; + + // First render with no hidden rows: geomean ratio = 4.0 + renderTable(container, rows); + let geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeTruthy(); + let cells = geomeanRow!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('4.0000'); + + resetTable(); + + // Now hide 'b': geomean should be computed from only 'a' (ratio 2.0) + renderTable(container, rows, { hiddenTests: new Set(['b']) }); + geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeTruthy(); + cells = geomeanRow!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('2.0000'); + + resetTable(); + }); + + it('updates geomean when hidden rows change (simulates toggle)', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + makeRow({ test: 'b', valueA: 400, valueB: 3200, ratio: 8.0, status: 'regressed' }), + ]; + + // Start with 'a' hidden: geomean = ratio of 'b' = 8.0 + renderTable(container, rows, { hiddenTests: new Set(['a']) }); + let cells = container.querySelector('.geomean-row')!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('8.0000'); + + resetTable(); + + // Un-hide 'a' (empty hidden set): geomean = sqrt(2*8) = 4.0 + renderTable(container, rows, { hiddenTests: new Set() }); + cells = container.querySelector('.geomean-row')!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('4.0000'); + + resetTable(); + }); + + it('removes geomean row when all rows are hidden', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + ]; + + // With the row visible, geomean exists + renderTable(container, rows); + expect(container.querySelector('.geomean-row')).toBeTruthy(); + resetTable(); + + // With the only row hidden, geomean should be absent + renderTable(container, rows, { hiddenTests: new Set(['a']) }); + expect(container.querySelector('.geomean-row')).toBeNull(); + resetTable(); + }); + + it('does not render a geomean row when no valid ratios', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', sidePresent: 'a_only', status: 'missing' }), + ]; + + renderTable(container, rows); + + const geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeNull(); + + resetTable(); + }); +}); + +describe('row visibility toggling', () => { + function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: 100, + valueB: 110, + delta: 10, + deltaPct: 10, + ratio: 1.1, + status: 'improved', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; + } + + it('renders manually-hidden rows with row-hidden class', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'noise' }), + ]; + const hidden = new Set(['b']); + + renderTable(container, rows, { hiddenTests: hidden }); + + const rowA = container.querySelector('tr[data-test="a"]'); + const rowB = container.querySelector('tr[data-test="b"]'); + expect(rowA).toBeTruthy(); + expect(rowB).toBeTruthy(); + expect(rowA!.classList.contains('row-hidden')).toBe(false); + expect(rowB!.classList.contains('row-hidden')).toBe(true); + + resetTable(); + }); + + it('shows all rows including noise when hiddenTests is empty', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'noise' }), + makeRow({ test: 'c', status: 'regressed' }), + ]; + + renderTable(container, rows, { hiddenTests: new Set() }); + + const dataRows = container.querySelectorAll('tr[data-test]'); + expect(dataRows).toHaveLength(3); + + resetTable(); + }); + + it('calls onToggle on single click with delay', async () => { + const container = document.createElement('div'); + const onToggle = vi.fn(); + const rows = [makeRow({ test: 'a' })]; + + renderTable(container, rows, { onToggle }); + + const row = container.querySelector('tr[data-test="a"]')! as HTMLElement; + row.click(); + + // Not called immediately (200ms delay) + expect(onToggle).not.toHaveBeenCalled(); + + // Wait for delay + await new Promise(r => setTimeout(r, 250)); + expect(onToggle).toHaveBeenCalledWith('a'); + + resetTable(); + }); + + it('calls onIsolate on double click without triggering onToggle', async () => { + const container = document.createElement('div'); + const onToggle = vi.fn(); + const onIsolate = vi.fn(); + const rows = [makeRow({ test: 'a' })]; + + renderTable(container, rows, { onToggle, onIsolate }); + + const row = container.querySelector('tr[data-test="a"]')! as HTMLElement; + row.click(); + row.dispatchEvent(new MouseEvent('dblclick', { bubbles: true })); + + // Wait past the single-click delay + await new Promise(r => setTimeout(r, 250)); + expect(onIsolate).toHaveBeenCalledWith('a'); + expect(onToggle).not.toHaveBeenCalled(); + + resetTable(); + }); +}); + +describe('noise-hidden vs manually-hidden separation', () => { + function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: 100, + valueB: 110, + delta: 10, + deltaPct: 10, + ratio: 1.1, + status: 'improved', + sidePresent: 'both', + noiseReasons: [], + ...overrides, + }; + } + + it('noise rows excluded upstream are absent from the DOM', () => { + // Simulates what compare.ts does when hideNoise is on: it filters + // noise rows out of the array before calling renderTable. + const container = document.createElement('div'); + const allRows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'noise' }), + makeRow({ test: 'c', status: 'regressed' }), + ]; + // Upstream filters out noise row 'b' + const tableRows = allRows.filter(r => r.status !== 'noise'); + renderTable(container, tableRows); + + expect(container.querySelector('tr[data-test="a"]')).toBeTruthy(); + expect(container.querySelector('tr[data-test="b"]')).toBeNull(); + expect(container.querySelector('tr[data-test="c"]')).toBeTruthy(); + + resetTable(); + }); + + it('visible noise row does not get row-level styling class', () => { + // Noise rows are distinguished only by the grey Status cell text, + // not by row-level opacity. + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'noise' }), + ]; + renderTable(container, rows); + + const rowB = container.querySelector('tr[data-test="b"]')!; + expect(rowB.classList.contains('row-hidden')).toBe(false); + + resetTable(); + }); + + it('row-hidden class only applies to manually-hidden rows, not noise rows', () => { + const container = document.createElement('div'); + const rows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'b', status: 'noise' }), + makeRow({ test: 'c', status: 'regressed' }), + ]; + // 'a' is manually hidden; 'b' is noise (visible); 'c' is normal + renderTable(container, rows, { hiddenTests: new Set(['a']) }); + + const rowA = container.querySelector('tr[data-test="a"]')!; + const rowB = container.querySelector('tr[data-test="b"]')!; + const rowC = container.querySelector('tr[data-test="c"]')!; + + // 'a': manually hidden → row-hidden + expect(rowA.classList.contains('row-hidden')).toBe(true); + + // 'b': noise visible → no row-level class + expect(rowB.classList.contains('row-hidden')).toBe(false); + + // 'c': normal → no row-level class + expect(rowC.classList.contains('row-hidden')).toBe(false); + + resetTable(); + }); + + it('summary message reflects pre-filtered input (noise excluded upstream)', () => { + const container = document.createElement('div'); + // 3 original rows, but noise row 'b' was filtered upstream + const tableRows = [ + makeRow({ test: 'a', status: 'improved' }), + makeRow({ test: 'c', status: 'regressed' }), + ]; + // 'a' is manually hidden → 1 of 2 visible + renderTable(container, tableRows, { hiddenTests: new Set(['a']) }); + + const message = container.querySelector('.table-message'); + expect(message).toBeTruthy(); + expect(message!.textContent).toBe('1 of 2 tests visible'); + + resetTable(); + }); +}); + +// =========================================================================== +// Profile column +// =========================================================================== + +describe('renderTable — profile column', () => { + let container: HTMLElement; + + function setup(): void { + container = document.createElement('div'); + vi.stubGlobal('window', { + location: { origin: 'http://localhost:3000' }, + }); + } + + it('renders 8th Profile column header when profileLinks is provided', () => { + setup(); + const rows = [makeRow({ test: 'a', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' })]; + const profileLinks = new Map([['a', '/profiles?suite=nts&run_a=r1&test_a=a']]); + renderTable(container, rows, { profileLinks }); + + const ths = container.querySelectorAll('thead th'); + expect(ths[ths.length - 1].textContent).toBe('Profile'); + resetTable(); + }); + + it('Profile header is NOT sortable', () => { + setup(); + const rows = [makeRow({ test: 'a', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' })]; + renderTable(container, rows, { profileLinks: new Map() }); + + const ths = container.querySelectorAll('thead th'); + const profileTh = ths[ths.length - 1]; + expect(profileTh.classList.contains('sortable')).toBe(false); + expect(profileTh.getAttribute('aria-sort')).toBeNull(); + resetTable(); + }); + + it('renders only 7 columns when profileLinks is undefined', () => { + setup(); + const rows = [makeRow({ test: 'a', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' })]; + renderTable(container, rows); + + const ths = container.querySelectorAll('thead th'); + expect(ths).toHaveLength(7); + resetTable(); + }); + + it('shows View link for tests in the profileLinks map', () => { + setup(); + const rows = [makeRow({ test: 'a', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' })]; + const profileLinks = new Map([['a', '/profiles?suite=nts&run_a=r1&test_a=a']]); + renderTable(container, rows, { profileLinks }); + + const dataRows = container.querySelectorAll('tbody tr[data-test]'); + expect(dataRows).toHaveLength(1); + const profileCell = dataRows[0].querySelector('.col-profile'); + expect(profileCell).toBeTruthy(); + const link = profileCell!.querySelector('a'); + expect(link).toBeTruthy(); + expect(link!.textContent).toBe('View'); + resetTable(); + }); + + it('shows empty cell for tests not in the map', () => { + setup(); + const rows = [makeRow({ test: 'b', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' })]; + const profileLinks = new Map([['a', '/profiles?suite=nts']]); + renderTable(container, rows, { profileLinks }); + + // Find profile cell for the data row (not geomean row) + const dataRows = container.querySelectorAll('tbody tr[data-test]'); + expect(dataRows).toHaveLength(1); + const profileCell = dataRows[0].querySelector('.col-profile'); + expect(profileCell).toBeTruthy(); + expect(profileCell!.querySelector('a')).toBeNull(); + resetTable(); + }); + + it('geomean row has empty profile cell', () => { + setup(); + const rows = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, delta: 100, deltaPct: 100, ratio: 2, status: 'regressed' }), + ]; + renderTable(container, rows, { profileLinks: new Map([['a', '/profiles']]) }); + + const geomeanRow = container.querySelector('.geomean-row'); + if (geomeanRow) { + const cells = geomeanRow.querySelectorAll('td'); + const lastCell = cells[cells.length - 1]; + expect(lastCell.classList.contains('col-profile')).toBe(true); + expect(lastCell.textContent).toBe(''); + } + resetTable(); + }); + + it('missing-tests table has Profile column when profileLinks provided', () => { + setup(); + const rows = [makeRow({ test: 'missing-a', sidePresent: 'a_only', valueA: 100, valueB: null })]; + renderTable(container, rows, { profileLinks: new Map() }); + + const missingTable = container.querySelector('.missing-table'); + expect(missingTable).toBeTruthy(); + const ths = missingTable!.querySelectorAll('th'); + expect(ths[ths.length - 1].textContent).toBe('Profile'); + resetTable(); + }); +}); + +describe('applyTableFilters (display:none fast path)', () => { + function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: null, valueB: null, delta: null, deltaPct: null, + ratio: null, status: 'unchanged', sidePresent: 'both', noiseReasons: [], + ...overrides, + }; + } + + const rows = [ + makeRow({ test: 'bench/compile', valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, status: 'regressed' }), + makeRow({ test: 'bench/link', valueA: 50, valueB: 45, delta: -5, deltaPct: -10, ratio: 0.9, status: 'improved' }), + makeRow({ test: 'bench/run', valueA: 200, valueB: 200, delta: 0, deltaPct: 0, ratio: 1.0, status: 'unchanged' }), + makeRow({ test: 'only-a', sidePresent: 'a_only', valueA: 10, valueB: null }), + ]; + + let container: HTMLElement; + + function setup(): void { + mockState.testFilter = ''; + mockState.sort = 'delta_pct' as const; + mockState.sortDir = 'desc' as const; + container = document.createElement('div'); + renderTable(container, rows); + } + + afterEach(() => { + mockState.testFilter = ''; + mockState.sort = 'delta_pct' as const; + mockState.sortDir = 'desc' as const; + resetTable(); + }); + + it('preserves the tbody element (no DOM rebuild)', () => { + setup(); + const tbody = container.querySelector('tbody'); + expect(tbody).toBeTruthy(); + + mockState.testFilter = 'compile'; + applyTableFilters(); + + const tbodyAfter = container.querySelector('tbody'); + expect(tbodyAfter).toBe(tbody); + }); + + it('hides non-matching rows via display:none', () => { + setup(); + mockState.testFilter = 'compile'; + applyTableFilters(); + + const compileRow = container.querySelector('tr[data-test="bench/compile"]') as HTMLElement; + const linkRow = container.querySelector('tr[data-test="bench/link"]') as HTMLElement; + expect(compileRow.style.display).toBe(''); + expect(linkRow.style.display).toBe('none'); + }); + + it('restores all rows when filter is cleared', () => { + setup(); + mockState.testFilter = 'compile'; + applyTableFilters(); + + mockState.testFilter = ''; + applyTableFilters(); + + const mainTable = container.querySelector('.comparison-table:not(.missing-table)'); + const dataRows = mainTable!.querySelectorAll<HTMLElement>('tr[data-test]'); + for (const tr of dataRows) { + expect(tr.style.display).toBe(''); + } + }); + + it('supports re: regex filter', () => { + setup(); + mockState.testFilter = 're:bench/(compile|link)'; + applyTableFilters(); + + const compileRow = container.querySelector('tr[data-test="bench/compile"]') as HTMLElement; + const linkRow = container.querySelector('tr[data-test="bench/link"]') as HTMLElement; + const runRow = container.querySelector('tr[data-test="bench/run"]') as HTMLElement; + expect(compileRow.style.display).toBe(''); + expect(linkRow.style.display).toBe(''); + expect(runRow.style.display).toBe('none'); + }); + + it('hides all present rows on invalid regex', () => { + setup(); + mockState.testFilter = 're:invalid['; + applyTableFilters(); + + const mainTable = container.querySelector('.comparison-table:not(.missing-table)'); + const dataRows = mainTable!.querySelectorAll<HTMLElement>('tr[data-test]'); + for (const tr of dataRows) { + expect(tr.style.display).toBe('none'); + } + }); + + it('toggles missing-test rows too', () => { + setup(); + mockState.testFilter = 'only-a'; + applyTableFilters(); + + const missingRow = container.querySelector('.missing-table tr[data-test="only-a"]') as HTMLElement; + expect(missingRow).toBeTruthy(); + expect(missingRow.style.display).toBe(''); + + const compileRow = container.querySelector('tr[data-test="bench/compile"]') as HTMLElement; + expect(compileRow.style.display).toBe('none'); + }); + + it('missing header shows total count when no filter is active', () => { + setup(); + const header = container.querySelector('.missing-header'); + expect(header).toBeTruthy(); + expect(header!.textContent).toBe('Missing tests (1)'); + }); + + it('missing header shows "0 of N matching" when text filter hides all missing rows', () => { + setup(); + mockState.testFilter = 'bench'; + applyTableFilters(); + + const header = container.querySelector('.missing-header'); + expect(header!.textContent).toBe('Missing tests (0 of 1 matching)'); + }); + + it('missing header shows "M of N matching" when text filter matches missing rows', () => { + setup(); + mockState.testFilter = 'only'; + applyTableFilters(); + + const header = container.querySelector('.missing-header'); + expect(header!.textContent).toBe('Missing tests (1 of 1 matching)'); + }); + + it('missing header resets to total count when filter is cleared', () => { + setup(); + mockState.testFilter = 'bench'; + applyTableFilters(); + expect(container.querySelector('.missing-header')!.textContent).toBe('Missing tests (0 of 1 matching)'); + + mockState.testFilter = ''; + applyTableFilters(); + expect(container.querySelector('.missing-header')!.textContent).toBe('Missing tests (1)'); + }); + + it('missing header remains visible even when all missing rows are filtered out', () => { + setup(); + mockState.testFilter = 'nonexistent'; + applyTableFilters(); + + const header = container.querySelector('.missing-header') as HTMLElement; + expect(header).toBeTruthy(); + expect(header.style.display).not.toBe('none'); + }); + + it('missing header counts multiple missing rows correctly under text filter', () => { + const multiMissingRows = [ + makeRow({ test: 'bench/compile', valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, status: 'regressed' }), + makeRow({ test: 'only-a', sidePresent: 'a_only', valueA: 10, valueB: null }), + makeRow({ test: 'only-b', sidePresent: 'b_only', valueA: null, valueB: 20 }), + makeRow({ test: 'only-alpha', sidePresent: 'a_only', valueA: 30, valueB: null }), + ]; + container = document.createElement('div'); + renderTable(container, multiMissingRows); + + mockState.testFilter = 'only-a'; + applyTableFilters(); + + const header = container.querySelector('.missing-header'); + // "only-a" and "only-alpha" match, "only-b" does not => 2 of 3 + expect(header!.textContent).toBe('Missing tests (2 of 3 matching)'); + resetTable(); + }); + + it('filterToTests (chart zoom) hides all missing rows since they have no chart presence', () => { + setup(); + filterToTests(new Set(['bench/compile'])); + + const header = container.querySelector('.missing-header'); + expect(header!.textContent).toBe('Missing tests (0 of 1 matching)'); + + const missingRow = container.querySelector('.missing-table tr[data-test="only-a"]') as HTMLElement; + expect(missingRow.style.display).toBe('none'); + }); + + it('filterToTests + testFilter combined: header reflects both filters', () => { + setup(); + mockState.testFilter = 'only'; + filterToTests(new Set(['bench/compile'])); + + const header = container.querySelector('.missing-header'); + // text filter matches 'only-a', but zoom filter excludes it => 0 visible + expect(header!.textContent).toBe('Missing tests (0 of 1 matching)'); + }); + + it('missing header is absent when there are no missing rows', () => { + const presentOnly = [ + makeRow({ test: 'bench/compile', valueA: 100, valueB: 110, delta: 10, deltaPct: 10, ratio: 1.1, status: 'regressed' }), + ]; + container = document.createElement('div'); + renderTable(container, presentOnly); + + expect(container.querySelector('.missing-header')).toBeNull(); + + mockState.testFilter = 'compile'; + applyTableFilters(); + expect(container.querySelector('.missing-header')).toBeNull(); + resetTable(); + }); + + it('resetTable clears missing header state for subsequent renders', () => { + setup(); + mockState.testFilter = 'bench'; + applyTableFilters(); + expect(container.querySelector('.missing-header')!.textContent).toBe('Missing tests (0 of 1 matching)'); + + resetTable(); + + // Re-render with different missing rows + const newRows = [ + makeRow({ test: 'x', valueA: 1, valueB: 2, delta: 1, deltaPct: 100, ratio: 2, status: 'regressed' }), + makeRow({ test: 'miss-1', sidePresent: 'a_only', valueA: 5, valueB: null }), + makeRow({ test: 'miss-2', sidePresent: 'b_only', valueA: null, valueB: 10 }), + ]; + mockState.testFilter = ''; + container = document.createElement('div'); + renderTable(container, newRows); + + const header = container.querySelector('.missing-header'); + expect(header!.textContent).toBe('Missing tests (2)'); + resetTable(); + }); + + it('updates geomean values in-place', () => { + const rowsWithGeomean = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + makeRow({ test: 'b', valueA: 400, valueB: 3200, ratio: 8.0, status: 'regressed' }), + ]; + container = document.createElement('div'); + renderTable(container, rowsWithGeomean); + + const geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeTruthy(); + let cells = geomeanRow!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('4.0000'); + + mockState.testFilter = 're:^a'; + applyTableFilters(); + + cells = geomeanRow!.querySelectorAll('td'); + expect(cells[5].textContent).toBe('2.0000'); + + resetTable(); + }); + + it('hides geomean row when filter matches zero tests', () => { + const rowsWithGeomean = [ + makeRow({ test: 'a', valueA: 100, valueB: 200, ratio: 2.0, status: 'improved' }), + ]; + container = document.createElement('div'); + renderTable(container, rowsWithGeomean); + + mockState.testFilter = 'nonexistent'; + applyTableFilters(); + + const geomeanRow = container.querySelector('.geomean-row') as HTMLElement; + expect(geomeanRow.style.display).toBe('none'); + + resetTable(); + }); + + it('updates summary message on filter', () => { + setup(); + mockState.testFilter = 'compile'; + applyTableFilters(); + + const msg = container.querySelector('.table-message'); + expect(msg).toBeTruthy(); + expect(msg!.textContent).toContain('1 of 3 tests matching'); + }); + + it('filterToTests uses fast path', () => { + setup(); + const tbody = container.querySelector('tbody'); + + filterToTests(new Set(['bench/compile', 'bench/link'])); + + const tbodyAfter = container.querySelector('tbody'); + expect(tbodyAfter).toBe(tbody); + + const runRow = container.querySelector('tr[data-test="bench/run"]') as HTMLElement; + expect(runRow.style.display).toBe('none'); + }); + + it('resetTable clears state so next renderTable works', () => { + setup(); + mockState.testFilter = 'compile'; + applyTableFilters(); + resetTable(); + + const container2 = document.createElement('div'); + renderTable(container2, rows); + const dataRows = container2.querySelectorAll<HTMLElement>('tr[data-test]'); + expect(dataRows.length).toBeGreaterThan(0); + resetTable(); + }); + + it('round-trip: fullRebuild -> applyFilters -> sort -> data correct', () => { + setup(); + + mockState.testFilter = 'bench'; + applyTableFilters(); + const missingRow = container.querySelector('.missing-table tr[data-test="only-a"]') as HTMLElement; + expect(missingRow.style.display).toBe('none'); + + // Sort change triggers full rebuild via renderTable + mockState.sort = 'test'; + mockState.sortDir = 'asc'; + mockState.testFilter = ''; + renderTable(container, rows); + + // All present rows should be visible + const mainTable = container.querySelector('.comparison-table:not(.missing-table)'); + const dataRows = mainTable!.querySelectorAll<HTMLElement>('tr[data-test]'); + for (const tr of dataRows) { + expect(tr.style.display).not.toBe('none'); + } + }); + + it('applyTableFilters preserves child elements appended to .table-message', () => { + setup(); + const msg = container.querySelector('.table-message')!; + const child = document.createElement('button'); + child.textContent = 'Copy'; + msg.append(child); + + mockState.testFilter = 'compile'; + applyTableFilters(); + + expect(msg.contains(child)).toBe(true); + expect(msg.querySelector('button')).toBe(child); + // Summary text is still correct + expect(msg.textContent).toContain('1 of 3 tests matching'); + }); + + it('filterToTests preserves child elements appended to .table-message', () => { + setup(); + const msg = container.querySelector('.table-message')!; + const child = document.createElement('button'); + child.textContent = 'Copy'; + msg.append(child); + + filterToTests(new Set(['bench/compile', 'bench/link'])); + + expect(msg.contains(child)).toBe(true); + expect(msg.querySelector('button')).toBe(child); + }); +}); + +// =========================================================================== +// Sample count tooltips +// =========================================================================== + +describe('renderTable — sample count tooltips', () => { + function makeRow(overrides: Partial<ComparisonRow> & { test: string }): ComparisonRow { + return { + valueA: null, valueB: null, delta: null, deltaPct: null, + ratio: null, status: 'unchanged', sidePresent: 'both', noiseReasons: [], + ...overrides, + }; + } + + let container: HTMLElement; + + afterEach(() => { + resetTable(); + }); + + it('sets title on Value A cell with exact format', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 10, valueB: 12, delta: 2, deltaPct: 20, ratio: 1.2, status: 'regressed', samplesA: 6, runsA: 2, samplesB: 4, runsB: 1 })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + expect(cells[1].getAttribute('title')).toBe('6 samples across 2 runs'); + }); + + it('sets title on Value B cell with exact format', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 10, valueB: 12, delta: 2, deltaPct: 20, ratio: 1.2, status: 'regressed', samplesA: 6, runsA: 2, samplesB: 4, runsB: 1 })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + expect(cells[2].getAttribute('title')).toBe('4 samples across 1 run'); + }); + + it('sets combined title on Delta/Delta%/Ratio cells', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 10, valueB: 12, delta: 2, deltaPct: 20, ratio: 1.2, status: 'regressed', samplesA: 6, runsA: 2, samplesB: 4, runsB: 1 })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + const expected = 'A: 6 samples across 2 runs, B: 4 samples across 1 run'; + expect(cells[3].getAttribute('title')).toBe(expected); + expect(cells[4].getAttribute('title')).toBe(expected); + expect(cells[5].getAttribute('title')).toBe(expected); + }); + + it('does not set title when counts are undefined', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 10, valueB: 12, delta: 2, deltaPct: 20, ratio: 1.2, status: 'regressed' })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + for (let i = 1; i <= 5; i++) { + expect(cells[i].getAttribute('title')).toBeNull(); + } + }); + + it('geomean row has no title attributes on value cells', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 100, valueB: 200, delta: 100, deltaPct: 100, ratio: 2, status: 'regressed', samplesA: 3, runsA: 1, samplesB: 3, runsB: 1 })]; + renderTable(container, rows); + + const geomeanRow = container.querySelector('.geomean-row'); + expect(geomeanRow).toBeTruthy(); + const cells = geomeanRow!.querySelectorAll('td'); + for (let i = 1; i <= 5; i++) { + expect(cells[i].getAttribute('title')).toBeNull(); + } + }); + + it('applies singular form for 1 sample across 1 run', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'a', valueA: 10, valueB: 12, delta: 2, deltaPct: 20, ratio: 1.2, status: 'regressed', samplesA: 1, runsA: 1, samplesB: 1, runsB: 1 })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + expect(cells[1].getAttribute('title')).toBe('1 sample across 1 run'); + expect(cells[2].getAttribute('title')).toBe('1 sample across 1 run'); + }); + + it('missing-test rows have no title attributes', () => { + container = document.createElement('div'); + const rows = [makeRow({ test: 'missing-a', sidePresent: 'a_only', valueA: 100, valueB: null, samplesA: 3, runsA: 1 })]; + renderTable(container, rows); + + const missingTable = container.querySelector('.missing-table'); + expect(missingTable).toBeTruthy(); + const cells = missingTable!.querySelectorAll('td'); + for (const cell of cells) { + expect(cell.getAttribute('title')).toBeNull(); + } + }); + + it('noise tooltip on Status cell coexists with sample tooltip on value cells', () => { + container = document.createElement('div'); + const rows = [makeRow({ + test: 'a', valueA: 10, valueB: 10.01, delta: 0.01, deltaPct: 0.1, ratio: 1.001, + status: 'noise', samplesA: 5, runsA: 2, samplesB: 3, runsB: 1, + noiseReasons: [{ knob: 'pct', message: 'Delta 0.1% below 1% threshold' }], + })]; + renderTable(container, rows); + + const dataRow = container.querySelector('tr[data-test="a"]')!; + const cells = dataRow.querySelectorAll('td'); + expect(cells[1].getAttribute('title')).toBe('5 samples across 2 runs'); + expect(cells[6].getAttribute('title')).toBe('Delta 0.1% below 1% threshold'); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/__tests__/utils.test.ts b/lnt/server/ui/v5/frontend/src/__tests__/utils.test.ts new file mode 100644 index 000000000..59179881b --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/__tests__/utils.test.ts @@ -0,0 +1,796 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../router', () => ({ + navigate: vi.fn(), + getBasePath: vi.fn(() => '/v5/nts'), +})); + +vi.mock('../api', () => ({ + getTestSuiteInfoCached: vi.fn(), + resolveCommits: vi.fn(), +})); + +import { + median, mean, safeMin, safeMax, getAggFn, geomean, + formatValue, formatPercent, formatRatio, formatTime, + truncate, + debounce, el, isModifiedClick, spaLink, + commitDisplayValue, resolveDisplayMap, + matchesFilter, isFilterValid, updateFilterValidation, +} from '../utils'; +import { navigate } from '../router'; +import { getTestSuiteInfoCached, resolveCommits } from '../api'; + +describe('median', () => { + it('returns 0 for empty array', () => { + expect(median([])).toBe(0); + }); + + it('returns the single value', () => { + expect(median([42])).toBe(42); + }); + + it('returns middle value for odd count', () => { + expect(median([1, 3, 5])).toBe(3); + expect(median([5, 1, 3])).toBe(3); // unsorted input + }); + + it('returns average of two middle values for even count', () => { + expect(median([1, 2, 3, 4])).toBe(2.5); + expect(median([4, 1, 3, 2])).toBe(2.5); // unsorted input + }); +}); + +describe('mean', () => { + it('returns 0 for empty array', () => { + expect(mean([])).toBe(0); + }); + + it('returns the single value', () => { + expect(mean([7])).toBe(7); + }); + + it('returns average of multiple values', () => { + expect(mean([2, 4, 6])).toBe(4); + expect(mean([1, 2, 3, 4])).toBe(2.5); + }); +}); + +describe('safeMin', () => { + it('returns 0 for empty array', () => { + expect(safeMin([])).toBe(0); + }); + + it('returns the minimum value', () => { + expect(safeMin([3, 1, 2])).toBe(1); + expect(safeMin([-5, 0, 5])).toBe(-5); + }); +}); + +describe('safeMax', () => { + it('returns 0 for empty array', () => { + expect(safeMax([])).toBe(0); + }); + + it('returns the maximum value', () => { + expect(safeMax([3, 1, 2])).toBe(3); + expect(safeMax([-5, 0, 5])).toBe(5); + }); +}); + +describe('geomean', () => { + it('computes geometric mean of positive values', () => { + expect(geomean([4, 16])).toBeCloseTo(8); + }); + + it('filters out zero and negative values', () => { + expect(geomean([4, 0, 16])).toBeCloseTo(8); + expect(geomean([4, -3, 16])).toBeCloseTo(8); + }); + + it('returns null when all values are invalid', () => { + expect(geomean([0, -1])).toBeNull(); + }); + + it('returns null for empty array', () => { + expect(geomean([])).toBeNull(); + }); + + it('returns the single value for a single-element array', () => { + expect(geomean([25])).toBeCloseTo(25); + }); +}); + +describe('getAggFn', () => { + it('returns the correct function for each name', () => { + expect(getAggFn('median')).toBe(median); + expect(getAggFn('mean')).toBe(mean); + expect(getAggFn('min')).toBe(safeMin); + expect(getAggFn('max')).toBe(safeMax); + }); +}); + +describe('formatValue', () => { + it('returns N/A for null', () => { + expect(formatValue(null)).toBe('N/A'); + }); + + it('returns "0" for zero', () => { + expect(formatValue(0)).toBe('0'); + }); + + it('formats large numbers with 1 decimal', () => { + expect(formatValue(1234.567)).toBe('1234.6'); + expect(formatValue(-5000.1)).toBe('-5000.1'); + }); + + it('formats medium numbers with 4 significant digits', () => { + expect(formatValue(12.34)).toBe('12.34'); + expect(formatValue(1.0)).toBe('1.000'); + }); + + it('formats small numbers with 3 significant digits', () => { + expect(formatValue(0.001234)).toBe('0.00123'); + expect(formatValue(0.5)).toBe('0.500'); + }); + + it('formats negative small numbers', () => { + expect(formatValue(-0.005)).toBe('-0.00500'); + }); + + describe('edge cases', () => { + it('returns "NaN" for NaN', () => { + expect(formatValue(NaN)).toBe('NaN'); + }); + + it('returns "Infinity" for Infinity', () => { + expect(formatValue(Infinity)).toBe('Infinity'); + }); + + it('returns "-Infinity" for -Infinity', () => { + expect(formatValue(-Infinity)).toBe('-Infinity'); + }); + + it('returns "0" for zero', () => { + expect(formatValue(0)).toBe('0'); + }); + }); +}); + +describe('formatPercent', () => { + it('returns N/A for null', () => { + expect(formatPercent(null)).toBe('N/A'); + }); + + it('adds + sign for positive values', () => { + expect(formatPercent(5.123)).toBe('+5.12%'); + }); + + it('keeps - sign for negative values', () => { + expect(formatPercent(-3.456)).toBe('-3.46%'); + }); + + it('formats zero without sign', () => { + expect(formatPercent(0)).toBe('0.00%'); + }); +}); + +describe('formatRatio', () => { + it('returns N/A for null', () => { + expect(formatRatio(null)).toBe('N/A'); + }); + + it('formats to 4 decimal places', () => { + expect(formatRatio(1.0)).toBe('1.0000'); + expect(formatRatio(0.9876)).toBe('0.9876'); + expect(formatRatio(1.23456)).toBe('1.2346'); + }); +}); + +describe('debounce', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('delays execution by the specified ms', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 200); + + debounced(); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(199); + expect(fn).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('only executes the last of multiple rapid calls', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced(); + debounced(); + debounced(); + + vi.advanceTimersByTime(100); + expect(fn).toHaveBeenCalledOnce(); + }); + + it('is actually called after the delay', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 50); + + debounced(); + vi.advanceTimersByTime(50); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + it('passes arguments through correctly', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced('hello', 42); + vi.advanceTimersByTime(100); + + expect(fn).toHaveBeenCalledWith('hello', 42); + }); + + it('resets the timer when called again before it fires', () => { + const fn = vi.fn(); + const debounced = debounce(fn, 100); + + debounced(); + vi.advanceTimersByTime(80); + expect(fn).not.toHaveBeenCalled(); + + // Call again — this should reset the 100ms timer + debounced(); + vi.advanceTimersByTime(80); + expect(fn).not.toHaveBeenCalled(); + + // 20ms more completes the second timer (80 + 20 = 100 from second call) + vi.advanceTimersByTime(20); + expect(fn).toHaveBeenCalledOnce(); + }); +}); + +describe('el', () => { + it('creates an element with the correct tag name', () => { + const div = el('div'); + expect(div.tagName).toBe('DIV'); + + const span = el('span'); + expect(span.tagName).toBe('SPAN'); + }); + + it('sets string attributes via setAttribute', () => { + const input = el('input', { type: 'text', id: 'my-input', class: 'form-control' }); + expect(input.getAttribute('type')).toBe('text'); + expect(input.getAttribute('id')).toBe('my-input'); + expect(input.getAttribute('class')).toBe('form-control'); + }); + + it('handles boolean attributes: true sets empty attribute, false omits it', () => { + const input = el('input', { disabled: true, hidden: false }); + expect(input.hasAttribute('disabled')).toBe(true); + expect(input.getAttribute('disabled')).toBe(''); + expect(input.hasAttribute('hidden')).toBe(false); + }); + + it('appends string children as text nodes', () => { + const p = el('p', undefined, 'Hello, world!'); + expect(p.childNodes.length).toBe(1); + expect(p.textContent).toBe('Hello, world!'); + }); + + it('appends Node children (e.g., another element)', () => { + const child = el('span'); + const parent = el('div', undefined, child); + expect(parent.childNodes.length).toBe(1); + expect(parent.firstChild).toBe(child); + }); + + it('appends multiple children in order', () => { + const first = el('span', undefined, 'first'); + const second = el('em', undefined, 'second'); + const parent = el('div', undefined, first, 'middle', second); + + expect(parent.childNodes.length).toBe(3); + expect(parent.childNodes[0]).toBe(first); + expect(parent.childNodes[1].textContent).toBe('middle'); + expect(parent.childNodes[2]).toBe(second); + }); + + it('works correctly with no attributes (undefined)', () => { + const div = el('div', undefined, 'content'); + expect(div.tagName).toBe('DIV'); + expect(div.attributes.length).toBe(0); + expect(div.textContent).toBe('content'); + }); +}); + +describe('formatTime', () => { + it('returns em-dash for null', () => { + expect(formatTime(null)).toBe('\u2014'); + }); + + it('returns empty string for null when custom fallback is empty', () => { + expect(formatTime(null, '')).toBe(''); + }); + + it('returns custom fallback for null', () => { + expect(formatTime(null, 'N/A')).toBe('N/A'); + }); + + it('returns em-dash for empty string', () => { + expect(formatTime('')).toBe('\u2014'); + }); + + it('returns a locale string for a valid ISO date', () => { + const result = formatTime('2025-01-15T10:30:00Z'); + // Just verify it returns a non-empty string (locale formatting varies) + expect(result.length).toBeGreaterThan(0); + expect(result).not.toBe('\u2014'); + }); +}); + +describe('truncate', () => { + it('returns original string when length <= max', () => { + expect(truncate('hello', 10)).toBe('hello'); + }); + + it('returns original string when length equals max', () => { + expect(truncate('hello', 5)).toBe('hello'); + }); + + it('truncates with ellipsis when length > max', () => { + expect(truncate('hello world', 5)).toBe('hello\u2026'); + }); + + it('handles empty string', () => { + expect(truncate('', 5)).toBe(''); + }); +}); + +describe('isModifiedClick', () => { + it('returns false for a plain left click', () => { + const e = new MouseEvent('click', { button: 0 }); + expect(isModifiedClick(e)).toBe(false); + }); + + it('returns true for metaKey (Cmd on macOS)', () => { + const e = new MouseEvent('click', { button: 0, metaKey: true }); + expect(isModifiedClick(e)).toBe(true); + }); + + it('returns true for ctrlKey (Ctrl+Click)', () => { + const e = new MouseEvent('click', { button: 0, ctrlKey: true }); + expect(isModifiedClick(e)).toBe(true); + }); + + it('returns true for shiftKey', () => { + const e = new MouseEvent('click', { button: 0, shiftKey: true }); + expect(isModifiedClick(e)).toBe(true); + }); + + it('returns true for altKey', () => { + const e = new MouseEvent('click', { button: 0, altKey: true }); + expect(isModifiedClick(e)).toBe(true); + }); + + it('returns true for middle-click (button 1)', () => { + const e = new MouseEvent('click', { button: 1 }); + expect(isModifiedClick(e)).toBe(true); + }); +}); + +describe('spaLink', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('creates an anchor with the correct href and text', () => { + const a = spaLink('Machines', '/machines'); + expect(a.tagName).toBe('A'); + expect(a.textContent).toBe('Machines'); + expect(a.getAttribute('href')).toBe('/v5/nts/machines'); + }); + + it('plain click calls navigate() and prevents default', () => { + const a = spaLink('Machines', '/machines'); + document.body.append(a); + + a.click(); + expect(navigate).toHaveBeenCalledWith('/machines'); + }); + + it('Cmd+Click does not call navigate()', () => { + const a = spaLink('Machines', '/machines'); + document.body.append(a); + + a.dispatchEvent(new MouseEvent('click', { bubbles: true, metaKey: true })); + expect(navigate).not.toHaveBeenCalled(); + }); + + it('Ctrl+Click does not call navigate()', () => { + const a = spaLink('Machines', '/machines'); + document.body.append(a); + + a.dispatchEvent(new MouseEvent('click', { bubbles: true, ctrlKey: true })); + expect(navigate).not.toHaveBeenCalled(); + }); +}); + +describe('commitDisplayValue', () => { + it('returns commit string when no commitFields provided', () => { + expect(commitDisplayValue({ commit: 'abc123', fields: { rev: 'v1.0' } })).toBe('abc123'); + }); + + it('returns commit string when no display field in schema', () => { + const fields = [{ name: 'rev' }]; + expect(commitDisplayValue({ commit: 'abc123', fields: { rev: 'v1.0' } }, fields)).toBe('abc123'); + }); + + it('returns display field value when display=true and value exists', () => { + const fields = [{ name: 'rev', display: true }]; + expect(commitDisplayValue({ commit: 'abc123', fields: { rev: 'v1.0' } }, fields)).toBe('v1.0'); + }); + + it('falls back to commit string when display field value is empty', () => { + const fields = [{ name: 'rev', display: true }]; + expect(commitDisplayValue({ commit: 'abc123', fields: {} }, fields)).toBe('abc123'); + }); + + it('falls back to commit string when display field value is missing', () => { + const fields = [{ name: 'tag', display: true }]; + expect(commitDisplayValue({ commit: 'abc123', fields: { rev: 'v1.0' } }, fields)).toBe('abc123'); + }); + + it('appends tag when tag is truthy', () => { + expect(commitDisplayValue({ commit: 'abc', fields: {}, tag: 'v1.0' })).toBe('abc (v1.0)'); + }); + + it('appends tag after display field value', () => { + const fields = [{ name: 'sha', display: true }]; + expect(commitDisplayValue({ commit: 'abc', fields: { sha: 'short' }, tag: 'v1.0' }, fields)).toBe('short (v1.0)'); + }); + + it('does not append tag when tag is null', () => { + expect(commitDisplayValue({ commit: 'abc', fields: {}, tag: null })).toBe('abc'); + }); + + it('does not append tag when tag is undefined', () => { + expect(commitDisplayValue({ commit: 'abc', fields: {} })).toBe('abc'); + }); +}); + +// --------------------------------------------------------------------------- +// resolveDisplayMap +// --------------------------------------------------------------------------- + +describe('resolveDisplayMap', () => { + const mockGetSuiteInfo = getTestSuiteInfoCached as ReturnType<typeof vi.fn>; + const mockResolve = resolveCommits as ReturnType<typeof vi.fn>; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns map with display values when schema has display field', async () => { + mockGetSuiteInfo.mockResolvedValue({ + name: 'nts', + schema: { metrics: [], commit_fields: [{ name: 'sha', display: true }], machine_fields: [] }, + }); + mockResolve.mockResolvedValue({ + results: { + 'abc': { commit: 'abc', ordinal: 1, tag: null, fields: { sha: 'short-abc' } }, + 'def': { commit: 'def', ordinal: 2, tag: null, fields: { sha: 'short-def' } }, + }, + not_found: [], + }); + + const map = await resolveDisplayMap('nts', ['abc', 'def']); + expect(map.get('abc')).toBe('short-abc'); + expect(map.get('def')).toBe('short-def'); + }); + + it('returns empty map when commits array is empty', async () => { + const map = await resolveDisplayMap('nts', []); + expect(map.size).toBe(0); + expect(mockGetSuiteInfo).not.toHaveBeenCalled(); + expect(mockResolve).not.toHaveBeenCalled(); + }); + + it('returns empty map on network error', async () => { + mockGetSuiteInfo.mockRejectedValue(new Error('network')); + + const map = await resolveDisplayMap('nts', ['abc']); + expect(map.size).toBe(0); + }); + + it('re-throws AbortError', async () => { + mockGetSuiteInfo.mockRejectedValue(new DOMException('Aborted', 'AbortError')); + + await expect(resolveDisplayMap('nts', ['abc'])).rejects.toThrow('Aborted'); + }); + + it('only includes entries where display value differs from raw commit', async () => { + mockGetSuiteInfo.mockResolvedValue({ + name: 'nts', + schema: { metrics: [], commit_fields: [{ name: 'sha', display: true }], machine_fields: [] }, + }); + mockResolve.mockResolvedValue({ + results: { + 'abc': { commit: 'abc', ordinal: 1, tag: null, fields: { sha: 'short-abc' } }, + 'def': { commit: 'def', ordinal: 2, tag: null, fields: {} }, + }, + not_found: [], + }); + + const map = await resolveDisplayMap('nts', ['abc', 'def']); + expect(map.get('abc')).toBe('short-abc'); + // 'def' has no sha field, so commitDisplayValue returns 'def' — included in map + // but the value equals the key (identity mapping) + expect(map.get('def')).toBe('def'); + }); +}); + +// --------------------------------------------------------------------------- +// matchesFilter / isFilterValid +// --------------------------------------------------------------------------- + +describe('matchesFilter', () => { + it('returns true for empty filter', () => { + expect(matchesFilter('anything', '')).toBe(true); + }); + + it('returns true when text contains the filter (case-insensitive substring)', () => { + expect(matchesFilter('SingleSource/UnitTests/benchmark', 'unittest')).toBe(true); + expect(matchesFilter('clang-x86_64', 'X86')).toBe(true); + }); + + it('returns false when text does not contain the filter', () => { + expect(matchesFilter('clang-x86_64', 'arm')).toBe(false); + }); + + it('handles regex-special characters literally in plain mode', () => { + expect(matchesFilter('test.suite', 'test.suite')).toBe(true); + expect(matchesFilter('test_suite', 'test.suite')).toBe(false); + expect(matchesFilter('foo[0]', '[0]')).toBe(true); + expect(matchesFilter('foo(bar)', '(bar)')).toBe(true); + }); + + it('test names containing / work as plain substring', () => { + expect(matchesFilter('SingleSource/UnitTests/benchmark', '/UnitTests/')).toBe(true); + expect(matchesFilter('SingleSource/UnitTests/benchmark', '/Other/')).toBe(false); + }); + + it('handles re: prefix for regex mode', () => { + expect(matchesFilter('clang-x86_64', 're:clang.*x86')).toBe(true); + expect(matchesFilter('clang-arm', 're:clang.*x86')).toBe(false); + }); + + it('regex is case-insensitive', () => { + expect(matchesFilter('CLANG-x86', 're:clang')).toBe(true); + expect(matchesFilter('clang-x86', 're:CLANG')).toBe(true); + }); + + it('re: alone matches everything', () => { + expect(matchesFilter('anything', 're:')).toBe(true); + expect(matchesFilter('', 're:')).toBe(true); + }); + + it('returns false for invalid regex', () => { + expect(matchesFilter('test', 're:invalid[')).toBe(false); + expect(matchesFilter('test', 're:(?P<bad)')).toBe(false); + }); + + it('re: prefix is case-sensitive (RE: is plain substring)', () => { + expect(matchesFilter('RE:foo', 'RE:foo')).toBe(true); + expect(matchesFilter('re:foo', 'RE:foo')).toBe(true); // case-insensitive substring match + expect(matchesFilter('bar', 'RE:bar')).toBe(false); // plain substring "RE:bar" not in "bar" + }); + + it('// is treated as plain substring, not regex', () => { + expect(matchesFilter('a//b', '//')).toBe(true); + expect(matchesFilter('ab', '//')).toBe(false); + }); + + it('matches empty text with empty filter', () => { + expect(matchesFilter('', '')).toBe(true); + }); + + it('plain filter with empty text', () => { + expect(matchesFilter('', 'something')).toBe(false); + }); +}); + +describe('isFilterValid', () => { + it('returns true for empty filter', () => { + expect(isFilterValid('')).toBe(true); + }); + + it('returns true for plain substring filter', () => { + expect(isFilterValid('hello')).toBe(true); + expect(isFilterValid('/path/to/test/')).toBe(true); + }); + + it('returns true for valid regex', () => { + expect(isFilterValid('re:clang.*x86')).toBe(true); + expect(isFilterValid('re:')).toBe(true); + expect(isFilterValid('re:^test$')).toBe(true); + }); + + it('returns false for invalid regex', () => { + expect(isFilterValid('re:invalid[')).toBe(false); + expect(isFilterValid('re:(?P<bad)')).toBe(false); + expect(isFilterValid('re:*')).toBe(false); + }); + + it('RE: (uppercase) is plain substring, always valid', () => { + expect(isFilterValid('RE:invalid[')).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// updateFilterValidation +// --------------------------------------------------------------------------- + +describe('updateFilterValidation', () => { + let container: HTMLElement; + let input: HTMLInputElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.append(container); + input = document.createElement('input'); + input.type = 'text'; + container.append(input); + }); + + afterEach(() => { + container.remove(); + }); + + it('creates a badge when input starts with re:', () => { + input.value = 're:test'; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge'); + expect(badge).toBeTruthy(); + expect(badge!.textContent).toBe('regex'); + }); + + it('badge has aria-hidden="true"', () => { + input.value = 're:test'; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge'); + expect(badge!.getAttribute('aria-hidden')).toBe('true'); + }); + + it('removes badge when re: prefix is cleared', () => { + input.value = 're:test'; + updateFilterValidation(input); + expect(input.parentElement!.querySelector('.filter-regex-badge')).toBeTruthy(); + + input.value = 'test'; + updateFilterValidation(input); + expect(input.parentElement!.querySelector('.filter-regex-badge')).toBeNull(); + }); + + it('toggles filter-regex-badge-invalid on invalid regex', () => { + input.value = 're:invalid['; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge'); + expect(badge!.classList.contains('filter-regex-badge-invalid')).toBe(true); + }); + + it('badge has no invalid class on valid regex', () => { + input.value = 're:valid.*'; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge'); + expect(badge!.classList.contains('filter-regex-badge-invalid')).toBe(false); + }); + + it('wraps input in filter-input-wrapper when parent is not positioned', () => { + input.value = 're:test'; + updateFilterValidation(input); + expect(input.parentElement!.classList.contains('filter-input-wrapper')).toBe(true); + expect(input.parentElement!.parentElement).toBe(container); + }); + + it('does NOT double-wrap on second call', () => { + input.value = 're:test'; + updateFilterValidation(input); + updateFilterValidation(input); + const wrappers = container.querySelectorAll('.filter-input-wrapper'); + expect(wrappers).toHaveLength(1); + }); + + it('does NOT wrap when parent is .combobox', () => { + container.classList.add('combobox'); + input.value = 're:test'; + updateFilterValidation(input); + expect(input.parentElement).toBe(container); + expect(container.querySelector('.filter-regex-badge')).toBeTruthy(); + }); + + it('does NOT wrap when parent is .commit-search', () => { + container.classList.add('commit-search'); + input.value = 're:test'; + updateFilterValidation(input); + expect(input.parentElement).toBe(container); + expect(container.querySelector('.filter-regex-badge')).toBeTruthy(); + }); + + it('bare re: (empty pattern) shows valid badge', () => { + input.value = 're:'; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge'); + expect(badge).toBeTruthy(); + expect(badge!.classList.contains('filter-regex-badge-invalid')).toBe(false); + }); + + it('RE: (uppercase) does NOT trigger badge', () => { + input.value = 'RE:test'; + updateFilterValidation(input); + expect(container.querySelector('.filter-regex-badge')).toBeNull(); + }); + + it('toggles filter-invalid on invalid regex', () => { + input.value = 're:invalid['; + updateFilterValidation(input); + expect(input.classList.contains('filter-invalid')).toBe(true); + + input.value = 're:valid'; + updateFilterValidation(input); + expect(input.classList.contains('filter-invalid')).toBe(false); + }); + + it('no filter-invalid on plain text', () => { + input.value = 'plain text'; + updateFilterValidation(input); + expect(input.classList.contains('filter-invalid')).toBe(false); + }); + + it('invalid-to-valid transition removes filter-regex-badge-invalid', () => { + input.value = 're:invalid['; + updateFilterValidation(input); + const badge = input.parentElement!.querySelector('.filter-regex-badge')!; + expect(badge.classList.contains('filter-regex-badge-invalid')).toBe(true); + + input.value = 're:valid.*'; + updateFilterValidation(input); + expect(badge.classList.contains('filter-regex-badge-invalid')).toBe(false); + }); + + it('does NOT create duplicate badges on repeated calls', () => { + input.value = 're:test'; + updateFilterValidation(input); + updateFilterValidation(input); + updateFilterValidation(input); + const badges = input.parentElement!.querySelectorAll('.filter-regex-badge'); + expect(badges).toHaveLength(1); + }); + + it('adds filter-has-badge class when badge is shown', () => { + input.value = 're:test'; + updateFilterValidation(input); + expect(input.classList.contains('filter-has-badge')).toBe(true); + }); + + it('removes filter-has-badge when badge is removed', () => { + input.value = 're:test'; + updateFilterValidation(input); + input.value = 'plain'; + updateFilterValidation(input); + expect(input.classList.contains('filter-has-badge')).toBe(false); + }); +}); diff --git a/lnt/server/ui/v5/frontend/src/api.ts b/lnt/server/ui/v5/frontend/src/api.ts new file mode 100644 index 000000000..ecee0225e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/api.ts @@ -0,0 +1,708 @@ +import type { + APIKeyCreateResponse, APIKeyItem, + CursorPaginated, FieldChangeInfo, FieldInfo, MachineInfo, MachineRunInfo, + OffsetPaginated, CommitDetail, CommitResolveResponse, CommitSummary, RunDetail, + RunInfo, SampleInfo, TestSuiteInfo, + RegressionListItem, RegressionDetail, RegressionState, + ProfileListItem, ProfileMetadata, ProfileFunctionInfo, ProfileFunctionDetail, +} from './types'; + +let apiBase = ''; + +export function setApiBase(base: string): void { + // base should be the lnt_url_base value, e.g. "" or "/lnt" + apiBase = base.replace(/\/$/, ''); +} + +export function getToken(): string | null { + return localStorage.getItem('lnt_v5_token'); +} + +/** Structured API error with HTTP status code. */ +export class ApiError extends Error { + constructor(public readonly status: number, message: string) { + super(message); + } +} + +/** Format an auth/permission error for display. */ +export function authErrorMessage(err: unknown): string { + if (err instanceof ApiError && (err.status === 401 || err.status === 403)) { + return "Permission denied. Set an API token with the required scope in Settings."; + } + return `Error: ${err}`; +} + +interface FetchOptions { + params?: Record<string, string | string[]>; + signal?: AbortSignal; + method?: string; + body?: unknown; +} + +async function fetchJson<T>(url: string, opts?: FetchOptions): Promise<T> { + const params = opts?.params; + const signal = opts?.signal; + const method = opts?.method; + const body = opts?.body; + + const u = new URL(url, window.location.origin); + if (params) { + for (const [k, v] of Object.entries(params)) { + if (Array.isArray(v)) { + for (const item of v) u.searchParams.append(k, item); + } else if (v !== undefined && v !== '') { + u.searchParams.set(k, v); + } + } + } + const headers: Record<string, string> = { 'Accept': 'application/json' }; + const token = getToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + + const init: RequestInit = { headers, signal }; + if (method) init.method = method; + if (body !== undefined) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + + const resp = await fetch(u.toString(), init); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new ApiError(resp.status, `API ${resp.status}: ${text || resp.statusText}`); + } + return resp.json(); +} + +/** Like fetchJson but for endpoints that return no body (e.g. DELETE → 204). */ +async function fetchVoid(url: string, opts?: FetchOptions): Promise<void> { + const u = new URL(url, window.location.origin); + if (opts?.params) { + for (const [k, v] of Object.entries(opts.params)) { + if (Array.isArray(v)) { + for (const item of v) u.searchParams.append(k, item); + } else if (v !== undefined && v !== '') { + u.searchParams.set(k, v); + } + } + } + const headers: Record<string, string> = {}; + const token = getToken(); + if (token) headers['Authorization'] = `Bearer ${token}`; + + const init: RequestInit = { headers, signal: opts?.signal }; + if (opts?.method) init.method = opts.method; + + const resp = await fetch(u.toString(), init); + if (!resp.ok) { + const text = await resp.text().catch(() => ''); + throw new ApiError(resp.status, `API ${resp.status}: ${text || resp.statusText}`); + } +} + +async function fetchAllCursorPages<T>( + url: string, + params?: Record<string, string | string[]>, + signal?: AbortSignal, + onProgress?: (loaded: number) => void, + postBody?: Record<string, unknown>, +): Promise<T[]> { + const all: T[] = []; + let cursor: string | undefined; + + while (true) { + let page: CursorPaginated<T>; + if (postBody !== undefined) { + // POST mode: parameters go in JSON body, cursor merged in. + const body = { ...postBody, limit: 10000, ...(cursor ? { cursor } : {}) }; + page = await fetchJson<CursorPaginated<T>>(url, { method: 'POST', body, signal }); + } else { + // GET mode: parameters go in URL query string. + const p: Record<string, string | string[]> = { ...params, limit: '10000' }; + if (cursor) p.cursor = cursor; + page = await fetchJson<CursorPaginated<T>>(url, { params: p, signal }); + } + all.push(...page.items); + if (onProgress) onProgress(all.length); + if (!page.cursor.next) break; + cursor = page.cursor.next; + } + return all; +} + +export function apiUrl(ts: string, path: string): string { + return `${apiBase}/api/v5/${encodeURIComponent(ts)}/${path}`; +} + +export interface CursorPageResult<T> { + items: T[]; + nextCursor: string | null; +} + +/** + * Fetch exactly one page of cursor-paginated results. + * Unlike fetchAllCursorPages, the caller controls limit and cursor via params. + */ +export async function fetchOneCursorPage<T>( + url: string, + params?: Record<string, string | string[]>, + signal?: AbortSignal, +): Promise<CursorPageResult<T>> { + const page = await fetchJson<CursorPaginated<T>>(url, { params, signal }); + return { items: page.items, nextCursor: page.cursor.next }; +} + +/** + * POST one page of cursor-paginated results with a JSON body. + * Used by the query endpoint where parameters are in the request body. + */ +export async function postOneCursorPage<T>( + url: string, + body: Record<string, unknown>, + signal?: AbortSignal, +): Promise<CursorPageResult<T>> { + const page = await fetchJson<CursorPaginated<T>>(url, { method: 'POST', body, signal }); + return { items: page.items, nextCursor: page.cursor.next }; +} + +export async function getFields(ts: string, _signal?: AbortSignal): Promise<FieldInfo[]> { + const info = await getTestSuiteInfoCached(ts); + return info.schema.metrics; +} + +export async function getCommits( + ts: string, + opts?: { machine?: string; has_profiles?: boolean; signal?: AbortSignal; onProgress?: (loaded: number) => void }, +): Promise<CommitSummary[]> { + const params: Record<string, string> = {}; + if (opts?.machine) params.machine = opts.machine; + if (opts?.has_profiles !== undefined) params.has_profiles = String(opts.has_profiles); + return fetchAllCursorPages<CommitSummary>(apiUrl(ts, 'commits'), params, opts?.signal, opts?.onProgress); +} + +export async function getMachines( + ts: string, + opts: { search?: string; limit?: number; offset?: number }, + signal?: AbortSignal, +): Promise<{ items: MachineInfo[]; total: number }> { + const params: Record<string, string> = {}; + if (opts.search) params.search = opts.search; + if (opts.limit !== undefined) params.limit = String(opts.limit); + if (opts.offset !== undefined) params.offset = String(opts.offset); + const data = await fetchJson<OffsetPaginated<MachineInfo>>(apiUrl(ts, 'machines'), { params, signal }); + return { items: data.items, total: data.total }; +} + +export async function getRuns( + ts: string, + opts: { machine: string; commit?: string; has_profiles?: boolean }, + signal?: AbortSignal, +): Promise<RunInfo[]> { + const params: Record<string, string> = { machine: opts.machine }; + if (opts.commit) params.commit = opts.commit; + if (opts.has_profiles !== undefined) params.has_profiles = String(opts.has_profiles); + return fetchAllCursorPages<RunInfo>( + apiUrl(ts, 'runs'), + params, + signal, + ); +} + +export async function getSamples( + ts: string, + runUuid: string, + signal?: AbortSignal, + onProgress?: (loaded: number) => void, +): Promise<SampleInfo[]> { + return fetchAllCursorPages<SampleInfo>( + apiUrl(ts, `runs/${encodeURIComponent(runUuid)}/samples`), + undefined, + signal, + onProgress, + ); +} + +export async function getMachine( + ts: string, + name: string, + signal?: AbortSignal, +): Promise<MachineInfo> { + return fetchJson<MachineInfo>( + apiUrl(ts, `machines/${encodeURIComponent(name)}`), + { signal }, + ); +} + +export async function getMachineRuns( + ts: string, + machineName: string, + opts?: { sort?: string; limit?: number; cursor?: string }, + signal?: AbortSignal, +): Promise<CursorPaginated<MachineRunInfo>> { + const params: Record<string, string> = {}; + if (opts?.sort) params.sort = opts.sort; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + if (opts?.cursor) params.cursor = opts.cursor; + return fetchJson<CursorPaginated<MachineRunInfo>>( + apiUrl(ts, `machines/${encodeURIComponent(machineName)}/runs`), + { params, signal }, + ); +} + +export async function deleteMachine(ts: string, name: string): Promise<void> { + return fetchVoid(apiUrl(ts, `machines/${encodeURIComponent(name)}`), { method: 'DELETE' }); +} + +export async function getRun( + ts: string, + uuid: string, + signal?: AbortSignal, +): Promise<RunDetail> { + return fetchJson<RunDetail>( + apiUrl(ts, `runs/${encodeURIComponent(uuid)}`), + { signal }, + ); +} + +export async function deleteRun(ts: string, uuid: string): Promise<void> { + return fetchVoid(apiUrl(ts, `runs/${encodeURIComponent(uuid)}`), { method: 'DELETE' }); +} + +export async function getCommit( + ts: string, + value: string, + signal?: AbortSignal, +): Promise<CommitDetail> { + return fetchJson<CommitDetail>( + apiUrl(ts, `commits/${encodeURIComponent(value)}`), + { signal }, + ); +} + +export async function getRunsByCommit( + ts: string, + commitValue: string, + signal?: AbortSignal, +): Promise<RunInfo[]> { + return fetchAllCursorPages<RunInfo>( + apiUrl(ts, 'runs'), + { commit: commitValue }, + signal, + ); +} + +export async function getFieldChanges( + ts: string, + opts?: { limit?: number; cursor?: string }, + signal?: AbortSignal, +): Promise<CursorPaginated<FieldChangeInfo>> { + const params: Record<string, string> = {}; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + if (opts?.cursor) params.cursor = opts.cursor; + return fetchJson<CursorPaginated<FieldChangeInfo>>( + apiUrl(ts, 'field-changes'), + { params, signal }, + ); +} + +export async function searchCommits( + ts: string, + searchPrefix: string, + opts?: { limit?: number }, + signal?: AbortSignal, +): Promise<CursorPaginated<CommitSummary>> { + const params: Record<string, string> = { search: searchPrefix }; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + return fetchJson<CursorPaginated<CommitSummary>>( + apiUrl(ts, 'commits'), + { params, signal }, + ); +} + +/** Fetch one page of runs with optional filters (cursor-paginated). */ +export async function getRunsPage( + ts: string, + opts?: { machine?: string; sort?: string; limit?: number; cursor?: string }, + signal?: AbortSignal, +): Promise<CursorPageResult<RunInfo>> { + const params: Record<string, string> = {}; + if (opts?.machine) params.machine = opts.machine; + if (opts?.sort) params.sort = opts.sort; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + if (opts?.cursor) params.cursor = opts.cursor; + return fetchOneCursorPage<RunInfo>(apiUrl(ts, 'runs'), params, signal); +} + +/** Fetch one page of commits with optional search filter (cursor-paginated). */ +export async function getCommitsPage( + ts: string, + opts?: { search?: string; limit?: number; cursor?: string }, + signal?: AbortSignal, +): Promise<CursorPageResult<CommitSummary>> { + const params: Record<string, string> = {}; + if (opts?.search) params.search = opts.search; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + if (opts?.cursor) params.cursor = opts.cursor; + return fetchOneCursorPage<CommitSummary>(apiUrl(ts, 'commits'), params, signal); +} + +export async function updateCommit( + ts: string, + commitValue: string, + updates: Record<string, unknown>, + signal?: AbortSignal, +): Promise<CommitDetail> { + return fetchJson<CommitDetail>( + apiUrl(ts, `commits/${encodeURIComponent(commitValue)}`), + { method: 'PATCH', body: updates, signal }, + ); +} + +export async function getTests( + ts: string, + opts?: { machine?: string; metric?: string; search?: string; limit?: number }, + signal?: AbortSignal, +): Promise<CursorPageResult<{ name: string }>> { + const params: Record<string, string> = {}; + if (opts?.machine) params.machine = opts.machine; + if (opts?.metric) params.metric = opts.metric; + if (opts?.search) params.search = opts.search; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + return fetchOneCursorPage<{ name: string }>(apiUrl(ts, 'tests'), params, signal); +} + +// --------------------------------------------------------------------------- +// Test suite info +// --------------------------------------------------------------------------- + +export async function getTestSuiteInfo( + ts: string, + signal?: AbortSignal, +): Promise<TestSuiteInfo> { + return fetchJson<TestSuiteInfo>( + `${apiBase}/api/v5/test-suites/${encodeURIComponent(ts)}`, + { signal }, + ); +} + +export async function createTestSuite( + payload: Record<string, unknown>, + signal?: AbortSignal, +): Promise<TestSuiteInfo> { + return fetchJson<TestSuiteInfo>( + `${apiBase}/api/v5/test-suites/`, + { method: 'POST', body: payload, signal }, + ); +} + +export async function deleteTestSuite( + name: string, + signal?: AbortSignal, +): Promise<void> { + return fetchVoid( + `${apiBase}/api/v5/test-suites/${encodeURIComponent(name)}?confirm=true`, + { method: 'DELETE', signal }, + ); +} + +// --------------------------------------------------------------------------- +// Trends (server-side geomean aggregation for Dashboard) +// --------------------------------------------------------------------------- + +export interface TrendsDataPoint { + machine: string; + commit: string; + ordinal: number; + tag: string | null; + submitted_at: string | null; + value: number; +} + +export async function fetchTrends( + ts: string, + opts: { metric: string; machine?: string[]; lastN?: number }, + signal?: AbortSignal, +): Promise<TrendsDataPoint[]> { + const body: Record<string, unknown> = { metric: opts.metric }; + if (opts.machine?.length) body.machine = opts.machine; + if (opts.lastN) body.last_n = opts.lastN; + const data = await fetchJson<{ metric: string; items: TrendsDataPoint[] }>( + apiUrl(ts, 'trends'), { method: 'POST', body, signal }); + return data.items; +} + +// --------------------------------------------------------------------------- +// Admin — API keys (requires admin-scoped token) +// --------------------------------------------------------------------------- + +export async function getApiKeys(signal?: AbortSignal): Promise<APIKeyItem[]> { + const data = await fetchJson<{ items: APIKeyItem[] }>( + `${apiBase}/api/v5/admin/api-keys`, + { signal }, + ); + return data.items; +} + +export async function createApiKey( + name: string, + scope: string, + signal?: AbortSignal, +): Promise<APIKeyCreateResponse> { + return fetchJson<APIKeyCreateResponse>( + `${apiBase}/api/v5/admin/api-keys`, + { method: 'POST', body: { name, scope }, signal }, + ); +} + +export async function revokeApiKey( + prefix: string, + signal?: AbortSignal, +): Promise<void> { + return fetchVoid( + `${apiBase}/api/v5/admin/api-keys/${encodeURIComponent(prefix)}`, + { method: 'DELETE', signal }, + ); +} + +// --------------------------------------------------------------------------- +// Profiles +// --------------------------------------------------------------------------- + +/** List profiles for a run (non-paginated). */ +export async function getProfilesForRun( + ts: string, + runUuid: string, + signal?: AbortSignal, +): Promise<ProfileListItem[]> { + return fetchJson<ProfileListItem[]>( + apiUrl(ts, `runs/${encodeURIComponent(runUuid)}/profiles`), + { signal }, + ); +} + +/** Get profile metadata + top-level counters. */ +export async function getProfileMetadata( + ts: string, + profileUuid: string, + signal?: AbortSignal, +): Promise<ProfileMetadata> { + return fetchJson<ProfileMetadata>( + apiUrl(ts, `profiles/${encodeURIComponent(profileUuid)}`), + { signal }, + ); +} + +/** Get function list (sorted hottest-first by server). */ +export async function getProfileFunctions( + ts: string, + profileUuid: string, + signal?: AbortSignal, +): Promise<{ functions: ProfileFunctionInfo[] }> { + return fetchJson<{ functions: ProfileFunctionInfo[] }>( + apiUrl(ts, `profiles/${encodeURIComponent(profileUuid)}/functions`), + { signal }, + ); +} + +/** Get function detail with per-instruction disassembly. */ +export async function getProfileFunctionDetail( + ts: string, + profileUuid: string, + fnName: string, + signal?: AbortSignal, +): Promise<ProfileFunctionDetail> { + return fetchJson<ProfileFunctionDetail>( + apiUrl(ts, `profiles/${encodeURIComponent(profileUuid)}/functions/${encodeURIComponent(fnName)}`), + { signal }, + ); +} + +// --------------------------------------------------------------------------- +// Regressions +// --------------------------------------------------------------------------- + +/** Query parameters for listing regressions. */ +export interface RegressionListParams { + state?: RegressionState[]; + machine?: string; + test?: string; + metric?: string; + commit?: string; + has_commit?: boolean; + cursor?: string; + limit?: number; +} + +function buildRegressionParams(opts?: Partial<RegressionListParams>): Record<string, string> { + const params: Record<string, string> = {}; + if (opts?.state?.length) params.state = opts.state.join(','); + if (opts?.machine) params.machine = opts.machine; + if (opts?.test) params.test = opts.test; + if (opts?.metric) params.metric = opts.metric; + if (opts?.commit) params.commit = opts.commit; + if (opts?.has_commit === true) params.has_commit = 'true'; + if (opts?.has_commit === false) params.has_commit = 'false'; + if (opts?.limit !== undefined) params.limit = String(opts.limit); + if (opts?.cursor) params.cursor = opts.cursor; + return params; +} + +/** Fetch one page of regressions with optional filters. */ +export async function getRegressions( + ts: string, + opts?: RegressionListParams, + signal?: AbortSignal, +): Promise<CursorPageResult<RegressionListItem>> { + return fetchOneCursorPage<RegressionListItem>( + apiUrl(ts, 'regressions'), buildRegressionParams(opts), signal); +} + +/** Fetch all regressions matching filters (auto-paginate). */ +export async function getAllRegressions( + ts: string, + opts?: Omit<RegressionListParams, 'cursor' | 'limit'>, + signal?: AbortSignal, +): Promise<RegressionListItem[]> { + return fetchAllCursorPages<RegressionListItem>( + apiUrl(ts, 'regressions'), buildRegressionParams(opts), signal); +} + +/** Create a new regression. */ +export async function createRegression( + ts: string, + body: { + title?: string; + bug?: string; + notes?: string; + state?: RegressionState; + commit?: string; + indicators?: Array<{ machine: string; test: string; metric: string }>; + }, + signal?: AbortSignal, +): Promise<RegressionDetail> { + return fetchJson<RegressionDetail>( + apiUrl(ts, 'regressions'), + { method: 'POST', body, signal }, + ); +} + +/** Fetch a single regression by UUID. */ +export async function getRegression( + ts: string, + uuid: string, + signal?: AbortSignal, +): Promise<RegressionDetail> { + return fetchJson<RegressionDetail>( + apiUrl(ts, `regressions/${encodeURIComponent(uuid)}`), + { signal }, + ); +} + +/** Update regression fields (PATCH -- only included fields are changed). */ +export async function updateRegression( + ts: string, + uuid: string, + updates: { + title?: string; + bug?: string | null; + notes?: string | null; + state?: RegressionState; + commit?: string | null; + }, + signal?: AbortSignal, +): Promise<RegressionDetail> { + return fetchJson<RegressionDetail>( + apiUrl(ts, `regressions/${encodeURIComponent(uuid)}`), + { method: 'PATCH', body: updates, signal }, + ); +} + +/** Delete a regression. */ +export async function deleteRegression( + ts: string, + uuid: string, + signal?: AbortSignal, +): Promise<void> { + return fetchVoid( + apiUrl(ts, `regressions/${encodeURIComponent(uuid)}`), + { method: 'DELETE', signal }, + ); +} + +/** Add indicators to a regression (batch). Returns updated detail. */ +export async function addRegressionIndicators( + ts: string, + regressionUuid: string, + indicators: Array<{ machine: string; test: string; metric: string }>, + signal?: AbortSignal, +): Promise<RegressionDetail> { + return fetchJson<RegressionDetail>( + apiUrl(ts, `regressions/${encodeURIComponent(regressionUuid)}/indicators`), + { method: 'POST', body: { indicators }, signal }, + ); +} + +/** Remove indicators from a regression (batch, by UUID). Returns updated detail. */ +export async function removeRegressionIndicators( + ts: string, + regressionUuid: string, + indicatorUuids: string[], + signal?: AbortSignal, +): Promise<RegressionDetail> { + return fetchJson<RegressionDetail>( + apiUrl(ts, `regressions/${encodeURIComponent(regressionUuid)}/indicators`), + { method: 'DELETE', body: { indicator_uuids: indicatorUuids }, signal }, + ); +} + + +// --------------------------------------------------------------------------- +// Cached test suite info +// --------------------------------------------------------------------------- + +const suiteInfoCache = new Map<string, Promise<TestSuiteInfo>>(); + +/** Clear the suite-info cache. Exported for test use only. */ +export function _clearSuiteInfoCache(): void { + suiteInfoCache.clear(); +} + +/** + * Fetch test suite info with per-suite memoization. + * The Promise is cached so concurrent callers coalesce on one request. + * Rejected promises are evicted so retries work. + * + * Note: the caller's signal is NOT forwarded to the cached request. + * This prevents one caller's abort from poisoning the shared Promise + * for other callers. + */ +export function getTestSuiteInfoCached( + ts: string, + _signal?: AbortSignal, +): Promise<TestSuiteInfo> { + let cached = suiteInfoCache.get(ts); + if (!cached) { + cached = getTestSuiteInfo(ts); + suiteInfoCache.set(ts, cached); + cached.catch(() => suiteInfoCache.delete(ts)); + } + return cached; +} + + +// --------------------------------------------------------------------------- +// Batch commit resolve +// --------------------------------------------------------------------------- + +/** Resolve a list of commit strings to their summaries via POST /commits/resolve. */ +export async function resolveCommits( + ts: string, + commits: string[], + signal?: AbortSignal, +): Promise<CommitResolveResponse> { + return fetchJson<CommitResolveResponse>( + apiUrl(ts, 'commits/resolve'), + { method: 'POST', body: { commits }, signal }, + ); +} diff --git a/lnt/server/ui/v5/frontend/src/chart.ts b/lnt/server/ui/v5/frontend/src/chart.ts new file mode 100644 index 000000000..a48ec3a66 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/chart.ts @@ -0,0 +1,525 @@ +import type { ComparisonRow } from './types'; +import { CHART_ZOOM, CHART_HOVER } from './events'; +import { getState } from './state'; +import { el, STATUS_COLORS, matchesFilter } from './utils'; + +/** Candidate "nice" percentage values for the positive side (B > A). */ +const NICE_PCTS_POS = [ + 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, + 100, 200, 500, 1000, 2000, 5000, 10000, 50000, +]; +/** Candidate "nice" percentage values for the negative side (B < A, all < 100). */ +const NICE_PCTS_NEG = [ + 0.1, 0.2, 0.5, 1, 2, 5, 10, 20, 50, 75, 90, 95, 99, +]; + +function formatNicePct(p: number): string { + if (p >= 1000 && p === Math.floor(p)) { + return p.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ',') + '%'; + } + return p + '%'; +} + +/** Precomputed tick candidates — log₂ positions and labels for all nice percentages. */ +const TICK_CANDIDATES: ReadonlyArray<{ pos: number; label: string }> = [ + { pos: 0, label: '0%' }, + ...NICE_PCTS_POS.map(p => ({ pos: Math.log2(1 + p / 100), label: '+' + formatNicePct(p) })), + ...NICE_PCTS_NEG.map(p => ({ pos: Math.log2(1 - p / 100), label: '\u2212' + formatNicePct(p) })), +]; + +/** + * Generate "nice" tick values and labels for the log₂(ratio) y-axis. + * Ticks are placed at log₂ positions corresponding to nice percentage values, + * auto-adapting to the data range. For small ranges you get ±1%, ±2%, ±5%; + * for large ranges you get ±50%, ±100%, ±500%, etc. + */ +export function generateChartTicks( + yMin: number, yMax: number, +): { tickvals: number[]; ticktext: string[] } { + // Filter precomputed candidates to data range with slight padding + const pad = Math.max((yMax - yMin) * 0.05, 0.001); + const inRange = TICK_CANDIDATES + .filter(c => c.pos >= yMin - pad && c.pos <= yMax + pad && Number.isFinite(c.pos)) + .sort((a, b) => a.pos - b.pos); + + if (inRange.length === 0) { + return { tickvals: [0], ticktext: ['0%'] }; + } + + // Thin to ~10 ticks with even visual spacing if too many + const MAX_TICKS = 10; + let ticks = inRange; + if (inRange.length > MAX_TICKS) { + // Keep 0% always; select remaining at evenly-spaced log₂ target positions + const zero = inRange.find(c => c.pos === 0); + const others = inRange.filter(c => c.pos !== 0); + const targetCount = Math.min(others.length, zero ? MAX_TICKS - 1 : MAX_TICKS); + + const posMin = others[0].pos; + const posMax = others[others.length - 1].pos; + const step = targetCount > 1 ? (posMax - posMin) / (targetCount - 1) : 0; + + const selected: Array<{ pos: number; label: string }> = []; + const used = new Set<number>(); + for (let i = 0; i < targetCount; i++) { + const target = posMin + i * step; + let bestIdx = -1; + let bestDist = Infinity; + for (let j = 0; j < others.length; j++) { + if (used.has(j)) continue; + const dist = Math.abs(others[j].pos - target); + if (dist < bestDist) { bestDist = dist; bestIdx = j; } + } + if (bestIdx >= 0) { + used.add(bestIdx); + selected.push(others[bestIdx]); + } + } + + if (zero) selected.push(zero); + selected.sort((a, b) => a.pos - b.pos); + ticks = selected; + } + + // Enforce minimum visual gap so tick labels don't overlap. + // Walk left-to-right, keeping a tick only if it's far enough from the last + // kept tick. Always prefer 0% when it competes with a neighbor. + const range = yMax - yMin || 0.01; + const minGap = range * 0.06; + const spaced: Array<{ pos: number; label: string }> = [ticks[0]]; + for (let i = 1; i < ticks.length; i++) { + const prev = spaced[spaced.length - 1]; + if (ticks[i].pos - prev.pos >= minGap) { + spaced.push(ticks[i]); + } else if (ticks[i].pos === 0) { + // 0% wins over its neighbor + spaced[spaced.length - 1] = ticks[i]; + } + } + + return { tickvals: spaced.map(c => c.pos), ticktext: spaced.map(c => c.label) }; +} + +export interface ChartData { + sortedTests: string[]; + x: number[]; + y: number[]; + colors: string[]; + customdata: string[][]; +} + +function isPlottable(r: ComparisonRow): boolean { + return r.sidePresent === 'both' && r.ratio !== null && r.ratio > 0 && r.status !== 'na'; +} + +function rowCustomdata(r: ComparisonRow): string[] { + return [ + r.test, + r.valueA !== null ? r.valueA.toPrecision(4) : 'N/A', + r.valueB !== null ? r.valueB.toPrecision(4) : 'N/A', + r.deltaPct !== null ? `${r.deltaPct > 0 ? '+' : ''}${r.deltaPct.toFixed(2)}%` : 'N/A', + r.ratio !== null ? r.ratio.toFixed(4) : 'N/A', + ]; +} + +/** + * Prepare chart data from comparison rows. + * Filters, sorts, and maps rows into Plotly-ready arrays. + * Returns null if no plottable data remains after filtering. + */ +export function prepareChartData( + rows: ComparisonRow[], + filterTests: Set<string> | null, +): ChartData | null { + let plottable = rows.filter(isPlottable); + + if (filterTests) { + plottable = plottable.filter(r => filterTests.has(r.test)); + } + + if (plottable.length === 0) { + return null; + } + + // Sort by ratio ascending + plottable.sort((a, b) => (a.ratio ?? 0) - (b.ratio ?? 0)); + const sortedTests = plottable.map(r => r.test); + + const x = plottable.map((_, i) => i); + const y = plottable.map(r => Math.log2(r.ratio!)); // log₂ scale: symmetric for equal multiplicative changes + + const colors = plottable.map(r => + STATUS_COLORS[r.status] ?? '#1f77b4', + ); + + const customdata = plottable.map(rowCustomdata); + + return { sortedTests, x, y, colors, customdata }; +} + +export interface ShadowChartData { + x: number[]; + y: number[]; + customdata: string[][]; +} + +/** + * Prepare shadow trace data as an independently-sorted curve. + * The shadow is sorted by its own ratio (not aligned to main chart X positions). + */ +export function prepareShadowChartData( + shadowRows: ComparisonRow[], + filterTests: Set<string> | null, +): ShadowChartData | null { + let plottable = shadowRows.filter(isPlottable); + + if (filterTests) { + plottable = plottable.filter(r => filterTests.has(r.test)); + } + + if (plottable.length === 0) { + return null; + } + + plottable.sort((a, b) => (a.ratio ?? 0) - (b.ratio ?? 0)); + + const x = plottable.map((_, i) => i); + const y = plottable.map(r => Math.log2(r.ratio!)); + const customdata = plottable.map(rowCustomdata); + + return { x, y, customdata }; +} + +declare const Plotly: { + newPlot(el: HTMLElement, data: unknown[], layout: unknown, config?: unknown): Promise<HTMLElement>; + react(el: HTMLElement, data: unknown[], layout: unknown, config?: unknown): Promise<HTMLElement>; + relayout(el: HTMLElement, update: Record<string, unknown>): Promise<void>; + purge(el: HTMLElement): void; + Fx: { + hover(el: HTMLElement, data: Array<{ curveNumber: number; pointNumber: number }>): void; + unhover(el: HTMLElement): void; + }; +}; + +export interface RenderChartOptions { + preserveZoom?: boolean; + preFilteredTests?: Set<string> | null; + shadowRows?: ComparisonRow[]; + shadowLabel?: string; +} + +let chartContainer: HTMLElement | null = null; +let chartData: ComparisonRow[] = []; +let sortedTests: string[] = []; // test names in chart order +let wiredContainer: HTMLElement | null = null; // track which container has listeners +/** Last zoom filter passed to drawChart, preserved for refreshChart(). */ +let lastFilterTests: Set<string> | null = null; +/** Last shadow options, preserved for refreshChart(). */ +let lastShadowRows: ComparisonRow[] | undefined; +let lastShadowLabel: string | undefined; +/** Guard flag to prevent infinite loop when we call Plotly.relayout() to update ticks. */ +let updatingTicks = false; +/** Full data y-range, used to restore ticks on double-click autorange reset. */ +let dataYMin = 0; +let dataYMax = 0; + +export function renderChart( + container: HTMLElement, + rows: ComparisonRow[], + options?: RenderChartOptions, +): void { + // If switching to a different container, reset event wiring + if (chartContainer !== container) { + wiredContainer = null; + } + chartContainer = container; + chartData = rows; + lastShadowRows = options?.shadowRows; + lastShadowLabel = options?.shadowLabel; + const preserveZoom = options?.preserveZoom ?? false; + drawChart(preserveZoom ? lastFilterTests : null, options?.preFilteredTests); +} + +// Plotly event handlers — receive data directly via gd.on() API +function onPlotlyRelayout(data: Record<string, unknown>): void { + // X-axis zoom → dispatch CHART_ZOOM to sync table filtering + if (data && data['xaxis.range[0]'] !== undefined) { + const lo = Math.max(0, Math.floor(data['xaxis.range[0]'] as number)); + const hi = Math.min(sortedTests.length - 1, Math.ceil(data['xaxis.range[1]'] as number)); + const visibleTests = new Set(sortedTests.slice(lo, hi + 1)); + document.dispatchEvent(new CustomEvent(CHART_ZOOM, { detail: visibleTests })); + } else if (data && (data['xaxis.autorange'] || data['autosize'])) { + document.dispatchEvent(new CustomEvent(CHART_ZOOM, { detail: null })); + } + + // Y-axis zoom → recompute tick labels for the new visible range + if (updatingTicks || !chartContainer) return; + + let newYMin: number | undefined; + let newYMax: number | undefined; + if (data && data['yaxis.range[0]'] !== undefined) { + newYMin = data['yaxis.range[0]'] as number; + newYMax = data['yaxis.range[1]'] as number; + } else if (data && data['yaxis.autorange']) { + newYMin = dataYMin; + newYMax = dataYMax; + } + + if (newYMin !== undefined && newYMax !== undefined) { + const { tickvals, ticktext } = generateChartTicks(newYMin, newYMax); + updatingTicks = true; + Plotly.relayout(chartContainer, { + 'yaxis.tickvals': tickvals, + 'yaxis.ticktext': ticktext, + }).finally(() => { updatingTicks = false; }); + } +} + +function onPlotlyHover(data: { points?: Array<{ pointIndex: number; curveNumber: number; customdata?: string[] }> }): void { + const points = data?.points; + if (points && points.length > 0) { + const p = points[0]; + const testName = p.customdata?.[0] + ?? (p.curveNumber === 0 ? sortedTests[p.pointIndex] : null); + if (testName) { + document.dispatchEvent(new CustomEvent(CHART_HOVER, { detail: testName })); + } + } +} + +function onPlotlyUnhover(): void { + document.dispatchEvent(new CustomEvent(CHART_HOVER, { detail: null })); +} + +function drawChart(filterTests: Set<string> | null, preFilteredTests?: Set<string> | null): void { + if (!chartContainer) return; + lastFilterTests = filterTests; + + const state = getState(); + + // Apply text filter from state on top of chart zoom filter + let effectiveFilter = filterTests; + if (preFilteredTests) { + if (effectiveFilter) { + effectiveFilter = new Set([...effectiveFilter].filter(t => preFilteredTests.has(t))); + } else { + effectiveFilter = preFilteredTests; + } + } else if (state.testFilter) { + const textMatches = new Set<string>(); + for (const r of chartData) { + if (matchesFilter(r.test, state.testFilter)) textMatches.add(r.test); + } + if (effectiveFilter) { + effectiveFilter = new Set([...effectiveFilter].filter(t => textMatches.has(t))); + } else { + effectiveFilter = textMatches; + } + } + + const prepared = prepareChartData(chartData, effectiveFilter); + + if (!prepared) { + Plotly.purge(chartContainer); + chartContainer.replaceChildren(el('p', { class: 'no-chart-data' }, 'No data to chart.')); + sortedTests = []; + wiredContainer = null; + return; + } + + sortedTests = prepared.sortedTests; + const { x, y, colors, customdata } = prepared; + + const trace = { + x, y, + customdata, + type: 'bar', + marker: { + color: colors, + line: { color: '#fff', width: 0.5 }, + }, + hovertemplate: + '<b>%{customdata[0]}</b><br>' + + 'Ratio: %{customdata[4]}<br>' + + 'Value A: %{customdata[1]}<br>' + + 'Value B: %{customdata[2]}<br>' + + 'Delta: %{customdata[3]}' + + '<extra></extra>', + }; + + const traces: unknown[] = [trace]; + + // Shadow trace + let shadowYMin = 0; + let shadowYMax = 0; + if (lastShadowRows && lastShadowRows.length > 0) { + const shadowData = prepareShadowChartData(lastShadowRows, effectiveFilter); + if (shadowData) { + for (const val of shadowData.y) { + if (val < shadowYMin) shadowYMin = val; + if (val > shadowYMax) shadowYMax = val; + } + traces.push({ + x: shadowData.x, + y: shadowData.y, + customdata: shadowData.customdata, + type: 'scatter', + mode: 'lines', + name: `Shadow: ${lastShadowLabel ?? ''}`, + showlegend: false, + line: { + color: 'rgba(74, 144, 217, 0.6)', + width: 1.5, + }, + hovertemplate: + '<b>%{customdata[0]}</b> (shadow)<br>' + + 'Ratio: %{customdata[4]}<br>' + + 'Value A: %{customdata[1]}<br>' + + 'Value B: %{customdata[2]}<br>' + + 'Delta: %{customdata[3]}' + + '<extra></extra>', + }); + } + } + + // Noise band shapes in log₂ space (only when Delta % knob is enabled). + const shapes: Array<Record<string, unknown>> = []; + if (state.noiseConfig.pct.enabled && state.noiseConfig.pct.value > 0) { + const noiseFrac = state.noiseConfig.pct.value / 100; + const noiseUpper = Math.log2(1 + noiseFrac); + const noiseLower = noiseFrac < 1 ? Math.log2(1 - noiseFrac) : -noiseUpper; + shapes.push( + { + type: 'line' as const, + x0: -0.5, x1: sortedTests.length - 0.5, + y0: noiseLower, y1: noiseLower, + xref: 'x' as const, yref: 'y' as const, + line: { color: '#aaa', width: 1, dash: 'dash' as const }, + }, + { + type: 'line' as const, + x0: -0.5, x1: sortedTests.length - 0.5, + y0: noiseUpper, y1: noiseUpper, + xref: 'x' as const, yref: 'y' as const, + line: { color: '#aaa', width: 1, dash: 'dash' as const }, + }, + ); + } + + // Compute data y-range for tick generation and autorange restore. + let yMin = 0, yMax = 0; + for (const val of y) { + if (val < yMin) yMin = val; + if (val > yMax) yMax = val; + } + dataYMin = Math.min(yMin, shadowYMin); + dataYMax = Math.max(yMax, shadowYMax); + + // Determine effective y-range for tick generation: use preserved zoom if active, + // otherwise use full data range. Ticks are computed once for whichever range applies. + let tickYMin = dataYMin; + let tickYMax = dataYMax; + + const layout: Record<string, unknown> = { + xaxis: { + title: { text: 'Tests (sorted by ratio)' }, + showticklabels: false, + }, + yaxis: { + title: { text: 'Change from baseline (log scale)', standoff: 15 }, + zeroline: true, + zerolinewidth: 2, + zerolinecolor: '#333', + }, + shapes, + bargap: 0, + margin: { t: 30, b: 50, l: 90, r: 20 }, + height: 400, + hovermode: 'closest', + dragmode: 'zoom', + legend: { itemclick: false, itemdoubleclick: false }, + showlegend: false, + }; + + // Preserve user zoom: read current axis ranges from the chart div + // (set by Plotly on user drag-zoom) and apply them to the new layout. + // If the user hasn't zoomed (autorange is true), don't set explicit + // ranges so Plotly auto-fits to new data. + if (wiredContainer === chartContainer) { + const gd = chartContainer as unknown as { layout?: Record<string, Record<string, unknown>> }; + if (gd.layout) { + const xa = gd.layout['xaxis']; + const ya = gd.layout['yaxis']; + if (xa && xa['autorange'] === false && xa['range']) { + (layout['xaxis'] as Record<string, unknown>)['range'] = xa['range']; + (layout['xaxis'] as Record<string, unknown>)['autorange'] = false; + } + if (ya && ya['autorange'] === false && ya['range']) { + (layout['yaxis'] as Record<string, unknown>)['range'] = ya['range']; + (layout['yaxis'] as Record<string, unknown>)['autorange'] = false; + tickYMin = (ya['range'] as number[])[0]; + tickYMax = (ya['range'] as number[])[1]; + } + } + } + + const { tickvals, ticktext } = generateChartTicks(tickYMin, tickYMax); + (layout['yaxis'] as Record<string, unknown>)['tickvals'] = tickvals; + (layout['yaxis'] as Record<string, unknown>)['ticktext'] = ticktext; + + const config = { + responsive: true, + displayModeBar: true, + modeBarButtonsToRemove: ['toImage', 'sendDataToCloud'], + scrollZoom: true, + }; + + // If container was purged (no-data state), clear stale HTML before Plotly.react + if (wiredContainer !== chartContainer) { + chartContainer.replaceChildren(); + } + + Plotly.react(chartContainer, traces, layout, config); + + // Wire events via Plotly's .on() API (added to the div by Plotly.react). + // Purge removes .on() handlers, so re-register whenever wiredContainer is stale. + if (wiredContainer !== chartContainer) { + const gd = chartContainer as unknown as { + on(event: string, handler: (...args: never[]) => void): void; + }; + gd.on('plotly_relayout', onPlotlyRelayout); + gd.on('plotly_hover', onPlotlyHover); + gd.on('plotly_unhover', onPlotlyUnhover); + wiredContainer = chartContainer; + } +} + +// External: highlight a point by test name +export function highlightPoint(testName: string | null): void { + if (!chartContainer) return; + if (!testName) { + try { Plotly.Fx.unhover(chartContainer); } catch { /* chart may be purged */ } + return; + } + const idx = sortedTests.indexOf(testName); + if (idx >= 0) { + Plotly.Fx.hover(chartContainer, [{ curveNumber: 0, pointNumber: idx }]); + } +} + +/** Purge the Plotly chart and reset module-level state. Call from page unmount. */ +export function destroyChart(): void { + if (chartContainer) { + Plotly.purge(chartContainer); + } + chartContainer = null; + chartData = []; + sortedTests = []; + wiredContainer = null; + lastFilterTests = null; + lastShadowRows = undefined; + lastShadowLabel = undefined; + updatingTicks = false; + dataYMin = 0; + dataYMax = 0; +} diff --git a/lnt/server/ui/v5/frontend/src/comparison.ts b/lnt/server/ui/v5/frontend/src/comparison.ts new file mode 100644 index 000000000..b0b2ae6c0 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/comparison.ts @@ -0,0 +1,420 @@ +import type { AggFn, ComparisonRow, NoiseConfig, NoiseReason, RowStatus, SampleInfo } from './types'; +import { getAggFn, geomean, mean } from './utils'; + +// --------------------------------------------------------------------------- +// Sample grouping and aggregation +// --------------------------------------------------------------------------- + +/** + * Group raw metric values by test name, pooling across multiple runs. + * Returns the raw (unaggregated) values for each test. + */ +export function groupSamplesByTest( + samplesByRun: SampleInfo[][], + metric: string, +): Map<string, number[]> { + const byTest = new Map<string, number[]>(); + for (const samples of samplesByRun) { + for (const s of samples) { + const val = s.metrics[metric]; + if (val === null || val === undefined) continue; + let arr = byTest.get(s.test); + if (!arr) { + arr = []; + byTest.set(s.test, arr); + } + arr.push(val); + } + } + return byTest; +} + +/** + * Aggregate a pre-grouped map using the specified function. + */ +export function aggregateGrouped( + grouped: Map<string, number[]>, + aggFn: AggFn, +): Map<string, number> { + const agg = getAggFn(aggFn); + const result = new Map<string, number>(); + for (const [test, values] of grouped) { + if (values.length > 0) { + result.set(test, agg(values)); + } + } + return result; +} + +/** + * Aggregate multiple samples within a single run for one metric. + * Groups by test name, applies aggFn to metric values (skips nulls). + */ +export function aggregateSamplesWithinRun( + samples: SampleInfo[], + metric: string, + aggFn: AggFn, +): Map<string, number> { + return aggregateGrouped(groupSamplesByTest([samples], metric), aggFn); +} + +/** + * Aggregate across multiple runs. For each test, collect the per-run values + * and apply aggFn. + */ +export function aggregateAcrossRuns( + perRunMaps: Map<string, number>[], + aggFn: AggFn, +): Map<string, number> { + if (perRunMaps.length === 0) return new Map(); + if (perRunMaps.length === 1) return perRunMaps[0]; + + const allTests = new Set<string>(); + for (const m of perRunMaps) { + for (const t of m.keys()) allTests.add(t); + } + + const agg = getAggFn(aggFn); + const result = new Map<string, number>(); + for (const test of allTests) { + const values: number[] = []; + for (const m of perRunMaps) { + const v = m.get(test); + if (v !== undefined) values.push(v); + } + if (values.length > 0) { + result.set(test, agg(values)); + } + } + return result; +} + +/** + * Count how many per-run maps contain each test. Used to determine + * how many runs contributed data for a given test. + */ +export function countRunsPerTest( + perRunMaps: Map<string, number>[], +): Map<string, number> { + const counts = new Map<string, number>(); + for (const m of perRunMaps) { + for (const test of m.keys()) { + counts.set(test, (counts.get(test) ?? 0) + 1); + } + } + return counts; +} + +// --------------------------------------------------------------------------- +// Welch's t-test +// --------------------------------------------------------------------------- + +/** Compute sample variance (unbiased, divides by n-1). */ +function variance(arr: number[], m: number): number { + let sum = 0; + for (const v of arr) { + const d = v - m; + sum += d * d; + } + return sum / (arr.length - 1); +} + +/** + * Regularized incomplete beta function I_x(a, b) via Lentz's continued + * fraction algorithm. Used for the Student's t-distribution CDF. + */ +function regularizedIncompleteBeta(x: number, a: number, b: number): number { + if (x <= 0) return 0; + if (x >= 1) return 1; + + // Use the symmetry relation when x > (a+1)/(a+b+2) for better convergence + if (x > (a + 1) / (a + b + 2)) { + return 1 - regularizedIncompleteBeta(1 - x, b, a); + } + + // Log of the beta function prefix: x^a * (1-x)^b / (a * B(a,b)) + const lnPrefix = a * Math.log(x) + b * Math.log(1 - x) + - Math.log(a) + - (lnGamma(a) + lnGamma(b) - lnGamma(a + b)); + + const prefix = Math.exp(lnPrefix); + + // Lentz's continued fraction + const TINY = 1e-30; + const EPS = 1e-14; + const MAX_ITER = 200; + + let f = 1 + cfCoeff(1, a, b, x); + if (Math.abs(f) < TINY) f = TINY; + let C = f; + let D = 1; + + for (let m = 1; m <= MAX_ITER; m++) { + const d = cfCoeff(m + 1, a, b, x); + D = 1 + d * D; + if (Math.abs(D) < TINY) D = TINY; + D = 1 / D; + C = 1 + d / C; + if (Math.abs(C) < TINY) C = TINY; + const delta = C * D; + f *= delta; + if (Math.abs(delta - 1) < EPS) break; + } + + return prefix * f; +} + +/** Continued fraction coefficients for I_x(a, b). */ +function cfCoeff(n: number, a: number, b: number, x: number): number { + const m = Math.floor(n / 2); + if (n % 2 === 0) { + // Even terms: d_{2m} + return (m * (b - m) * x) / ((a + 2 * m - 1) * (a + 2 * m)); + } else { + // Odd terms: d_{2m+1} + return -((a + m) * (a + b + m) * x) / ((a + 2 * m) * (a + 2 * m + 1)); + } +} + +/** Log-gamma function using Lanczos approximation. */ +function lnGamma(z: number): number { + const g = 7; + const c = [ + 0.99999999999980993, + 676.5203681218851, + -1259.1392167224028, + 771.32342877765313, + -176.61502916214059, + 12.507343278686905, + -0.13857109526572012, + 9.9843695780195716e-6, + 1.5056327351493116e-7, + ]; + + if (z < 0.5) { + // Reflection formula + return Math.log(Math.PI / Math.sin(Math.PI * z)) - lnGamma(1 - z); + } + + z -= 1; + let x = c[0]; + for (let i = 1; i < g + 2; i++) { + x += c[i] / (z + i); + } + const t = z + g + 0.5; + return 0.5 * Math.log(2 * Math.PI) + (z + 0.5) * Math.log(t) - t + Math.log(x); +} + +/** + * Welch's t-test (two-tailed). Returns p-value or null if the test cannot + * be computed (< 2 samples per side, or both sides zero variance with equal + * means). + */ +export function welchTTest(a: number[], b: number[]): number | null { + if (a.length < 2 || b.length < 2) return null; + + const nA = a.length; + const nB = b.length; + const mA = mean(a); + const mB = mean(b); + const vA = variance(a, mA); + const vB = variance(b, mB); + + // Both zero variance + if (vA === 0 && vB === 0) { + return mA === mB ? null : 0; + } + + const se = Math.sqrt(vA / nA + vB / nB); + const t = (mA - mB) / se; + + // Welch-Satterthwaite degrees of freedom + const sA = vA / nA; + const sB = vB / nB; + const num = (sA + sB) ** 2; + const den = (sA ** 2) / (nA - 1) + (sB ** 2) / (nB - 1); + const df = num / den; + + // Two-tailed p-value from Student's t-distribution + const x = df / (df + t * t); + return regularizedIncompleteBeta(x, df / 2, 0.5); +} + +// --------------------------------------------------------------------------- +// Comparison +// --------------------------------------------------------------------------- + +/** + * Full outer join on test name. Compute delta, deltaPct, ratio, status. + * Applies multi-knob noise classification. + */ +export function computeComparison( + mapA: Map<string, number>, + mapB: Map<string, number>, + biggerIsBetter: boolean, + noiseConfig: NoiseConfig, + rawA?: Map<string, number[]>, + rawB?: Map<string, number[]>, + runCountA?: Map<string, number>, + runCountB?: Map<string, number>, +): ComparisonRow[] { + const allTests = new Set<string>(); + for (const t of mapA.keys()) allTests.add(t); + for (const t of mapB.keys()) allTests.add(t); + + const rows: ComparisonRow[] = []; + + for (const test of allTests) { + const vA = mapA.get(test) ?? null; + const vB = mapB.get(test) ?? null; + + const counts = { + samplesA: rawA?.get(test)?.length, + samplesB: rawB?.get(test)?.length, + runsA: runCountA?.get(test), + runsB: runCountB?.get(test), + }; + + let sidePresent: 'both' | 'a_only' | 'b_only'; + if (vA !== null && vB !== null) sidePresent = 'both'; + else if (vA !== null) sidePresent = 'a_only'; + else sidePresent = 'b_only'; + + // Missing side + if (sidePresent !== 'both') { + rows.push({ + test, valueA: vA, valueB: vB, + delta: null, deltaPct: null, ratio: null, + status: 'missing', sidePresent, noiseReasons: [], + ...counts, + }); + continue; + } + + // Both sides present + const delta = vB! - vA!; + + // Zero baseline + if (vA === 0) { + rows.push({ + test, valueA: vA, valueB: vB, + delta, deltaPct: null, ratio: null, + status: 'na', sidePresent, noiseReasons: [], + ...counts, + }); + continue; + } + + // We use Math.abs(vA) in the denominator so that deltaPct always has the + // same sign as delta (positive when B > A, negative when B < A), even if + // the baseline is negative. Without abs, a negative baseline would flip + // the percentage sign, making the displayed value misleading. + const deltaPct = (delta / Math.abs(vA!)) * 100; + const ratio = vB! / vA!; + + // Multi-knob noise classification + const noiseReasons: NoiseReason[] = []; + + // Delta % knob + if (noiseConfig.pct.enabled) { + if (Math.abs(deltaPct) < noiseConfig.pct.value) { + noiseReasons.push({ + knob: 'pct', + message: `Delta ${Math.abs(deltaPct).toFixed(1)}% below ${noiseConfig.pct.value}% threshold`, + }); + } + } + + // P-value knob + if (noiseConfig.pval.enabled && rawA && rawB) { + const samplesA = rawA.get(test); + const samplesB = rawB.get(test); + if (samplesA && samplesB) { + const pval = welchTTest(samplesA, samplesB); + if (pval !== null && pval > noiseConfig.pval.value) { + noiseReasons.push({ + knob: 'pval', + message: `p-value ${pval.toFixed(2)} above ${noiseConfig.pval.value}`, + }); + } + } + } + + // Absolute floor knob + if (noiseConfig.floor.enabled) { + const maxAbs = Math.max(Math.abs(vA!), Math.abs(vB!)); + if (maxAbs < noiseConfig.floor.value) { + noiseReasons.push({ + knob: 'floor', + message: `max(|A|, |B|) = ${maxAbs.toPrecision(3)} below floor of ${noiseConfig.floor.value}`, + }); + } + } + + // Classify + let status: RowStatus; + if (noiseReasons.length > 0) { + status = 'noise'; + } else if (delta === 0) { + status = 'unchanged'; + } else if (biggerIsBetter) { + status = delta > 0 ? 'improved' : 'regressed'; + } else { + status = delta < 0 ? 'improved' : 'regressed'; + } + + rows.push({ + test, valueA: vA, valueB: vB, + delta, deltaPct, ratio, + status, sidePresent, noiseReasons, + ...counts, + }); + } + + return rows; +} + +export interface GeomeanResult { + /** Geometric mean of side A values. */ + geomeanA: number; + /** Geometric mean of side B values. */ + geomeanB: number; + /** Delta: geomeanB - geomeanA. */ + delta: number; + /** Delta as percentage of geomeanA. Null if geomeanA is 0. */ + deltaPct: number | null; + /** Geometric mean of per-test ratios (B/A). */ + ratioGeomean: number; +} + +/** + * Compute geomean summary for all rows present on both sides. + * Returns null if no valid rows exist. + */ +export function computeGeomean(rows: ComparisonRow[]): GeomeanResult | null { + const valid = rows.filter( + r => r.sidePresent === 'both' + && r.ratio !== null + && r.status !== 'na' + && r.valueA !== null + && r.valueB !== null + && r.valueA !== 0 + && r.valueB !== 0, + ); + + if (valid.length === 0) return null; + + const geomeanA = geomean(valid.map(r => Math.abs(r.valueA!))); + const geomeanB = geomean(valid.map(r => Math.abs(r.valueB!))); + const ratioGeomean = geomean(valid.map(r => Math.abs(r.ratio!))); + + // geomean() returns null only when all values are <= 0, but we already + // filtered out zeros above, so these will always be non-null. + if (geomeanA === null || geomeanB === null || ratioGeomean === null) return null; + + const delta = geomeanB - geomeanA; + // Use Math.abs so deltaPct sign matches delta sign (see computeComparison). + const deltaPct = geomeanA !== 0 ? (delta / Math.abs(geomeanA)) * 100 : null; + + return { geomeanA, geomeanB, delta, deltaPct, ratioGeomean }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/checkbox-range.ts b/lnt/server/ui/v5/frontend/src/components/checkbox-range.ts new file mode 100644 index 000000000..f1f9a7828 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/checkbox-range.ts @@ -0,0 +1,53 @@ +// components/checkbox-range.ts — Shift+click range selection for checkbox lists. + +/** + * Enable shift+click range selection on checkboxes within a container. + * Uses event delegation so it works after DOM rebuilds (sort, filter). + * Tracks the last-clicked checkbox by its identity in the current DOM order. + * + * @param container - The parent element containing the checkboxes. + * @param selector - CSS selector matching the checkboxes (e.g. 'input[type="checkbox"][data-uuid]'). + * @param onChange - Called after any range selection so the caller can update UI state. + * @returns { destroy } to remove the event listener. + */ +export function setupCheckboxRange( + container: HTMLElement, + selector: string, + onChange: () => void, +): { destroy: () => void } { + let lastCheckedEl: HTMLInputElement | null = null; + + function onClick(e: MouseEvent): void { + const target = e.target as HTMLElement; + if (!target.matches(selector)) return; + + const cb = target as HTMLInputElement; + + if (e.shiftKey && lastCheckedEl) { + const allBoxes = [...container.querySelectorAll<HTMLInputElement>(selector)]; + const currentIndex = allBoxes.indexOf(cb); + const lastIndex = allBoxes.indexOf(lastCheckedEl); + + // If lastCheckedEl is no longer in the DOM (e.g. after sort), skip range + if (lastIndex >= 0 && currentIndex >= 0) { + const start = Math.min(lastIndex, currentIndex); + const end = Math.max(lastIndex, currentIndex); + for (let i = start; i <= end; i++) { + allBoxes[i].checked = cb.checked; + } + onChange(); + } + } + + lastCheckedEl = cb; + } + + container.addEventListener('click', onClick); + + return { + destroy() { + container.removeEventListener('click', onClick); + lastCheckedEl = null; + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/combobox.ts b/lnt/server/ui/v5/frontend/src/components/combobox.ts new file mode 100644 index 000000000..6a202a716 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/combobox.ts @@ -0,0 +1,214 @@ +// components/combobox.ts — Generic combobox base. +// +// Handles DOM structure, ARIA attributes, keyboard navigation, +// blur/outside-click dismiss, and validation halo. Specialized +// comboboxes (machine, commit, regression) are thin wrappers. + +import { el, updateFilterValidation } from '../utils'; + +export interface ComboboxItem { + value: string; + display: string; +} + +export interface ComboboxStatus { + text: string; + isError?: boolean; +} + +export interface ComboboxOptions { + id: string; + placeholder?: string; + initialValue?: string; + getItems: (filter: string) => ComboboxItem[]; + onSelect: (item: ComboboxItem) => void; + onEnter?: (text: string) => boolean; + onClear?: () => void; + getStatus?: () => ComboboxStatus | null; + maxItems?: number; +} + +export interface ComboboxHandle { + element: HTMLElement; + input: HTMLInputElement; + setValue: (display: string) => void; + clear: () => void; + destroy: () => void; +} + +let comboboxCounter = 0; + +export function createCombobox(opts: ComboboxOptions): ComboboxHandle { + const dropdownId = `combobox-list-${opts.id}-${++comboboxCounter}`; + const maxItems = opts.maxItems ?? 100; + + const wrapper = el('div', { + class: 'combobox', + role: 'combobox', + 'aria-expanded': 'false', + 'aria-haspopup': 'listbox', + }); + const input = el('input', { + type: 'text', + class: 'combobox-input', + placeholder: opts.placeholder ?? '', + autocomplete: 'off', + role: 'searchbox', + 'aria-autocomplete': 'list', + 'aria-controls': dropdownId, + }) as HTMLInputElement; + if (opts.initialValue) input.value = opts.initialValue; + + const dropdown = el('ul', { class: 'combobox-dropdown', role: 'listbox', id: dropdownId }); + wrapper.append(input, dropdown); + + dropdown.addEventListener('mousedown', (e) => e.preventDefault()); + + function setExpanded(expanded: boolean): void { + wrapper.setAttribute('aria-expanded', String(expanded)); + } + + function closeDropdown(): void { + dropdown.classList.remove('open'); + setExpanded(false); + } + + function showDropdown(filter: string): void { + dropdown.replaceChildren(); + + if (opts.getStatus) { + const status = opts.getStatus(); + if (status !== null) { + const cls = status.isError ? 'combobox-item combobox-status-error' : 'combobox-item combobox-status'; + dropdown.replaceChildren(el('li', { class: cls }, status.text)); + dropdown.classList.add('open'); + setExpanded(true); + input.classList.remove('combobox-invalid'); + return; + } + } + + const allItems = opts.getItems(filter); + const limited = allItems.slice(0, maxItems); + + for (const item of limited) { + const li = el('li', { class: 'combobox-item', role: 'option', tabindex: '-1' }, item.display); + li.addEventListener('click', () => { + input.value = item.display; + input.classList.remove('combobox-invalid'); + closeDropdown(); + opts.onSelect(item); + }); + dropdown.append(li); + } + + const isOpen = limited.length > 0; + dropdown.classList.toggle('open', isOpen); + setExpanded(isOpen); + + if (input.value.trim() && allItems.length === 0) { + input.classList.add('combobox-invalid'); + } else { + input.classList.remove('combobox-invalid'); + } + } + + // -- Keyboard navigation -- + + input.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const first = dropdown.querySelector<HTMLElement>('li[tabindex]'); + if (first) first.focus(); + } else if (e.key === 'Escape') { + closeDropdown(); + } else if (e.key === 'Enter') { + e.preventDefault(); + if (opts.onEnter) { + const text = input.value.trim(); + if (!text) return; + const accepted = opts.onEnter(text); + if (accepted) { + closeDropdown(); + } else { + input.classList.add('combobox-invalid'); + } + } + } + }); + + dropdown.addEventListener('keydown', (e: KeyboardEvent) => { + const target = e.target as HTMLElement; + if (target.tagName !== 'LI') return; + + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = target.nextElementSibling as HTMLElement | null; + if (next) next.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = target.previousElementSibling as HTMLElement | null; + if (prev) prev.focus(); + else input.focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + target.click(); + } else if (e.key === 'Escape') { + closeDropdown(); + suppressNextFocus = true; + input.focus(); + } + }); + + // -- Open/filter -- + + let suppressNextFocus = false; + + input.addEventListener('input', () => { + updateFilterValidation(input); + showDropdown(input.value); + }); + input.addEventListener('focus', () => { + if (suppressNextFocus) { suppressNextFocus = false; return; } + if (!dropdown.classList.contains('open')) { + showDropdown(input.value); + } + }); + + // -- Dismiss -- + + input.addEventListener('blur', (e: FocusEvent) => { + if (wrapper.contains(e.relatedTarget as Node)) return; + closeDropdown(); + }); + + input.addEventListener('change', () => { + if (!input.value.trim() && opts.onClear) { + input.classList.remove('combobox-invalid'); + opts.onClear(); + } + }); + + function onDocClick(e: MouseEvent): void { + if (!wrapper.contains(e.target as Node)) { + closeDropdown(); + } + } + document.addEventListener('click', onDocClick); + + return { + element: wrapper, + input, + setValue(display: string) { + input.value = display; + }, + clear() { + input.value = ''; + input.classList.remove('combobox-invalid'); + closeDropdown(); + }, + destroy() { + document.removeEventListener('click', onDocClick); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/commit-combobox.ts b/lnt/server/ui/v5/frontend/src/components/commit-combobox.ts new file mode 100644 index 000000000..52683fda7 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/commit-combobox.ts @@ -0,0 +1,87 @@ +// components/commit-combobox.ts — Commit typeahead selector. +// +// Thin wrapper around the generic combobox base. Takes lazy commit data +// (values + optional displayMap) and requires exact-match validation on +// Enter/change. + +import { matchesFilter } from '../utils'; +import { createCombobox, type ComboboxItem } from './combobox'; + +export interface CommitPickerOptions { + id: string; + getCommitData: () => { values: string[]; displayMap?: Map<string, string> }; + initialValue?: string; + placeholder?: string; + onSelect: (value: string) => void; +} + +export interface CommitPickerHandle { + element: HTMLElement; + input: HTMLInputElement; + setValue: (raw: string) => void; + destroy: () => void; +} + +export function createCommitPicker(opts: CommitPickerOptions): CommitPickerHandle { + function resolveDisplay(raw: string): string { + const { displayMap } = opts.getCommitData(); + return displayMap?.get(raw) ?? raw; + } + + function extractRaw(text: string): string { + return text.replace(/\s*\(.*\)$/, '').trim(); + } + + function isValidCommit(raw: string): boolean { + const { values } = opts.getCommitData(); + return values.includes(raw); + } + + const handle = createCombobox({ + id: `commit-${opts.id}`, + placeholder: opts.placeholder || 'Type to search commits...', + initialValue: opts.initialValue ? resolveDisplay(opts.initialValue) : undefined, + getItems(filter: string): ComboboxItem[] { + const { values, displayMap } = opts.getCommitData(); + const matches = filter + ? values.filter(v => { + if (matchesFilter(v, filter)) return true; + const display = displayMap?.get(v); + return display ? matchesFilter(display, filter) : false; + }) + : values; + return matches.map(v => ({ + value: v, + display: displayMap?.get(v) ?? v, + })); + }, + onSelect(item: ComboboxItem) { + opts.onSelect(item.value); + }, + onEnter(text: string): boolean { + const raw = extractRaw(text); + if (!raw) return false; + if (!isValidCommit(raw)) return false; + opts.onSelect(raw); + return true; + }, + }); + + handle.input.addEventListener('change', () => { + if (handle.input.classList.contains('combobox-invalid')) return; + const raw = extractRaw(handle.input.value); + if (!raw) { opts.onSelect(raw); return; } + if (!isValidCommit(raw)) { + handle.input.classList.add('combobox-invalid'); + return; + } + opts.onSelect(raw); + }); + + return { + element: handle.element, + input: handle.input, + setValue: (raw: string) => { handle.setValue(resolveDisplay(raw)); }, + destroy: () => { handle.destroy(); }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/commit-search.ts b/lnt/server/ui/v5/frontend/src/components/commit-search.ts new file mode 100644 index 000000000..63942ff89 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/commit-search.ts @@ -0,0 +1,234 @@ +// components/commit-search.ts — Commit search with autocomplete. + +import { el, debounce, commitDisplayValue, matchesFilter, updateFilterValidation } from '../utils'; +import { searchCommits } from '../api'; +import type { CommitSummary } from '../types'; +import { navigate } from '../router'; + +export interface CommitSearchOptions { + testsuite: string; + placeholder?: string; + /** If provided, called instead of navigating to Commit Detail. */ + onSelect?: (commitValue: string) => void; + /** Pre-loaded suggestions shown on focus. Commits with ordinals should come first. */ + suggestions?: CommitSummary[]; + /** Schema commit_fields for display field resolution. */ + commitFields?: Array<{ name: string; display?: boolean }>; +} + +let commitSearchCounter = 0; + +/** + * Render a commit search input with autocomplete dropdown. + * + * - If suggestions are provided, shows them on focus (filtered by input text) + * - Otherwise, typing triggers a debounced search via the API + * - Enter selects the current input value directly + * - Clicking a dropdown item selects that commit + */ +export function renderCommitSearch( + container: HTMLElement, + options: CommitSearchOptions, +): { destroy: () => void; setSuggestions: (s: CommitSummary[]) => void; clear: () => void } { + const dropdownId = `commit-search-list-${++commitSearchCounter}`; + const wrapper = el('div', { + class: 'commit-search', + role: 'combobox', + 'aria-expanded': 'false', + 'aria-haspopup': 'listbox', + }); + const input = el('input', { + type: 'text', + class: 'commit-search-input combobox-input', + placeholder: options.placeholder || 'Search commits...', + role: 'searchbox', + 'aria-autocomplete': 'list', + 'aria-controls': dropdownId, + }) as HTMLInputElement; + const dropdown = el('ul', { class: 'commit-search-dropdown combobox-dropdown', role: 'listbox', id: dropdownId }); + wrapper.append(input, dropdown); + container.append(wrapper); + + // Prevent dropdown clicks from blurring the input + dropdown.addEventListener('mousedown', (e) => e.preventDefault()); + + let abortCtrl: AbortController | null = null; + let suggestions: CommitSummary[] = options.suggestions || []; + // When suggestions are explicitly provided (even as []), use suggestions mode: + // only show from the suggestions list, never fall back to API search. + const useSuggestionsMode = options.suggestions !== undefined; + + function selectCommit(value: string): void { + input.value = options.onSelect ? value : ''; + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + if (options.onSelect) { + options.onSelect(value); + } else { + navigate(`/commits/${encodeURIComponent(value)}`); + } + } + + function showSuggestions(): void { + const text = input.value.trim(); + const filtered = text + ? suggestions.filter(s => + matchesFilter(s.commit, text) || + Object.values(s.fields).some(v => matchesFilter(v, text))) + : suggestions; + + dropdown.replaceChildren(); + if (filtered.length === 0) { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + return; + } + for (const s of filtered) { + const li = el('li', { class: 'combobox-item', tabindex: '-1' }); + li.append(el('span', {}, s.commit)); + const display = commitDisplayValue(s, options.commitFields); + if (display !== s.commit) { + li.append(el('span', { class: 'commit-search-field' }, ` \u2014 ${display}`)); + } + li.addEventListener('click', () => selectCommit(s.commit)); + dropdown.append(li); + } + dropdown.classList.add('open'); + wrapper.setAttribute('aria-expanded', 'true'); + } + + // API-based search (fallback when no suggestions) + const doApiSearch = debounce(async () => { + const text = input.value.trim(); + if (!text) { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + return; + } + if (abortCtrl) abortCtrl.abort(); + abortCtrl = new AbortController(); + try { + const result = await searchCommits( + options.testsuite, text, { limit: 10 }, abortCtrl.signal, + ); + dropdown.replaceChildren(); + if (result.items.length === 0) { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + return; + } + for (const item of result.items) { + const li = el('li', { class: 'combobox-item', tabindex: '-1' }); + li.append(el('span', {}, item.commit)); + const display = commitDisplayValue(item, options.commitFields); + if (display !== item.commit) { + li.append(el('span', { class: 'commit-search-field' }, ` \u2014 ${display}`)); + } + li.addEventListener('click', () => selectCommit(item.commit)); + dropdown.append(li); + } + dropdown.classList.add('open'); + wrapper.setAttribute('aria-expanded', 'true'); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') return; + } + }, 300); + + function isValidCommit(value: string): boolean { + if (!useSuggestionsMode) return true; + return suggestions.some(s => s.commit === value); + } + + function updateValidationState(): void { + const text = input.value.trim(); + if (!text || !useSuggestionsMode) { + input.classList.remove('commit-search-invalid'); + } else { + // Only show invalid when there are no partial matches (dropdown is empty) + const hasMatches = suggestions.some(s => + matchesFilter(s.commit, text) || + Object.values(s.fields).some(v => matchesFilter(v, text))); + if (hasMatches) { + input.classList.remove('commit-search-invalid'); + } else { + input.classList.add('commit-search-invalid'); + } + } + } + + input.addEventListener('input', () => { + updateFilterValidation(input); + if (useSuggestionsMode) { + showSuggestions(); + updateValidationState(); + } else { + (doApiSearch as EventListener)(new Event('input')); + } + }); + + input.addEventListener('focus', () => { + if (useSuggestionsMode) { + showSuggestions(); + } + }); + + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const first = dropdown.querySelector<HTMLElement>('.combobox-item'); + if (first) first.focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + const text = input.value.trim(); + if (text && isValidCommit(text)) selectCommit(text); + } else if (e.key === 'Escape') { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + } + }); + + dropdown.addEventListener('keydown', (e) => { + const target = e.target as HTMLElement; + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = target.nextElementSibling as HTMLElement | null; + if (next) next.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = target.previousElementSibling as HTMLElement | null; + if (prev) prev.focus(); + else input.focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + target.click(); + } else if (e.key === 'Escape') { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + input.focus(); + } + }); + + // Close dropdown when clicking outside + function onDocClick(e: MouseEvent): void { + if (!wrapper.contains(e.target as Node)) { + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + } + } + document.addEventListener('click', onDocClick); + + return { + destroy() { + document.removeEventListener('click', onDocClick); + if (abortCtrl) abortCtrl.abort(); + }, + setSuggestions(s: CommitSummary[]) { + suggestions = s; + }, + clear() { + input.value = ''; + dropdown.classList.remove('open'); + wrapper.setAttribute('aria-expanded', 'false'); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/comparison-summary.ts b/lnt/server/ui/v5/frontend/src/components/comparison-summary.ts new file mode 100644 index 000000000..73bf7557e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/comparison-summary.ts @@ -0,0 +1,102 @@ +import type { ComparisonRow } from '../types'; +import { el, STATUS_COLORS, matchesFilter } from '../utils'; + +export interface SummaryCounts { + improved: number; + regressed: number; + noise: number; + unchanged: number; + onlyInA: number; + onlyInB: number; + na: number; + total: number; +} + +type SummaryCategory = Exclude<keyof SummaryCounts, 'total'>; + +export function computeSummaryCounts( + rows: ComparisonRow[], + textFilter: string, + zoomFilter: Set<string> | null, + preFilteredTests?: Set<string> | null, +): SummaryCounts { + const counts: SummaryCounts = { + improved: 0, regressed: 0, noise: 0, unchanged: 0, + onlyInA: 0, onlyInB: 0, na: 0, total: 0, + }; + + for (const r of rows) { + if (preFilteredTests) { + if (!preFilteredTests.has(r.test)) continue; + } else if (textFilter && !matchesFilter(r.test, textFilter)) { + continue; + } + if (zoomFilter && !zoomFilter.has(r.test)) continue; + + switch (r.status) { + case 'improved': counts.improved++; break; + case 'regressed': counts.regressed++; break; + case 'noise': counts.noise++; break; + case 'unchanged': counts.unchanged++; break; + case 'missing': + if (r.sidePresent === 'a_only') counts.onlyInA++; + else counts.onlyInB++; + break; + case 'na': counts.na++; break; + } + counts.total++; + } + + return counts; +} + +const CATEGORIES: Array<{ key: SummaryCategory; label: string; color: string; comparable: boolean }> = [ + { key: 'improved', label: 'Improved', color: STATUS_COLORS.improved, comparable: true }, + { key: 'regressed', label: 'Regressed', color: STATUS_COLORS.regressed, comparable: true }, + { key: 'noise', label: 'Noise', color: STATUS_COLORS.noise, comparable: true }, + { key: 'unchanged', label: 'Unchanged', color: STATUS_COLORS.unchanged, comparable: true }, + { key: 'onlyInA', label: 'Only in A', color: '#888888', comparable: false }, + { key: 'onlyInB', label: 'Only in B', color: '#888888', comparable: false }, + { key: 'na', label: 'N/A', color: '#888888', comparable: false }, +]; + +function formatPct(n: number): string { + const s = n.toFixed(1); + return s.endsWith('.0') ? s.slice(0, -2) : s; +} + +const PCT_TOOLTIP = 'Percentage of comparable tests (excludes Only in A, Only in B, N/A)'; + +export function renderSummaryBar(container: HTMLElement, counts: SummaryCounts): void { + container.replaceChildren(); + + if (counts.total === 0) return; + + const bar = el('div', { class: 'comparison-summary' }); + const comparable = counts.improved + counts.regressed + counts.noise + counts.unchanged; + + for (const cat of CATEGORIES) { + const count = counts[cat.key]; + const showPct = cat.comparable && comparable > 0; + const countText = showPct + ? `${count} (${formatPct((count / comparable) * 100)}%)` + : `${count}`; + + const dot = el('span', { class: 'summary-dot' }); + dot.style.backgroundColor = cat.color; + + const countAttrs: Record<string, string | boolean> = { class: 'summary-count' }; + if (showPct) countAttrs.title = PCT_TOOLTIP; + + const item = el('span', + { class: count === 0 ? 'summary-item summary-item-zero' : 'summary-item' }, + dot, + el('span', { class: 'summary-label' }, cat.label), + el('span', countAttrs, countText), + ); + + bar.append(item); + } + + container.append(bar); +} diff --git a/lnt/server/ui/v5/frontend/src/components/data-table.ts b/lnt/server/ui/v5/frontend/src/components/data-table.ts new file mode 100644 index 000000000..8c99353ce --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/data-table.ts @@ -0,0 +1,151 @@ +// components/data-table.ts — Reusable sortable data table. + +import { el } from '../utils'; + +export interface Column<T> { + key: string; + label: string; + /** Custom cell content. Return a string or DOM node. */ + render?: (row: T) => string | Node; + /** Custom header content. When provided, replaces the text label. */ + headerRender?: () => HTMLElement; + /** Extract a sortable value. Defaults to render text content. */ + sortValue?: (row: T) => string | number | null; + /** CSS class for the cell (e.g. 'col-num'). */ + cellClass?: string; + /** Whether this column is sortable (default true). */ + sortable?: boolean; +} + +export interface DataTableOptions<T> { + columns: Column<T>[]; + rows: T[]; + sortKey?: string; + sortDir?: 'asc' | 'desc'; + onRowClick?: (row: T) => void; + rowClass?: (row: T) => string; + emptyMessage?: string; +} + +/** + * Render a sortable data table into the given container. + * Clicking a column header sorts by that column. + */ +export function renderDataTable<T>( + container: HTMLElement, + options: DataTableOptions<T>, +): void { + let currentSortKey = options.sortKey || ''; + let currentSortDir: 'asc' | 'desc' = options.sortDir || 'asc'; + let sortedRows = sortRows(options.rows, options.columns, currentSortKey, currentSortDir); + + const table = el('table', { class: 'comparison-table data-table' }); + const thead = el('thead', {}); + const headerRow = el('tr', {}); + thead.append(headerRow); + table.append(thead); + + const tbody = el('tbody', {}); + table.append(tbody); + + function rebuildHeader(): void { + headerRow.replaceChildren(); + for (const col of options.columns) { + const sortable = col.sortable !== false; + const th = el('th', { + class: sortable ? 'sortable' : '', + }); + if (col.headerRender) { + th.append(col.headerRender()); + } else { + th.textContent = col.label; + } + if (col.cellClass) th.classList.add(col.cellClass); + if (sortable && col.key === currentSortKey) { + th.append(currentSortDir === 'asc' ? ' \u25B2' : ' \u25BC'); + } + if (sortable) { + th.addEventListener('click', () => { + if (currentSortKey === col.key) { + currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc'; + } else { + currentSortKey = col.key; + currentSortDir = 'asc'; + } + sortedRows = sortRows(options.rows, options.columns, currentSortKey, currentSortDir); + rebuildHeader(); + rebuildBody(); + }); + } + headerRow.append(th); + } + } + + function rebuildBody(): void { + tbody.replaceChildren(); + if (sortedRows.length === 0) { + const emptyRow = el('tr', {}); + const emptyCell = el('td', { + colspan: String(options.columns.length), + class: 'no-results', + }, options.emptyMessage || 'No data.'); + emptyRow.append(emptyCell); + tbody.append(emptyRow); + return; + } + for (const row of sortedRows) { + const tr = el('tr', {}); + if (options.rowClass) { + const cls = options.rowClass(row); + if (cls) tr.className = cls; + } + if (options.onRowClick) { + tr.style.cursor = 'pointer'; + const handler = options.onRowClick; + tr.addEventListener('click', () => handler(row)); + } + for (const col of options.columns) { + const td = el('td', {}); + if (col.cellClass) td.className = col.cellClass; + if (col.render) { + const content = col.render(row); + if (typeof content === 'string') { + td.textContent = content; + } else { + td.append(content); + } + } else { + td.textContent = String((row as Record<string, unknown>)[col.key] ?? ''); + } + tr.append(td); + } + tbody.append(tr); + } + } + + rebuildHeader(); + rebuildBody(); + container.append(table); +} + +function sortRows<T>( + rows: T[], + columns: Column<T>[], + sortKey: string, + sortDir: 'asc' | 'desc', +): T[] { + if (!sortKey) return [...rows]; + const col = columns.find(c => c.key === sortKey); + if (!col) return [...rows]; + + const sorted = [...rows].sort((a, b) => { + const va = col.sortValue ? col.sortValue(a) : (a as Record<string, unknown>)[sortKey]; + const vb = col.sortValue ? col.sortValue(b) : (b as Record<string, unknown>)[sortKey]; + if (va === null || va === undefined) return 1; + if (vb === null || vb === undefined) return -1; + if (typeof va === 'number' && typeof vb === 'number') return va - vb; + return String(va).localeCompare(String(vb)); + }); + if (sortDir === 'desc') sorted.reverse(); + return sorted; +} diff --git a/lnt/server/ui/v5/frontend/src/components/delete-confirm.ts b/lnt/server/ui/v5/frontend/src/components/delete-confirm.ts new file mode 100644 index 000000000..7efbe3c2f --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/delete-confirm.ts @@ -0,0 +1,95 @@ +// components/delete-confirm.ts — Reusable delete-with-confirmation UI pattern. + +import { el } from '../utils'; +import { authErrorMessage } from '../api'; + +export interface DeleteConfirmOptions { + /** Label for the initial delete button (e.g. "Delete Machine"). */ + label: string; + /** Prompt text shown in the confirmation area. */ + prompt: string; + /** The value the user must type to enable the confirm button. */ + confirmValue: string; + /** Placeholder text for the confirmation input. */ + placeholder?: string; + /** Optional message shown while the deletion is in progress. */ + deletingMessage?: string; + /** Async function that performs the actual deletion. */ + onDelete: () => Promise<void>; + /** Called after successful deletion (e.g. to navigate away). */ + onSuccess: () => void; + /** When provided, confirmation UI goes here instead of the button container. */ + confirmContainer?: HTMLElement; +} + +/** + * Render a delete button with a type-to-confirm safeguard. + * + * Clicking the button reveals a confirmation area where the user must type + * a specific value before the confirm button becomes enabled. + */ +export function renderDeleteConfirm( + container: HTMLElement, + options: DeleteConfirmOptions, +): void { + const deleteBtn = el('button', { class: 'admin-btn admin-btn-danger' }, options.label); + + const confirmDiv = el('div', { class: 'delete-machine-confirm' }); + confirmDiv.style.display = 'none'; + + const errorDiv = el('div', {}); + + deleteBtn.addEventListener('click', () => { + deleteBtn.style.display = 'none'; + confirmDiv.style.display = ''; + }); + + const prompt = el('p', {}, options.prompt); + const confirmInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: options.placeholder ?? '', + }) as HTMLInputElement; + const confirmBtn = el('button', { class: 'admin-btn admin-btn-danger', disabled: '' }, 'Confirm Delete') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'admin-btn' }, 'Cancel'); + + confirmInput.addEventListener('input', () => { + confirmBtn.disabled = confirmInput.value !== options.confirmValue; + }); + + cancelBtn.addEventListener('click', () => { + confirmDiv.style.display = 'none'; + confirmInput.value = ''; + confirmBtn.disabled = true; + deleteBtn.style.display = ''; + errorDiv.replaceChildren(); + }); + + confirmBtn.addEventListener('click', () => { + confirmBtn.disabled = true; + confirmBtn.textContent = 'Deleting...'; + if (options.deletingMessage) { + errorDiv.replaceChildren( + el('p', { class: 'progress-label' }, options.deletingMessage), + ); + } else { + errorDiv.replaceChildren(); + } + + options.onDelete() + .then(() => { + options.onSuccess(); + }) + .catch((err: unknown) => { + confirmBtn.disabled = false; + confirmBtn.textContent = 'Confirm Delete'; + errorDiv.replaceChildren(el('p', { class: 'error-banner' }, authErrorMessage(err))); + }); + }); + + const btnRow = el('div', { class: 'admin-form-row' }, confirmBtn, cancelBtn); + confirmDiv.append(prompt, confirmInput, btnRow); + const confirmTarget = options.confirmContainer ?? container; + container.append(deleteBtn); + confirmTarget.append(confirmDiv, errorDiv); +} diff --git a/lnt/server/ui/v5/frontend/src/components/machine-combobox.ts b/lnt/server/ui/v5/frontend/src/components/machine-combobox.ts new file mode 100644 index 000000000..c9e4710bc --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/machine-combobox.ts @@ -0,0 +1,95 @@ +// components/machine-combobox.ts — Machine typeahead selector. +// +// Thin wrapper around the generic combobox base. Fetches the full machine +// list once on creation and filters locally on each keystroke. + +import { matchesFilter } from '../utils'; +import { getMachines } from '../api'; +import type { MachineInfo } from '../types'; +import { createCombobox } from './combobox'; + +export interface MachineComboboxOptions { + testsuite: string; + initialValue?: string; + onSelect: (name: string) => void; + onClear?: () => void; +} + +export function renderMachineCombobox( + container: HTMLElement, + opts: MachineComboboxOptions, +): { destroy: () => void; getValue: () => string; clear: () => void } { + let abortCtrl: AbortController | null = null; + let selectedValue = opts.initialValue || ''; + let machines: MachineInfo[] | null = null; + + const handle = createCombobox({ + id: `machine-${opts.testsuite}`, + placeholder: 'Type to search machines...', + initialValue: opts.initialValue, + getItems(filter: string) { + if (machines === null) return []; + const matches = filter.trim() + ? machines.filter(m => matchesFilter(m.name, filter)) + : machines; + return matches.map(m => ({ value: m.name, display: m.name })); + }, + onSelect(item) { + selectedValue = item.value; + opts.onSelect(item.value); + }, + onEnter(text: string): boolean { + if (machines === null) return false; + const matches = text.trim() + ? machines.filter(m => matchesFilter(m.name, text)) + : machines; + if (matches.length > 0) { + selectedValue = text; + opts.onSelect(text); + return true; + } + return false; + }, + onClear() { + selectedValue = ''; + if (opts.onClear) opts.onClear(); + }, + getStatus() { + if (machines === null) return { text: 'Loading machines...' }; + return null; + }, + }); + + container.append(handle.element); + + if (opts.testsuite) { + abortCtrl = new AbortController(); + getMachines(opts.testsuite, { limit: 500 }, abortCtrl.signal) + .then((result) => { + machines = result.items; + if (document.activeElement === handle.input) { + handle.input.dispatchEvent(new Event('input')); + } + }) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === 'AbortError') return; + }); + } else { + handle.input.disabled = true; + handle.input.placeholder = 'Select a suite first'; + } + + return { + destroy() { + handle.destroy(); + if (abortCtrl) abortCtrl.abort(); + }, + getValue() { + return selectedValue; + }, + clear() { + selectedValue = ''; + handle.clear(); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/metric-selector.ts b/lnt/server/ui/v5/frontend/src/components/metric-selector.ts new file mode 100644 index 000000000..3cd807828 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/metric-selector.ts @@ -0,0 +1,68 @@ +// components/metric-selector.ts — Reusable metric drop-down. + +import { el } from '../utils'; +import type { FieldInfo } from '../types'; + +/** + * Valid v5 metric types. Must match the backend's VALID_METRIC_TYPES + * in lnt/server/db/v5/schema.py. + */ +export const METRIC_TYPES = { REAL: 'real', STATUS: 'status', HASH: 'hash' } as const; + +/** Filter fields to only plottable numeric metrics (type === 'real'). */ +export function filterMetricFields(fields: FieldInfo[]): FieldInfo[] { + return fields.filter(f => f.type === METRIC_TYPES.REAL); +} + +export interface MetricSelectorOptions { + /** When true, prepend a "-- Select metric --" placeholder with empty value. */ + placeholder?: boolean; +} + +/** + * Create a metric selector dropdown from the given fields. + * Callers should pre-filter with filterMetricFields() if needed. + * If initialValue matches a field name, that option is pre-selected. + * Returns the effective initial metric name ('' when placeholder is active). + */ +/** + * Render a disabled metric dropdown with a placeholder option. + * Used when no suite is selected yet and metrics aren't available. + */ +export function renderEmptyMetricSelector(container: HTMLElement): void { + const group = el('div', { class: 'control-group' }); + group.append(el('label', {}, 'Metric')); + const select = el('select', { class: 'metric-select', disabled: '' }) as HTMLSelectElement; + select.append(el('option', { value: '' }, '-- Select metric --')); + group.append(select); + container.append(group); +} + +export function renderMetricSelector( + container: HTMLElement, + fields: FieldInfo[], + onChange: (metric: string) => void, + initialValue?: string, + options?: MetricSelectorOptions, +): string { + if (fields.length === 0) return ''; + + const group = el('div', { class: 'control-group' }); + group.append(el('label', {}, 'Metric')); + const select = el('select', { class: 'metric-select' }) as HTMLSelectElement; + + if (options?.placeholder) { + select.append(el('option', { value: '' }, '-- Select metric --')); + } + + for (const f of fields) { + const opt = el('option', { value: f.name }, f.display_name || f.name); + if (initialValue && f.name === initialValue) (opt as HTMLOptionElement).selected = true; + select.append(opt); + } + select.addEventListener('change', () => onChange(select.value)); + group.append(select); + container.append(group); + + return select.value; +} diff --git a/lnt/server/ui/v5/frontend/src/components/nav.ts b/lnt/server/ui/v5/frontend/src/components/nav.ts new file mode 100644 index 000000000..d9eaebc70 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/nav.ts @@ -0,0 +1,166 @@ +// components/nav.ts — Navigation bar for the v5 SPA. + +import { el, isModifiedClick } from '../utils'; +import { navigate } from '../router'; + +export interface NavConfig { + testsuite: string; + urlBase: string; // lnt_url_base +} + +let activeLink: HTMLElement | null = null; + +interface NavLink { + label: string; + path: string; + suiteParam?: string; +} + +/** + * Build a suite-agnostic nav link element. In suite-agnostic context, clicks + * use SPA navigation; in suite-scoped context, the browser follows the href. + */ +function buildNavLink(link: NavLink, agnosticBase: string, config: NavConfig): HTMLAnchorElement { + let href = `${agnosticBase}${link.path}`; + if (config.testsuite && link.suiteParam) { + href += `?${link.suiteParam}=${encodeURIComponent(config.testsuite)}`; + } + + const a = el('a', { + class: 'v5-nav-link', + href, + 'data-path': link.path, + }, link.label) as HTMLAnchorElement; + + if (!config.testsuite) { + a.addEventListener('click', (e) => { + if (isModifiedClick(e)) return; + e.preventDefault(); + navigate(link.path); + }); + } + + return a; +} + +/** + * Render the navigation bar. + * Returns the nav element to prepend to the app root. + * + * All navbar links are suite-agnostic. In suite-agnostic context they use + * SPA navigation; in suite-scoped context they use full-page navigation. + */ +export function renderNav(config: NavConfig): HTMLElement { + const nav = el('nav', { class: 'v5-nav' }); + const agnosticBase = `${config.urlBase}/v5`; + + // Brand — always links to the suite-agnostic dashboard at /v5/ + const brandHref = `${agnosticBase}/`; + const brand = el('a', { class: 'v5-nav-brand', href: brandHref }, 'LNT'); + if (!config.testsuite) { + brand.addEventListener('click', (e) => { + if (isModifiedClick(e)) return; + e.preventDefault(); + navigate('/'); + }); + } + // In suite-scoped context: no click handler — browser follows the href + nav.append(brand); + + // Left-side links + const linksContainer = el('div', { class: 'v5-nav-links' }); + + const leftLinks: NavLink[] = [ + { label: 'Test Suites', path: '/test-suites', suiteParam: 'suite' }, + { label: 'Graph', path: '/graph', suiteParam: 'suite' }, + { label: 'Compare', path: '/compare', suiteParam: 'suite_a' }, + { label: 'Profiles', path: '/profiles', suiteParam: 'suite_a' }, + ]; + + for (const link of leftLinks) { + linksContainer.append(buildNavLink(link, agnosticBase, config)); + } + + // API link — always opens Swagger UI in a new tab + const apiLink = el('a', { + class: 'v5-nav-link', + href: `${config.urlBase}/api/v5/openapi/swagger-ui`, + target: '_blank', + rel: 'noopener', + }, 'API'); + linksContainer.append(apiLink); + + nav.append(linksContainer); + + // Right side: Admin, Settings + const rightGroup = el('div', { class: 'v5-nav-right' }); + + rightGroup.append(buildNavLink({ label: 'Admin', path: '/admin' }, agnosticBase, config)); + + const settingsLink = el('a', { + class: 'v5-nav-link', + href: '#', + }, 'Settings'); + settingsLink.addEventListener('click', (e) => { + e.preventDefault(); + toggleSettings(); + }); + rightGroup.append(settingsLink); + nav.append(rightGroup); + + return nav; +} + +/** + * Update the active link in the nav bar based on the current route. + * Call this after each route resolution. + */ +export function updateActiveNavLink(currentPath: string): void { + if (activeLink) { + activeLink.classList.remove('v5-nav-link-active'); + } + activeLink = null; + + const links = document.querySelectorAll<HTMLElement>('.v5-nav-link[data-path]'); + for (const link of links) { + const path = link.getAttribute('data-path'); + if (!path) continue; + + if (currentPath.startsWith(path)) { + link.classList.add('v5-nav-link-active'); + activeLink = link; + break; + } + } +} + +/** Settings panel toggle (token input). Reuses the existing pattern. */ +function toggleSettings(): void { + let panel = document.getElementById('v5-settings-panel'); + if (panel) { + panel.style.display = panel.style.display === 'none' ? 'block' : 'none'; + return; + } + + // Create settings panel + panel = el('div', { id: 'v5-settings-panel', class: 'settings-panel' }); + panel.append(el('label', {}, 'Auth Token')); + const tokenInput = el('input', { + type: 'password', + class: 'token-input', + placeholder: 'Paste v5 API token...', + }) as HTMLInputElement; + tokenInput.value = localStorage.getItem('lnt_v5_token') || ''; + tokenInput.addEventListener('change', () => { + const val = tokenInput.value.trim(); + if (val) localStorage.setItem('lnt_v5_token', val); + else localStorage.removeItem('lnt_v5_token'); + }); + panel.append(tokenInput); + + // Insert after the nav + const navEl = document.querySelector('.v5-nav'); + if (navEl && navEl.parentElement) { + navEl.parentElement.insertBefore(panel, navEl.nextSibling); + } +} diff --git a/lnt/server/ui/v5/frontend/src/components/pagination.ts b/lnt/server/ui/v5/frontend/src/components/pagination.ts new file mode 100644 index 000000000..e5a81ad29 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/pagination.ts @@ -0,0 +1,40 @@ +// components/pagination.ts — Previous/Next pagination controls. + +import { el } from '../utils'; + +export interface PaginationOptions { + hasPrevious: boolean; + hasNext: boolean; + onPrevious: () => void; + onNext: () => void; + rangeText?: string; +} + +/** + * Render pagination controls (Previous / Next buttons + optional range text). + */ +export function renderPagination( + container: HTMLElement, + options: PaginationOptions, +): void { + const row = el('div', { class: 'pagination-controls' }); + + const prevBtn = el('button', { + class: 'pagination-btn', + }, '\u2190 Previous') as HTMLButtonElement; + if (!options.hasPrevious) prevBtn.disabled = true; + prevBtn.addEventListener('click', options.onPrevious); + + const nextBtn = el('button', { + class: 'pagination-btn', + }, 'Next \u2192') as HTMLButtonElement; + if (!options.hasNext) nextBtn.disabled = true; + nextBtn.addEventListener('click', options.onNext); + + row.append(prevBtn); + if (options.rangeText) { + row.append(el('span', { class: 'pagination-range' }, options.rangeText)); + } + row.append(nextBtn); + container.append(row); +} diff --git a/lnt/server/ui/v5/frontend/src/components/profile-colors.ts b/lnt/server/ui/v5/frontend/src/components/profile-colors.ts new file mode 100644 index 000000000..dab50cba8 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/profile-colors.ts @@ -0,0 +1,15 @@ +// components/profile-colors.ts — Shared heat-map gradient for profile components. + +/** + * Map a ratio (0..1) to a white → yellow → red gradient. + * Used by both the disassembly heat-map and function selector badges. + */ +export function heatGradient(ratio: number): string { + const r = Math.min(Math.max(ratio, 0), 1); + if (r <= 0.5) { + const t = r * 2; + return `rgb(255,255,${Math.round(255 * (1 - t))})`; + } + const t = (r - 0.5) * 2; + return `rgb(255,${Math.round(255 * (1 - t))},0)`; +} diff --git a/lnt/server/ui/v5/frontend/src/components/profile-stats.ts b/lnt/server/ui/v5/frontend/src/components/profile-stats.ts new file mode 100644 index 000000000..0235ea8d6 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/profile-stats.ts @@ -0,0 +1,101 @@ +// components/profile-stats.ts — Top-level counter comparison bar for profiles. + +import { el } from '../utils'; + +/** + * Render a top-level counter comparison table. + * + * Single-profile mode (only countersA): simple name | value table. + * Comparison mode (both sides): name | value A | value B | delta % with bar. + */ +export function renderProfileStats( + container: HTMLElement, + countersA: Record<string, number>, + countersB?: Record<string, number>, +): { destroy: () => void } { + container.replaceChildren(); + + const allNames = new Set([ + ...Object.keys(countersA), + ...(countersB ? Object.keys(countersB) : []), + ]); + + if (allNames.size === 0) { + container.append(el('p', { class: 'no-results' }, 'No counters available.')); + return { destroy() {} }; + } + + const table = el('table', { class: 'profile-stats' }); + const thead = el('thead'); + const tbody = el('tbody'); + + if (countersB) { + // Comparison mode + const headerRow = el('tr'); + headerRow.append( + el('th', {}, 'Counter'), + el('th', {}, 'A'), + el('th', {}, 'B'), + el('th', {}, 'Delta'), + ); + thead.append(headerRow); + + for (const name of sorted(allNames)) { + const a = countersA[name] ?? null; + const b = countersB[name] ?? null; + const row = el('tr'); + + row.append(el('td', {}, name)); + row.append(el('td', { class: 'profile-stats-value' }, a !== null ? formatCounter(a) : '--')); + row.append(el('td', { class: 'profile-stats-value' }, b !== null ? formatCounter(b) : '--')); + + if (a !== null && b !== null && a !== 0) { + const deltaPct = ((b - a) / a) * 100; + const isImproved = deltaPct < 0; // lower is better + const cls = isImproved ? 'profile-stats-improved' : deltaPct > 0 ? 'profile-stats-regressed' : ''; + + const deltaCell = el('td', { class: `profile-stats-delta ${cls}` }); + deltaCell.append(el('span', {}, `${deltaPct >= 0 ? '+' : ''}${deltaPct.toFixed(1)}%`)); + + // CSS bar proportional to |deltaPct|, capped at 100% + const barWidth = Math.min(Math.abs(deltaPct), 100); + const bar = el('div', { class: 'profile-stats-bar' }); + bar.style.width = `${barWidth}%`; + deltaCell.append(bar); + + row.append(deltaCell); + } else { + row.append(el('td', { class: 'profile-stats-delta' }, '--')); + } + + tbody.append(row); + } + } else { + // Single-profile mode + const headerRow = el('tr'); + headerRow.append(el('th', {}, 'Counter'), el('th', {}, 'Value')); + thead.append(headerRow); + + for (const name of sorted(allNames)) { + const val = countersA[name] ?? null; + const row = el('tr'); + row.append(el('td', {}, name)); + row.append(el('td', { class: 'profile-stats-value' }, val !== null ? formatCounter(val) : '--')); + tbody.append(row); + } + } + + table.append(thead, tbody); + container.append(table); + + return { destroy() {} }; +} + +function formatCounter(value: number): string { + if (Number.isInteger(value)) return value.toLocaleString(); + return value.toFixed(2); +} + +function sorted(names: Set<string>): string[] { + return [...names].sort(); +} diff --git a/lnt/server/ui/v5/frontend/src/components/profile-viewer.ts b/lnt/server/ui/v5/frontend/src/components/profile-viewer.ts new file mode 100644 index 000000000..76a01c127 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/profile-viewer.ts @@ -0,0 +1,142 @@ +// components/profile-viewer.ts — Straight-line disassembly view with heat-map. + +import { el } from '../utils'; +import { heatGradient } from './profile-colors'; +import type { ProfileFunctionDetail } from '../types'; + +export type DisplayMode = 'relative' | 'absolute' | 'cumulative'; + +export interface ProfileViewerOptions { + counter: string; + displayMode: DisplayMode; + showAll?: boolean; // preserve "show all" state across re-renders +} + +const DEFAULT_ROW_CAP = 500; + +/** + * Render a straight-line disassembly table for a single function. + * + * Columns: Counter value (heat-map background) | Address (hex) | Instruction text. + * Large functions are capped at DEFAULT_ROW_CAP with a "Show all" button. + */ +export function renderProfileViewer( + container: HTMLElement, + detail: ProfileFunctionDetail, + options: ProfileViewerOptions, +): { destroy: () => void; isShowAll: () => boolean } { + container.replaceChildren(); + const cleanups: Array<() => void> = []; + + const instructions = detail.instructions; + if (instructions.length === 0) { + container.append(el('p', { class: 'no-results' }, 'No instructions.')); + return { destroy() {}, isShowAll: () => false }; + } + + const counterValues = computeValues(instructions, options.counter, options.displayMode); + const maxValue = Math.max(...counterValues.map(Math.abs), 1e-10); + + let showAll = options.showAll ?? false; + const capped = instructions.length > DEFAULT_ROW_CAP; + + function render(): void { + container.replaceChildren(); + + const table = el('table', { class: 'profile-disasm' }); + const thead = el('thead'); + const headerRow = el('tr'); + headerRow.append( + el('th', { class: 'profile-disasm-heat' }, options.counter), + el('th', { class: 'profile-disasm-addr' }, 'Address'), + el('th', { class: 'profile-disasm-text' }, 'Instruction'), + ); + thead.append(headerRow); + table.append(thead); + + const tbody = el('tbody'); + const limit = (capped && !showAll) ? DEFAULT_ROW_CAP : instructions.length; + + for (let i = 0; i < limit; i++) { + const inst = instructions[i]; + const value = counterValues[i]; + const row = el('tr'); + + // Counter value cell with heat-map background + const heatCell = el('td', { class: 'profile-disasm-heat' }); + heatCell.style.backgroundColor = heatGradient(Math.min(Math.abs(value) / maxValue, 1)); + heatCell.textContent = formatValue(value, options.displayMode); + row.append(heatCell); + + // Address + row.append(el('td', { class: 'profile-disasm-addr' }, `0x${inst.address.toString(16)}`)); + + // Instruction text + row.append(el('td', { class: 'profile-disasm-text' }, inst.text)); + + tbody.append(row); + } + + table.append(tbody); + container.append(table); + + if (capped && !showAll) { + const msg = el('div', { class: 'profile-row-cap' }); + msg.append( + document.createTextNode(`Showing ${DEFAULT_ROW_CAP} of ${instructions.length} instructions. `), + ); + const showAllBtn = el('button', { class: 'admin-btn' }, 'Show all'); + const handler = () => { showAll = true; render(); }; + showAllBtn.addEventListener('click', handler); + cleanups.push(() => showAllBtn.removeEventListener('click', handler)); + msg.append(showAllBtn); + container.append(msg); + } + } + + render(); + + return { + destroy() { + for (const fn of cleanups) fn(); + cleanups.length = 0; + }, + isShowAll: () => showAll, + }; +} + +/** + * Compute display values for each instruction based on the selected counter + * and display mode. + */ +function computeValues( + instructions: ProfileFunctionDetail['instructions'], + counter: string, + mode: DisplayMode, +): number[] { + const raw = instructions.map(inst => inst.counters[counter] ?? 0); + + if (mode === 'absolute') return raw; + + if (mode === 'relative') { + const total = raw.reduce((a, b) => a + b, 0); + if (total === 0) return raw.map(() => 0); + return raw.map(v => (v / total) * 100); + } + + // cumulative + const result: number[] = []; + let sum = 0; + for (const v of raw) { + sum += v; + result.push(sum); + } + return result; +} + +function formatValue(value: number, mode: DisplayMode): string { + if (mode === 'relative') return `${value.toFixed(1)}%`; + if (mode === 'absolute') return value.toFixed(1); + // cumulative + return value.toFixed(1); +} diff --git a/lnt/server/ui/v5/frontend/src/components/regression-combobox.ts b/lnt/server/ui/v5/frontend/src/components/regression-combobox.ts new file mode 100644 index 000000000..80b25a4d8 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/regression-combobox.ts @@ -0,0 +1,98 @@ +// components/regression-combobox.ts — Regression typeahead selector. +// +// Thin wrapper around the generic combobox base. Fetches the full regression +// list once on creation and filters locally on each keystroke. + +import { truncate, matchesFilter } from '../utils'; +import { getRegressions } from '../api'; +import type { RegressionListItem } from '../types'; +import { createCombobox } from './combobox'; + +export interface RegressionComboboxOptions { + testsuite: string; + onSelect: (uuid: string, title: string | null) => void; + onClear?: () => void; +} + +export function renderRegressionCombobox( + container: HTMLElement, + opts: RegressionComboboxOptions, +): { destroy: () => void; getValue: () => string; clear: () => void } { + let abortCtrl: AbortController | null = null; + let selectedUuid = ''; + let regressions: RegressionListItem[] | null = null; + let fetchError = false; + + function displayText(r: RegressionListItem): string { + return truncate(r.title || `(untitled) ${r.uuid.slice(0, 8)}`, 60); + } + + const handle = createCombobox({ + id: `regression-${opts.testsuite}`, + placeholder: 'Type to search regressions...', + getItems(filter: string) { + if (regressions === null || regressions.length === 0) return []; + const matches = filter.trim() + ? regressions.filter(r => matchesFilter(r.title || '', filter)) + : regressions; + return matches.map(r => ({ value: r.uuid, display: displayText(r) })); + }, + onSelect(item) { + selectedUuid = item.value; + const r = regressions?.find(r => r.uuid === item.value); + opts.onSelect(item.value, r?.title ?? null); + }, + onClear() { + selectedUuid = ''; + if (opts.onClear) opts.onClear(); + }, + getStatus() { + if (fetchError) return { text: 'Failed to load regressions', isError: true }; + if (regressions === null) return { text: 'Loading regressions...' }; + if (regressions.length === 0) return { text: 'No regressions found' }; + return null; + }, + }); + + container.append(handle.element); + + handle.input.addEventListener('input', () => { + selectedUuid = ''; + }); + + if (opts.testsuite) { + abortCtrl = new AbortController(); + getRegressions(opts.testsuite, { limit: 500 }, abortCtrl.signal) + .then((result) => { + regressions = result.items; + if (document.activeElement === handle.input) { + handle.input.dispatchEvent(new Event('input')); + } + }) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === 'AbortError') return; + fetchError = true; + regressions = []; + if (document.activeElement === handle.input) { + handle.input.dispatchEvent(new Event('input')); + } + }); + } else { + handle.input.disabled = true; + handle.input.placeholder = 'Select a suite first'; + } + + return { + destroy() { + handle.destroy(); + if (abortCtrl) abortCtrl.abort(); + }, + getValue() { + return selectedUuid; + }, + clear() { + selectedUuid = ''; + handle.clear(); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/components/sparkline-card.ts b/lnt/server/ui/v5/frontend/src/components/sparkline-card.ts new file mode 100644 index 000000000..338396a3c --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/components/sparkline-card.ts @@ -0,0 +1,134 @@ +// components/sparkline-card.ts — Lightweight Plotly sparkline chart for Dashboard. + +import { el } from '../utils'; + +type PlotlyGd = HTMLElement & { + on: (evt: string, cb: (data: { points: Array<{ curveNumber: number }> }) => void) => void; +}; + +declare const Plotly: { + newPlot(el: HTMLElement, data: unknown[], layout: unknown, config?: unknown): Promise<PlotlyGd>; + purge(el: HTMLElement): void; +}; + +export { machineColor } from '../utils'; + +export interface SparklineTrace { + machine: string; + color: string; + points: Array<{ x: number; value: number; commit: string }>; +} + +export interface SparklineCardOptions { + title: string; + unit?: string; + traces: SparklineTrace[]; + /** Called on click. If a specific trace was clicked, `machine` is its name; + * otherwise (card background / title click) `machine` is undefined. */ + onClick?: (machine?: string) => void; +} + +function formatLabel(title: string, unit?: string): string { + return unit ? `${title} (${unit})` : title; +} + +/** + * Create a sparkline card element showing a small Plotly chart. + * Returns the DOM element and a destroy() function to free Plotly resources. + */ +export function createSparklineCard(options: SparklineCardOptions): { + element: HTMLElement; + destroy(): void; +} { + const titleEl = el('div', { class: 'sparkline-title' }, formatLabel(options.title, options.unit)); + const chartDiv = el('div', { class: 'sparkline-chart' }); + const card = el('div', { class: 'sparkline-card' }, titleEl, chartDiv); + + // Flag to prevent double-firing: Plotly's plotly_click fires after the DOM + // click has already bubbled to the card, so we can't use stopPropagation. + let traceClicked = false; + + if (options.onClick) { + const handler = options.onClick; + card.addEventListener('click', () => { + if (traceClicked) { + traceClicked = false; + return; + } + handler(); + }); + } + + const plotlyData = options.traces.map(trace => ({ + x: trace.points.map(p => p.x), + y: trace.points.map(p => p.value), + text: trace.points.map(p => p.commit), + type: 'scatter', + mode: 'lines', + line: { color: trace.color, width: 1.5 }, + hovertemplate: + `<b>${trace.machine}</b><br>` + + 'Commit: %{text}<br>' + + 'Value: %{y:.4g}<extra></extra>', + })); + + const layout = { + margin: { t: 8, r: 8, b: 30, l: 40 }, + xaxis: { type: 'linear', showgrid: false, showticklabels: false }, + yaxis: { automargin: true, tickfont: { size: 10 } }, + showlegend: false, + hovermode: 'closest' as const, + autosize: true, + }; + + const config = { responsive: true, displayModeBar: false }; + + // Schedule plot creation asynchronously (Plotly needs the element in the DOM) + let plotted = false; + requestAnimationFrame(() => { + if (chartDiv.isConnected) { + Plotly.newPlot(chartDiv, plotlyData, layout, config).then((gd) => { + if (options.onClick) { + const handler = options.onClick; + gd.on('plotly_click', (eventData) => { + const machine = options.traces[eventData.points[0]?.curveNumber]?.machine; + if (machine) { + traceClicked = true; + handler(machine); + } + }); + } + }).catch(() => { /* ok */ }); + plotted = true; + } + }); + + return { + element: card, + destroy() { + if (plotted) { + try { Plotly.purge(chartDiv); } catch { /* ok */ } + } + }, + }; +} + +/** + * Create a sparkline card placeholder showing a loading state. + */ +export function createSparklineLoading(title: string, unit?: string): HTMLElement { + return el('div', { class: 'sparkline-card' }, + el('div', { class: 'sparkline-title' }, formatLabel(title, unit)), + el('div', { class: 'sparkline-loading' }, 'Loading\u2026'), + ); +} + +/** + * Create a sparkline card placeholder showing an error state. + */ +export function createSparklineError(title: string, unit?: string): HTMLElement { + return el('div', { class: 'sparkline-card' }, + el('div', { class: 'sparkline-title' }, formatLabel(title, unit)), + el('div', { class: 'sparkline-error' }, 'Failed to load'), + ); +} diff --git a/lnt/server/ui/v5/frontend/src/csvExport.ts b/lnt/server/ui/v5/frontend/src/csvExport.ts new file mode 100644 index 000000000..12ce8f882 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/csvExport.ts @@ -0,0 +1,46 @@ +import type { ComparisonRow } from './types'; +import type { GeomeanResult } from './comparison'; +import { formatValue, formatPercent, formatRatio } from './utils'; + +function csvField(v: string): string { + if (v.includes(',') || v.includes('"') || v.includes('\n') || v.includes('\r')) { + return '"' + v.replace(/"/g, '""') + '"'; + } + return v; +} + +function csvRow(fields: string[]): string { + return fields.map(csvField).join(','); +} + +export function buildCsv(rows: ComparisonRow[], geomean: GeomeanResult | null): string { + const lines: string[] = [ + csvRow(['Test', 'Value A', 'Value B', 'Delta', 'Delta %', 'Ratio', 'Status']), + ]; + + if (geomean) { + lines.push(csvRow([ + 'Geomean', + formatValue(geomean.geomeanA), + formatValue(geomean.geomeanB), + formatValue(geomean.delta), + formatPercent(geomean.deltaPct), + formatRatio(geomean.ratioGeomean), + '', + ])); + } + + for (const r of rows) { + lines.push(csvRow([ + r.test, + formatValue(r.valueA), + formatValue(r.valueB), + formatValue(r.delta), + formatPercent(r.deltaPct), + formatRatio(r.ratio), + r.status, + ])); + } + + return lines.join('\n'); +} diff --git a/lnt/server/ui/v5/frontend/src/events.ts b/lnt/server/ui/v5/frontend/src/events.ts new file mode 100644 index 000000000..2d8f3e796 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/events.ts @@ -0,0 +1,22 @@ +// Custom DOM event names used for inter-module communication. +export const CHART_ZOOM = 'chart-zoom' as const; +export const CHART_HOVER = 'chart-hover' as const; +export const TABLE_HOVER = 'table-hover' as const; +export const TEST_FILTER_CHANGE = 'test-filter-change' as const; +export const SETTINGS_CHANGE = 'settings-change' as const; +export const GRAPH_TABLE_HOVER = 'graph-table-hover' as const; +export const GRAPH_CHART_HOVER = 'graph-chart-hover' as const; +export const GRAPH_CHART_DBLCLICK = 'graph-chart-dblclick' as const; + +/** Type-safe wrapper for addEventListener with CustomEvent detail. + * Returns a cleanup function to remove the listener. */ +export function onCustomEvent<T>( + name: string, + handler: (detail: T) => void, +): () => void { + const listener = ((e: CustomEvent<T>) => { + handler(e.detail); + }) as EventListener; + document.addEventListener(name, listener); + return () => document.removeEventListener(name, listener); +} diff --git a/lnt/server/ui/v5/frontend/src/main.ts b/lnt/server/ui/v5/frontend/src/main.ts new file mode 100644 index 000000000..73f9e9c0d --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/main.ts @@ -0,0 +1,79 @@ +// main.ts — SPA entry point for the v5 UI. + +import { setApiBase } from './api'; +import { addRoute, initRouter } from './router'; +import { renderNav, updateActiveNavLink } from './components/nav'; +import { el } from './utils'; +import './style.css'; + +// Page modules +import { homePage } from './pages/home'; +import { testSuitesPage } from './pages/test-suites'; +import { machineDetailPage } from './pages/machine-detail'; +import { runDetailPage } from './pages/run-detail'; +import { commitDetailPage } from './pages/commit-detail'; +import { graphPage } from './pages/graph'; +import { comparePage } from './pages/compare'; +import { regressionDetailPage } from './pages/regression-detail'; +import { adminPage } from './pages/admin'; +import { profilesPage } from './pages/profiles'; +import type { PageModule } from './router'; + +declare const lnt_url_base: string; + +function init(): void { + const root = document.getElementById('v5-app'); + if (!root) return; + + const testsuite = root.getAttribute('data-testsuite') || ''; + const testsuites: string[] = JSON.parse( + root.getAttribute('data-testsuites') || '[]' + ); + // Set API base from global set in layout.html + const urlBase = typeof lnt_url_base !== 'undefined' ? lnt_url_base : ''; + setApiBase(urlBase); + + // Render nav bar (persistent across route changes) + const nav = renderNav({ testsuite, urlBase }); + root.append(nav); + + // Page content container + const pageContainer = el('div', { id: 'v5-page' }); + root.append(pageContainer); + + if (testsuite) { + // Suite-scoped pages — detail views within a single test suite. + // The suite root redirects to the Test Suites page with suite pre-selected. + const suiteRedirectPage: PageModule = { + mount(): void { + window.location.replace(`${urlBase}/v5/test-suites?suite=${encodeURIComponent(testsuite)}`); + }, + }; + addRoute('/', suiteRedirectPage); + addRoute('/machines/:name', machineDetailPage); + addRoute('/runs/:uuid', runDetailPage); + addRoute('/commits/:value', commitDetailPage); + addRoute('/regressions/:uuid', regressionDetailPage); + + const basePath = `${urlBase}/v5/${encodeURIComponent(testsuite)}`; + initRouter(pageContainer, basePath, updateActiveNavLink, { testsuite, testsuites, urlBase }); + } else { + // Suite-agnostic pages — dashboard, test suites, analysis tools, admin + addRoute('/', homePage); + addRoute('/test-suites', testSuitesPage); + addRoute('/admin', adminPage); + addRoute('/graph', graphPage); + addRoute('/compare', comparePage); + addRoute('/profiles', profilesPage); + + const basePath = `${urlBase}/v5`; + initRouter(pageContainer, basePath, updateActiveNavLink, { testsuite: '', testsuites, urlBase }); + } +} + +// Start +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} diff --git a/lnt/server/ui/v5/frontend/src/pages/admin.ts b/lnt/server/ui/v5/frontend/src/pages/admin.ts new file mode 100644 index 000000000..735762d99 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/admin.ts @@ -0,0 +1,524 @@ +// pages/admin.ts — Admin page with API Keys and Test Suites tabs. +// Not test-suite specific — served at /v5/admin. + +import type { PageModule, RouteParams } from '../router'; +import type { APIKeyItem, TestSuiteInfo } from '../types'; +import { getApiKeys, createApiKey, revokeApiKey, getTestSuiteInfo, createTestSuite, deleteTestSuite, authErrorMessage } from '../api'; +import { el, formatTime } from '../utils'; + +let controller: AbortController | null = null; + +export const adminPage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + if (controller) controller.abort(); + controller = new AbortController(); + const { signal } = controller; + + container.append(el('h2', { class: 'page-header' }, 'Admin')); + + // Tab bar + const tabBar = el('div', { class: 'v5-tab-bar' }); + const keysTab = el('button', { class: 'v5-tab v5-tab-active' }, 'API Keys'); + const schemasTab = el('button', { class: 'v5-tab' }, 'Test Suites'); + const createSuiteTab = el('button', { class: 'v5-tab' }, 'Create Suite'); + tabBar.append(keysTab, schemasTab, createSuiteTab); + container.append(tabBar); + + const tabContent = el('div', { class: 'v5-tab-content' }); + container.append(tabContent); + + const allTabs = [keysTab, schemasTab, createSuiteTab]; + function activateTab(active: HTMLElement): void { + for (const t of allTabs) t.classList.remove('v5-tab-active'); + active.classList.add('v5-tab-active'); + } + + keysTab.addEventListener('click', () => { + activateTab(keysTab); + renderApiKeysTab(tabContent, signal); + }); + + schemasTab.addEventListener('click', () => { + activateTab(schemasTab); + renderSchemasTab(tabContent, signal); + }); + + createSuiteTab.addEventListener('click', () => { + activateTab(createSuiteTab); + renderCreateSuiteTab(tabContent, signal, () => { + // On create success: switch to Test Suites tab to see the new suite + activateTab(schemasTab); + renderSchemasTab(tabContent, signal); + }); + }); + + // Default to API Keys tab + renderApiKeysTab(tabContent, signal); + }, + + unmount(): void { + if (controller) { controller.abort(); controller = null; } + }, +}; + +// --------------------------------------------------------------------------- +// API Keys Tab +// --------------------------------------------------------------------------- + +function renderApiKeysTab(container: HTMLElement, signal: AbortSignal): void { + container.replaceChildren( + el('span', { class: 'progress-label' }, 'Loading API keys...'), + ); + + getApiKeys(signal) + .then(keys => { + container.replaceChildren(); + renderCreateForm(container, signal); + renderKeysTable(container, keys, signal); + }) + .catch(err => { + container.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + }); +} + +function renderCreateForm(container: HTMLElement, signal: AbortSignal): void { + const form = el('div', { class: 'admin-create-form' }); + + const nameInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: 'Key name...', + }) as HTMLInputElement; + + const scopeSelect = el('select', { class: 'admin-select' }) as HTMLSelectElement; + for (const scope of ['read', 'submit', 'triage', 'manage', 'admin']) { + scopeSelect.append(el('option', { value: scope }, scope)); + } + + const createBtn = el('button', { class: 'admin-btn' }, 'Create Key'); + const feedback = el('div', {}); + + createBtn.addEventListener('click', () => { + const name = nameInput.value.trim(); + if (!name) { + feedback.replaceChildren( + el('p', { class: 'error-banner' }, 'Key name is required.'), + ); + return; + } + + createBtn.setAttribute('disabled', ''); + feedback.replaceChildren( + el('span', { class: 'progress-label' }, 'Creating...'), + ); + + createApiKey(name, scopeSelect.value) + .then(result => { + nameInput.value = ''; + const copyBtn = el('button', { class: 'admin-copy-btn', title: 'Copy to clipboard' }, '\u{1F4CB}'); + copyBtn.addEventListener('click', () => { + navigator.clipboard.writeText(result.key).then(() => { + copyBtn.textContent = '\u2713'; + setTimeout(() => { copyBtn.textContent = '\u{1F4CB}'; }, 1500); + }); + }); + const tokenBox = el('div', { class: 'admin-raw-token' }, + el('span', {}, result.key), + copyBtn, + ); + feedback.replaceChildren( + el('div', { class: 'admin-key-created' }, + el('p', {}, 'Key created. Copy the token now — it will not be shown again:'), + tokenBox, + ), + ); + createBtn.removeAttribute('disabled'); + // Refresh the keys table + const tableContainer = container.querySelector('.admin-keys-table-container'); + if (tableContainer) { + getApiKeys(signal).then(keys => { + renderKeysTable(container, keys, signal); + }).catch(() => { /* keep existing table */ }); + } + }) + .catch(err => { + createBtn.removeAttribute('disabled'); + feedback.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + }); + }); + + form.append( + el('label', {}, 'Create API Key'), + el('div', { class: 'admin-form-row' }, nameInput, scopeSelect, createBtn), + feedback, + ); + container.append(form); +} + +function renderKeysTable(container: HTMLElement, keys: APIKeyItem[], signal: AbortSignal): void { + // Remove existing table if present + const existing = container.querySelector('.admin-keys-table-container'); + if (existing) existing.remove(); + + const wrapper = el('div', { class: 'admin-keys-table-container' }); + + if (keys.length === 0) { + wrapper.append(el('p', {}, 'No API keys found.')); + container.append(wrapper); + return; + } + + const table = el('table', { class: 'comparison-table' }) as HTMLTableElement; + const thead = el('thead'); + const headerRow = el('tr'); + for (const label of ['Prefix', 'Name', 'Scope', 'Created', 'Last Used', 'Active', '']) { + headerRow.append(el('th', {}, label)); + } + thead.append(headerRow); + table.append(thead); + + const tbody = el('tbody'); + for (const key of keys) { + const tr = el('tr'); + tr.append( + el('td', {}, key.prefix), + el('td', {}, key.name), + el('td', {}, key.scope), + el('td', {}, formatTime(key.created_at)), + el('td', {}, formatTime(key.last_used_at)), + el('td', {}, key.is_active ? 'Yes' : 'No'), + ); + + const actionTd = el('td', {}); + if (key.is_active) { + const revokeBtn = el('button', { class: 'admin-btn admin-btn-danger' }, 'Revoke'); + revokeBtn.addEventListener('click', () => { + revokeBtn.setAttribute('disabled', ''); + revokeBtn.textContent = 'Revoking...'; + revokeApiKey(key.prefix) + .then(() => { + // Refresh + getApiKeys(signal).then(updated => { + renderKeysTable(container, updated, signal); + }).catch(() => { + revokeBtn.removeAttribute('disabled'); + revokeBtn.textContent = 'Revoke'; + }); + }) + .catch(err => { + revokeBtn.removeAttribute('disabled'); + revokeBtn.textContent = 'Revoke'; + // Show error inline in the row + actionTd.append(el('span', { class: 'error-banner', style: 'display:inline; margin-left:4px; font-size:12px' }, authErrorMessage(err))); + }); + }); + actionTd.append(revokeBtn); + } + tr.append(actionTd); + tbody.append(tr); + } + table.append(tbody); + + wrapper.append(table); + container.append(wrapper); +} + +// --------------------------------------------------------------------------- +// Schemas (Test Suites) Tab +// --------------------------------------------------------------------------- + +function renderSchemasTab(container: HTMLElement, signal: AbortSignal): void { + container.replaceChildren(); + + // Always read the current suites from the DOM attribute (single source of truth). + const root = document.getElementById('v5-app'); + let suites: string[] = root + ? JSON.parse(root.getAttribute('data-testsuites') || '[]') + : []; + + // --- Suite selector + viewer --- + const selectorRow = el('div', { class: 'admin-form-row', style: 'margin-bottom: 12px' }); + selectorRow.append(el('label', {}, 'Test Suite: ')); + const suiteSelect = el('select', { class: 'admin-select' }) as HTMLSelectElement; + selectorRow.append(suiteSelect); + container.append(selectorRow); + + const schemaContent = el('div', {}); + container.append(schemaContent); + + function populateSelect(selectName?: string): void { + suiteSelect.replaceChildren(); + if (suites.length === 0) { + suiteSelect.append(el('option', { value: '' }, '(no test suites)')); + schemaContent.replaceChildren(el('p', {}, 'No test suites available.')); + return; + } + for (const name of suites) { + suiteSelect.append(el('option', { value: name }, name)); + } + if (selectName && suites.includes(selectName)) { + suiteSelect.value = selectName; + } + loadSchema(); + } + + function loadSchema(): void { + const ts = suiteSelect.value; + if (!ts) return; + schemaContent.replaceChildren( + el('span', { class: 'progress-label' }, 'Loading schema...'), + ); + getTestSuiteInfo(ts, signal) + .then(info => { + schemaContent.replaceChildren(); + renderSchemaContent(schemaContent, info); + renderDeleteSuite(schemaContent, ts, () => reloadSuites()); + }) + .catch(err => { + schemaContent.replaceChildren( + el('p', { class: 'error-banner' }, `Failed to load schema: ${err}`), + ); + }); + } + + function reloadSuites(selectName?: string): void { + // Re-read from the HTML attribute which is kept in sync on create/delete. + const root = document.getElementById('v5-app'); + if (root) { + suites = JSON.parse(root.getAttribute('data-testsuites') || '[]'); + } + populateSelect(selectName); + } + + suiteSelect.addEventListener('change', loadSchema); + populateSelect(); +} + +function renderCreateSuiteTab( + container: HTMLElement, + signal: AbortSignal, + onCreated: () => void, +): void { + container.replaceChildren(); + + const section = el('div', { class: 'admin-create-form' }); + section.append(el('label', {}, 'Create Test Suite')); + + const nameInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: 'Suite name (e.g., my_suite)...', + }) as HTMLInputElement; + + const jsonArea = el('textarea', { + class: 'admin-textarea', + placeholder: '{\n "format_version": "5",\n "name": "my_suite",\n "metrics": [\n {"name": "exec_time", "type": "Real", "bigger_is_better": false}\n ],\n "commit_fields": [\n {"name": "revision"}\n ],\n "machine_fields": [\n {"name": "hostname"}\n ]\n}', + }) as HTMLTextAreaElement; + jsonArea.rows = 10; + + const createBtn = el('button', { class: 'admin-btn' }, 'Create'); + const feedback = el('div', {}); + + createBtn.addEventListener('click', () => { + const name = nameInput.value.trim(); + if (!name) { + feedback.replaceChildren(el('p', { class: 'error-banner' }, 'Suite name is required.')); + return; + } + + let payload: Record<string, unknown>; + try { + payload = JSON.parse(jsonArea.value); + } catch { + feedback.replaceChildren(el('p', { class: 'error-banner' }, 'Invalid JSON.')); + return; + } + + // Ensure name in payload matches the name input + payload['name'] = name; + if (!payload['format_version']) payload['format_version'] = '5'; + + createBtn.setAttribute('disabled', ''); + feedback.replaceChildren(el('span', { class: 'progress-label' }, 'Creating...')); + + createTestSuite(payload, signal) + .then(() => { + createBtn.removeAttribute('disabled'); + nameInput.value = ''; + jsonArea.value = ''; + feedback.replaceChildren( + el('p', { class: 'admin-key-created', style: 'padding: 8px' }, + `Test suite '${name}' created successfully.`), + ); + // Update the DOM attribute (source of truth) and nav bar so the new suite is selectable. + // Do NOT mutate the shared `suites` array — rebuild from the DOM attribute instead. + const root = document.getElementById('v5-app'); + if (root) { + const current: string[] = JSON.parse(root.getAttribute('data-testsuites') || '[]'); + const updated = [...current, name].sort(); + root.setAttribute('data-testsuites', JSON.stringify(updated)); + } + onCreated(); + }) + .catch(err => { + createBtn.removeAttribute('disabled'); + feedback.replaceChildren(el('p', { class: 'error-banner' }, authErrorMessage(err))); + }); + }); + + section.append( + el('div', { class: 'admin-form-row' }, nameInput), + jsonArea, + el('div', { class: 'admin-form-row', style: 'margin-top: 8px' }, createBtn), + feedback, + ); + container.append(section); +} + +function renderDeleteSuite( + container: HTMLElement, + suiteName: string, + onDeleted: () => void, +): void { + const section = el('div', { class: 'admin-delete-section' }); + + const deleteToggle = el('button', { class: 'admin-btn admin-btn-danger' }, 'Delete This Suite'); + section.append(deleteToggle); + + const confirmPanel = el('div', { class: 'admin-delete-confirm', style: 'display: none' }); + + confirmPanel.append(el('p', { class: 'admin-delete-warning' }, + `Deleting test suite '${suiteName}' will permanently destroy all machines, runs, ` + + 'commits, samples, regressions, and field changes associated with it. ' + + 'This cannot be undone.', + )); + + confirmPanel.append(el('p', {}, `Type "${suiteName}" to confirm:`)); + + const confirmInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: suiteName, + }) as HTMLInputElement; + + const confirmBtn = el('button', { + class: 'admin-btn admin-btn-danger', + disabled: '', + }, 'Delete permanently') as HTMLButtonElement; + + const feedback = el('div', {}); + + confirmInput.addEventListener('input', () => { + if (confirmInput.value === suiteName) { + confirmBtn.removeAttribute('disabled'); + } else { + confirmBtn.setAttribute('disabled', ''); + } + }); + + confirmBtn.addEventListener('click', () => { + confirmBtn.setAttribute('disabled', ''); + confirmBtn.textContent = 'Deleting...'; + feedback.replaceChildren(); + + deleteTestSuite(suiteName) + .then(() => { + section.replaceChildren( + el('p', { class: 'admin-key-created', style: 'padding: 8px' }, + `Test suite '${suiteName}' deleted.`), + ); + // Remove from the suites list tracked by the parent + const root = document.getElementById('v5-app'); + if (root) { + const current: string[] = JSON.parse(root.getAttribute('data-testsuites') || '[]'); + const updated = current.filter(s => s !== suiteName); + root.setAttribute('data-testsuites', JSON.stringify(updated)); + } + onDeleted(); + }) + .catch(err => { + confirmBtn.removeAttribute('disabled'); + confirmBtn.textContent = 'Delete permanently'; + feedback.replaceChildren(el('p', { class: 'error-banner' }, authErrorMessage(err))); + }); + }); + + confirmPanel.append( + el('div', { class: 'admin-form-row' }, confirmInput, confirmBtn), + feedback, + ); + section.append(confirmPanel); + + deleteToggle.addEventListener('click', () => { + const visible = confirmPanel.style.display !== 'none'; + confirmPanel.style.display = visible ? 'none' : 'block'; + deleteToggle.textContent = visible ? 'Delete This Suite' : 'Cancel'; + deleteToggle.classList.toggle('admin-btn-danger', visible); + if (visible) { + confirmInput.value = ''; + confirmBtn.setAttribute('disabled', ''); + } + }); + + container.append(section); +} + +function renderSchemaContent(container: HTMLElement, info: TestSuiteInfo): void { + + // Metrics table + if (info.schema.metrics.length > 0) { + container.append(el('h4', {}, 'Metrics')); + const table = el('table', { class: 'comparison-table' }) as HTMLTableElement; + const thead = el('thead'); + const headerRow = el('tr'); + for (const label of ['Name', 'Type', 'Display Name', 'Unit', 'Bigger is Better']) { + headerRow.append(el('th', {}, label)); + } + thead.append(headerRow); + table.append(thead); + + const tbody = el('tbody'); + for (const f of info.schema.metrics) { + const tr = el('tr'); + tr.append( + el('td', {}, f.name), + el('td', {}, f.type), + el('td', {}, f.display_name || '\u2014'), + el('td', {}, f.unit ? `${f.unit} (${f.unit_abbrev || ''})` : '\u2014'), + el('td', {}, f.bigger_is_better === null ? '\u2014' : f.bigger_is_better ? 'Yes' : 'No'), + ); + tbody.append(tr); + } + table.append(tbody); + container.append(table); + } + + // Other schema sections + for (const [label, fields] of [ + ['Commit Fields', info.schema.commit_fields], + ['Machine Fields', info.schema.machine_fields], + ] as const) { + if (fields && fields.length > 0) { + container.append(el('h4', {}, label)); + const table = el('table', { class: 'comparison-table' }) as HTMLTableElement; + const thead = el('thead'); + const hr = el('tr'); + hr.append(el('th', {}, 'Name'), el('th', {}, 'Type')); + thead.append(hr); + table.append(thead); + + const tbody = el('tbody'); + for (const f of fields) { + const tr = el('tr'); + tr.append(el('td', {}, f.name), el('td', {}, f.type)); + tbody.append(tr); + } + table.append(tbody); + container.append(table); + } + } +} diff --git a/lnt/server/ui/v5/frontend/src/pages/commit-detail.ts b/lnt/server/ui/v5/frontend/src/pages/commit-detail.ts new file mode 100644 index 000000000..32912e5ca --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/commit-detail.ts @@ -0,0 +1,303 @@ +// pages/commit-detail.ts — Commit detail with ordinal editing, prev/next, machine filter, runs table. + +import type { PageModule, RouteParams } from '../router'; +import type { RunInfo, CommitDetail, RegressionListItem } from '../types'; +import { getCommit, getRunsByCommit, updateCommit, authErrorMessage, getRegressions } from '../api'; +import { el, spaLink, formatTime, truncate, debounce, matchesFilter, updateFilterValidation } from '../utils'; +import { navigate } from '../router'; +import { renderDataTable } from '../components/data-table'; +import { renderStateBadge } from '../regression-utils'; + +let controller: AbortController | null = null; + +export const commitDetailPage: PageModule = { + mount(container: HTMLElement, params: RouteParams): void { + if (controller) controller.abort(); + controller = new AbortController(); + const { signal } = controller; + + const ts = params.testsuite; + const commitValue = params.value; + + container.append(el('h2', { class: 'page-header' }, `Commit: ${commitValue}`)); + + const fieldsContainer = el('div', { class: 'commit-fields' }); + const ordinalContainer = el('div', { class: 'editable-field', 'data-field': 'ordinal' }); + const tagContainer = el('div', { class: 'editable-field', 'data-field': 'tag' }); + const navContainer = el('div', { class: 'commit-nav' }); + const summaryContainer = el('div', {}); + const filterContainer = el('div', { class: 'table-controls' }); + const tableContainer = el('div', {}); + const regressionsContainer = el('div', { class: 'commit-regressions-section' }); + container.append( + fieldsContainer, ordinalContainer, tagContainer, navContainer, + regressionsContainer, + summaryContainer, filterContainer, tableContainer, + ); + + const loading = el('p', { class: 'progress-label' }, 'Loading commit data...'); + container.append(loading); + + let runs: RunInfo[] = []; + let machineFilter = ''; + + Promise.all([ + getCommit(ts, commitValue, signal), + getRunsByCommit(ts, commitValue, signal), + ]).then(([commit, commitRuns]) => { + loading.remove(); + runs = commitRuns; + + // Commit fields + const dl = el('dl', { class: 'metadata-dl' }); + for (const [k, v] of Object.entries(commit.fields)) { + dl.append(el('dt', {}, k), el('dd', {}, v || '')); + } + fieldsContainer.append(dl); + + // Tag display + edit + renderOrdinal(ordinalContainer, ts, commitValue, commit); + renderTag(tagContainer, ts, commitValue, commit); + + // Prev/Next navigation + if (commit.previous_commit) { + const prevBtn = el('button', { class: 'pagination-btn' }, '\u2190 Previous'); + prevBtn.addEventListener('click', () => navigate(`/commits/${encodeURIComponent(commit.previous_commit!.commit)}`)); + navContainer.append(prevBtn); + } + if (commit.next_commit) { + const nextBtn = el('button', { class: 'pagination-btn' }, 'Next \u2192'); + nextBtn.addEventListener('click', () => navigate(`/commits/${encodeURIComponent(commit.next_commit!.commit)}`)); + navContainer.append(nextBtn); + } + + // Machine filter + const filterInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter machines...', + }) as HTMLInputElement; + const doFilter = debounce(() => { + machineFilter = filterInput.value; + renderSummaryAndTable(); + }, 200); + filterInput.addEventListener('input', () => { + updateFilterValidation(filterInput); + doFilter(); + }); + filterContainer.append(filterInput); + + renderSummaryAndTable(); + + // Load matching regressions (non-blocking) + loadCommitRegressions(ts, commitValue, regressionsContainer, signal); + }).catch(e => { + loading.remove(); + container.append(el('p', { class: 'error-banner' }, `Failed to load commit: ${e}`)); + }); + + function filteredRuns(): RunInfo[] { + if (!machineFilter) return runs; + return runs.filter(r => matchesFilter(r.machine, machineFilter)); + } + + function renderSummaryAndTable(): void { + const visible = filteredRuns(); + const allMachines = new Set(runs.map(r => r.machine)); + const visibleMachines = new Set(visible.map(r => r.machine)); + + summaryContainer.replaceChildren(); + if (machineFilter && visible.length !== runs.length) { + summaryContainer.append(el('p', {}, + `${visible.length} of ${runs.length} run${runs.length !== 1 ? 's' : ''} across ${visibleMachines.size} of ${allMachines.size} machine${allMachines.size !== 1 ? 's' : ''}` + )); + } else { + summaryContainer.append(el('p', {}, + `${runs.length} run${runs.length !== 1 ? 's' : ''} across ${allMachines.size} machine${allMachines.size !== 1 ? 's' : ''}` + )); + } + + tableContainer.replaceChildren(); + renderDataTable(tableContainer, { + columns: [ + { key: 'machine', label: 'Machine', + render: (r: RunInfo) => spaLink(r.machine, `/machines/${encodeURIComponent(r.machine)}`) }, + { key: 'uuid', label: 'Run UUID', + render: (r: RunInfo) => spaLink(r.uuid.slice(0, 8), `/runs/${encodeURIComponent(r.uuid)}`) }, + { key: 'submitted_at', label: 'Submitted', + render: (r: RunInfo) => formatTime(r.submitted_at) }, + ], + rows: visible, + emptyMessage: machineFilter ? 'No runs matching filter.' : 'No runs at this commit.', + }); + } + }, + + unmount(): void { + if (controller) { controller.abort(); controller = null; } + }, +}; + +function renderOrdinal( + container: HTMLElement, + ts: string, + commitValue: string, + commit: CommitDetail, +): void { + container.replaceChildren(); + + const label = el('strong', {}, 'Ordinal: '); + const value = el('span', {}, commit.ordinal != null ? String(commit.ordinal) : '(none)'); + const editBtn = el('button', { class: 'pagination-btn' }, 'Edit'); + container.append(label, value, editBtn); + + editBtn.addEventListener('click', () => { + container.replaceChildren(); + container.append(el('strong', {}, 'Ordinal: ')); + + const input = el('input', { + type: 'text', + class: 'ordinal-edit-input combobox-input', + placeholder: 'Enter ordinal (integer)...', + }) as HTMLInputElement; + input.value = commit.ordinal != null ? String(commit.ordinal) : ''; + input.style.width = '200px'; + + const saveBtn = el('button', { class: 'compare-btn' }, 'Save') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + const errorEl = el('span', { class: 'error-banner', style: 'display:none;margin-left:8px;padding:4px 8px' }); + + container.append(input, saveBtn, cancelBtn, errorEl); + input.focus(); + + cancelBtn.addEventListener('click', () => renderOrdinal(container, ts, commitValue, commit)); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + errorEl.textContent = ''; + const raw = input.value.trim(); + const newOrdinal = raw === '' ? null : parseInt(raw, 10); + if (raw !== '' && (isNaN(newOrdinal!) || !Number.isInteger(newOrdinal))) { + errorEl.textContent = 'Ordinal must be an integer'; + errorEl.style.display = 'inline'; + saveBtn.disabled = false; + return; + } + try { + const updated = await updateCommit(ts, commitValue, { ordinal: newOrdinal }); + commit.ordinal = updated.ordinal; + renderOrdinal(container, ts, commitValue, commit); + } catch (e: unknown) { + errorEl.textContent = authErrorMessage(e); + errorEl.style.display = 'inline'; + saveBtn.disabled = false; + } + }); + }); +} + +function renderTag( + container: HTMLElement, + ts: string, + commitValue: string, + commit: CommitDetail, +): void { + container.replaceChildren(); + + const label = el('strong', {}, 'Tag: '); + const value = el('span', {}, commit.tag ?? '(none)'); + const editBtn = el('button', { class: 'pagination-btn' }, 'Edit'); + container.append(label, value, editBtn); + + editBtn.addEventListener('click', () => { + container.replaceChildren(); + container.append(el('strong', {}, 'Tag: ')); + + const input = el('input', { + type: 'text', + class: 'ordinal-edit-input combobox-input', + placeholder: 'Enter tag...', + maxlength: '256', + }) as HTMLInputElement; + input.value = commit.tag ?? ''; + input.style.width = '200px'; + + const saveBtn = el('button', { class: 'compare-btn' }, 'Save') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + const errorEl = el('span', { class: 'error-banner', style: 'display:none;margin-left:8px;padding:4px 8px' }); + + container.append(input, saveBtn, cancelBtn, errorEl); + input.focus(); + + cancelBtn.addEventListener('click', () => renderTag(container, ts, commitValue, commit)); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + errorEl.textContent = ''; + const trimmed = input.value.trim(); + const newTag = trimmed || null; + try { + const updated = await updateCommit(ts, commitValue, { tag: newTag }); + commit.tag = updated.tag; + renderTag(container, ts, commitValue, commit); + } catch (e: unknown) { + errorEl.textContent = authErrorMessage(e); + errorEl.style.display = 'inline'; + saveBtn.disabled = false; + } + }); + }); +} + +async function loadCommitRegressions( + ts: string, + commit: string, + container: HTMLElement, + signal: AbortSignal, +): Promise<void> { + container.append(el('h3', {}, 'Regressions')); + + try { + const result = await getRegressions(ts, { commit, limit: 25 }, signal); + const regressions = result.items; + + if (regressions.length === 0) { + container.append(el('p', { class: 'no-results' }, + 'No regressions at this commit.')); + return; + } + + renderDataTable(container, { + columns: [ + { + key: 'title', + label: 'Regression', + render: (r: RegressionListItem) => spaLink( + truncate(r.title || '(untitled)', 50), + `/regressions/${encodeURIComponent(r.uuid)}`), + }, + { + key: 'state', + label: 'State', + render: (r: RegressionListItem) => renderStateBadge(r.state), + }, + { + key: 'machine_count', + label: 'Machines', + cellClass: 'col-num', + }, + { + key: 'test_count', + label: 'Tests', + cellClass: 'col-num', + }, + ], + rows: regressions, + emptyMessage: 'No matching regressions.', + }); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') return; + container.append(el('p', { class: 'error-banner' }, + `Failed to load regressions: ${e}`)); + } +} diff --git a/lnt/server/ui/v5/frontend/src/pages/compare.ts b/lnt/server/ui/v5/frontend/src/pages/compare.ts new file mode 100644 index 000000000..36a33a928 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/compare.ts @@ -0,0 +1,883 @@ +// pages/compare.ts — Compare page module for the SPA. +// +// Absorbs the existing comparison code (comparison.ts, selection.ts, table.ts, +// chart.ts) into the SPA as a page module. The mount() function replaces what +// the old standalone main.ts init() did. +// +// Per-run sample caching: fetched samples are cached by run UUID. Changing the +// metric, aggregation, or noise re-aggregates from cache without API calls. +// Only new run UUIDs (from commit/machine changes) trigger fetches. +// +// Cross-suite support: each side can independently select a test suite. +// Samples are fetched from the side's suite. The comparison joins on test name. + +import type { PageModule, RouteParams } from '../router'; +import type { ComparisonRow, SampleInfo, ProfileListItem } from '../types'; +import { getTestsuites } from '../router'; +import { getSamples, getProfilesForRun, createRegression, addRegressionIndicators, getToken, authErrorMessage } from '../api'; +import { renderRegressionCombobox } from '../components/regression-combobox'; +import { + CHART_ZOOM, CHART_HOVER, TABLE_HOVER, + TEST_FILTER_CHANGE, SETTINGS_CHANGE, + onCustomEvent, +} from '../events'; +import { getState, applyUrlState, setShadow, clearShadow } from '../state'; +import { + initSelection, fetchSideData, getMetricFields, renderSelectionPanel, +} from '../selection'; +import { + aggregateSamplesWithinRun, aggregateAcrossRuns, computeComparison, + computeGeomean, groupSamplesByTest, countRunsPerTest, +} from '../comparison'; +import { renderTable, filterToTests, highlightRow, resetTable, sortRows, applyTableFilters } from '../table'; +import { buildCsv } from '../csvExport'; +import { renderChart, highlightPoint, destroyChart } from '../chart'; +import { computeSummaryCounts, renderSummaryBar } from '../components/comparison-summary'; +import { el, truncate, matchesFilter, agnosticLink } from '../utils'; + +/** Cleanup functions for document-level event listeners. */ +let eventCleanups: Array<() => void> = []; +/** AbortController for in-flight sample fetches. */ +let fetchController: AbortController | null = null; +/** Per-run sample cache: run UUID → samples. */ +let sampleCache = new Map<string, SampleInfo[]>(); +/** Per-run profile cache: run UUID → profile list items. */ +let profileCache = new Map<string, ProfileListItem[]>(); +/** Cached profile links (invalidated when profileCache or runs change). */ +let cachedProfileLinks: Map<string, string> | undefined = undefined; +/** Cached intermediate aggregation state — preserved when only noise settings change. */ +let cachedRawA: Map<string, number[]> | null = null; +let cachedRawB: Map<string, number[]> | null = null; +let cachedMapA: Map<string, number> | null = null; +let cachedMapB: Map<string, number> | null = null; +let cachedRunCountA: Map<string, number> | null = null; +let cachedRunCountB: Map<string, number> | null = null; +/** Tests manually hidden by the user (click toggle). */ +let manuallyHidden = new Set<string>(); +/** Callback invoked after every table/chart render (used by regression panel). */ +let onAfterRender: (() => void) | null = null; +/** Regression combobox handle for lifecycle cleanup. */ +let regComboCleanup: { destroy: () => void } | null = null; +/** Pending requestAnimationFrame ID for chart rendering. */ +let pendingChartRAF: number | null = null; +/** Generation counter for RAF-batched chart renders. */ +let chartRenderGen = 0; +/** Cached shadow comparison rows. */ +let shadowRows: ComparisonRow[] = []; +/** Cached shadow side B intermediate aggregation state. */ +let cachedShadowRawB: Map<string, number[]> | null = null; +let cachedShadowMapB: Map<string, number> | null = null; + +export const comparePage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + // Restore state from URL query params + applyUrlState(window.location.search); + + const header = el('h2', { class: 'page-header' }, 'Compare'); + container.append(header); + + // Containers + const selectionContainer = el('div', {}); + const progressContainer = el('div', {}); + const errorContainer = el('div', {}); + const chartContainer = el('div', { class: 'chart-container' }, + el('p', { class: 'no-chart-data' }, 'No data to chart.'), + ); + const summaryContainer = el('div', { class: 'comparison-summary-container' }); + const copyBtn = el('button', { class: 'admin-copy-btn', title: 'Copy table as CSV' }, '\u{1F4CB}') as HTMLButtonElement; + copyBtn.style.display = 'none'; + const tableContainer = el('div', { class: 'table-container' }); + + let lastRows: ComparisonRow[] = []; + let chartZoomFilter: Set<string> | null = null; + + function scheduleChartRender( + chartRows: ComparisonRow[], + preFilteredTests?: Set<string> | null, + ): void { + chartRenderGen++; + const gen = chartRenderGen; + if (pendingChartRAF !== null) cancelAnimationFrame(pendingChartRAF); + pendingChartRAF = requestAnimationFrame(() => { + pendingChartRAF = null; + if (gen !== chartRenderGen) return; + const state = getState(); + const noiseHidden = computeNoiseHidden(); + const filteredShadow = state.shadow + ? shadowRows.filter(r => !noiseHidden.has(r.test) && !manuallyHidden.has(r.test)) + : undefined; + renderChart(chartContainer, chartRows, { + preserveZoom: true, + preFilteredTests, + shadowRows: filteredShadow, + shadowLabel: state.shadow + ? `${truncate(state.shadow.sideB.commit, 12)} on ${state.shadow.sideB.machine}` + : undefined, + }); + }); + } + + // ----- Compute noise-hidden set, visible tests, and render ----- + + /** Tests with noise status that are hidden via the "Hide noise" checkbox. */ + function computeNoiseHidden(): Set<string> { + const state = getState(); + if (!state.hideNoise) return new Set(); + const noisy = new Set<string>(); + for (const r of lastRows) { + if (r.status === 'noise') noisy.add(r.test); + } + return noisy; + } + + /** Rows actually in the table (lastRows minus noise-hidden). */ + let tableRows: ComparisonRow[] = []; + + /** Rows visible in the table: both sides present, not click-hidden, matching text filter and chart zoom. Operates on tableRows (noise already removed). */ + function isVisibleBoth(r: ComparisonRow, filter: string): boolean { + return r.sidePresent === 'both' + && !manuallyHidden.has(r.test) + && (!filter || matchesFilter(r.test, filter)) + && (!chartZoomFilter || chartZoomFilter.has(r.test)); + } + + function getVisibleBothRows(): ComparisonRow[] { + const filter = getState().testFilter ?? ''; + return tableRows.filter(r => isVisibleBoth(r, filter)); + } + + function hasVisibleBothRows(): boolean { + const filter = getState().testFilter ?? ''; + return tableRows.some(r => isVisibleBoth(r, filter)); + } + + /** Tests visible for the regression panel: both sides, not noise/click-hidden, matching text filter. Unlike getVisibleBothRows, operates on lastRows and ignores chart zoom. */ + function computeVisibleTests(): string[] { + const noiseHidden = computeNoiseHidden(); + const filter = getState().testFilter ?? ''; + return lastRows + .filter(r => r.sidePresent === 'both' + && !noiseHidden.has(r.test) + && !manuallyHidden.has(r.test) + && (!filter || matchesFilter(r.test, filter))) + .map(r => r.test); + } + + function buildProfileLinks(): Map<string, string> | undefined { + if (cachedProfileLinks !== undefined) return cachedProfileLinks; + + const state = getState(); + const repA = state.sideA.runs[state.sideA.runs.length - 1]; + const repB = state.sideB.runs[state.sideB.runs.length - 1]; + const suiteA = state.sideA.suite; + const suiteB = state.sideB.suite; + const profilesA = repA ? profileCache.get(repA) ?? [] : []; + const profilesB = repB ? profileCache.get(repB) ?? [] : []; + + if (profilesA.length === 0 && profilesB.length === 0) { + cachedProfileLinks = undefined; + return undefined; + } + + const profileSetA = new Set(profilesA.map(p => p.test)); + const profileSetB = new Set(profilesB.map(p => p.test)); + const links = new Map<string, string>(); + + for (const test of new Set([...profileSetA, ...profileSetB])) { + const hasA = profileSetA.has(test) && repA; + const hasB = profileSetB.has(test) && repB; + const params = new URLSearchParams(); + + if (hasA && hasB && suiteA === suiteB) { + params.set('suite_a', suiteA); + params.set('run_a', repA); + params.set('test_a', test); + params.set('suite_b', suiteB); + params.set('run_b', repB); + params.set('test_b', test); + } else if (hasA) { + params.set('suite_a', suiteA); + params.set('run_a', repA); + params.set('test_a', test); + } else if (hasB) { + params.set('suite_b', suiteB); + params.set('run_b', repB); + params.set('test_b', test); + } + links.set(test, `/profiles?${params.toString()}`); + } + cachedProfileLinks = links.size > 0 ? links : undefined; + return cachedProfileLinks; + } + + function syncChartAndSummary(matchingTests: Set<string> | null): void { + const noiseHidden = computeNoiseHidden(); + const chartRows = lastRows + .filter(r => !noiseHidden.has(r.test) && !manuallyHidden.has(r.test)); + scheduleChartRender(chartRows, matchingTests); + + const testFilter = getState().testFilter ?? ''; + const counts = computeSummaryCounts(lastRows, testFilter, chartZoomFilter, matchingTests); + renderSummaryBar(summaryContainer, counts); + + copyBtn.style.display = hasVisibleBothRows() ? '' : 'none'; + const tableMsg = tableContainer.querySelector('.table-message'); + if (tableMsg && !tableMsg.contains(copyBtn)) tableMsg.append(copyBtn); + + if (onAfterRender) onAfterRender(); + } + + function renderTableAndChart(): void { + const noiseHidden = computeNoiseHidden(); + tableRows = lastRows.filter(r => !noiseHidden.has(r.test)); + + // Pre-compute filtered test set once + const state = getState(); + const testFilter = state.testFilter ?? ''; + const matchingTests: Set<string> | null = testFilter + ? new Set(lastRows.filter(r => matchesFilter(r.test, testFilter)).map(r => r.test)) + : null; + + renderTable(tableContainer, tableRows, { + hiddenTests: manuallyHidden, + profileLinks: buildProfileLinks(), + onToggle: (test) => { + if (manuallyHidden.has(test)) { + manuallyHidden.delete(test); + } else { + manuallyHidden.add(test); + } + renderTableAndChart(); + }, + onIsolate: (test) => { + // Isolate only affects manuallyHidden; the two filters (noise, + // manual) are independent. + const visibleTests = tableRows + .filter(r => r.sidePresent === 'both' && !manuallyHidden.has(r.test) + && (matchingTests ? matchingTests.has(r.test) : true)) + .map(r => r.test); + + if (visibleTests.length === 1 && visibleTests[0] === test) { + // Already isolated — restore all + manuallyHidden = new Set(); + } else { + // Hide all visible except the target + manuallyHidden = new Set( + tableRows + .filter(r => r.sidePresent === 'both' && r.test !== test + && (matchingTests ? matchingTests.has(r.test) : true)) + .map(r => r.test), + ); + } + renderTableAndChart(); + }, + }); + syncChartAndSummary(matchingTests); + } + + // ----- Recompute from cache (no API calls) ----- + + function currentBiggerIsBetter(): boolean { + const field = getMetricFields().find(f => f.name === getState().metric); + return field?.bigger_is_better ?? false; + } + + function recomputeFromCache(): void { + const state = getState(); + if (!state.metric) return; + + const biggerIsBetter = currentBiggerIsBetter(); + + // Build raw sample pools per side (single pass) + const samplesA = state.sideA.runs + .map(uuid => sampleCache.get(uuid)) + .filter((s): s is SampleInfo[] => s !== undefined); + const samplesB = state.sideB.runs + .map(uuid => sampleCache.get(uuid)) + .filter((s): s is SampleInfo[] => s !== undefined); + + cachedRawA = groupSamplesByTest(samplesA, state.metric); + cachedRawB = groupSamplesByTest(samplesB, state.metric); + + // Aggregate within-run then across-runs + const perRunA = samplesA.map(s => aggregateSamplesWithinRun(s, state.metric, state.sampleAgg)); + const perRunB = samplesB.map(s => aggregateSamplesWithinRun(s, state.metric, state.sampleAgg)); + cachedMapA = aggregateAcrossRuns(perRunA, state.sideA.runAgg); + cachedMapB = aggregateAcrossRuns(perRunB, state.sideB.runAgg); + cachedRunCountA = countRunsPerTest(perRunA); + cachedRunCountB = countRunsPerTest(perRunB); + + const rows = computeComparison(cachedMapA, cachedMapB, biggerIsBetter, state.noiseConfig, cachedRawA, cachedRawB, cachedRunCountA, cachedRunCountB); + lastRows = rows; + + recomputeShadow(biggerIsBetter); + renderTableAndChart(); + } + + /** Re-classify from cached aggregated maps (no re-aggregation). */ + function reclassifyFromCache(): void { + const state = getState(); + if (!state.metric || !cachedMapA || !cachedMapB) return; + + const biggerIsBetter = currentBiggerIsBetter(); + + lastRows = computeComparison(cachedMapA, cachedMapB, biggerIsBetter, state.noiseConfig, cachedRawA ?? undefined, cachedRawB ?? undefined, cachedRunCountA ?? undefined, cachedRunCountB ?? undefined); + recomputeShadow(biggerIsBetter); + renderTableAndChart(); + } + + function clearShadowCaches(): void { + shadowRows = []; + cachedShadowRawB = null; + cachedShadowMapB = null; + } + + function recomputeShadow(biggerIsBetter?: boolean): void { + const state = getState(); + if (!state.shadow || !state.metric || !cachedMapA || !cachedRawA) { + shadowRows = []; + return; + } + + const bib = biggerIsBetter ?? currentBiggerIsBetter(); + + const shadowSamplesB = state.shadow.sideB.runs + .map(uuid => sampleCache.get(uuid)) + .filter((s): s is SampleInfo[] => s !== undefined); + + if (shadowSamplesB.length === 0) { + shadowRows = []; + return; + } + + cachedShadowRawB = groupSamplesByTest(shadowSamplesB, state.metric); + const perRunShadowB = shadowSamplesB.map(s => + aggregateSamplesWithinRun(s, state.metric, state.sampleAgg)); + cachedShadowMapB = aggregateAcrossRuns(perRunShadowB, state.shadow.sideB.runAgg); + + shadowRows = computeComparison( + cachedMapA, cachedShadowMapB, bib, + state.noiseConfig, cachedRawA, cachedShadowRawB, + ); + } + + // ----- Compare callback ----- + + function doCompare(): void { + const state = getState(); + if (state.sideA.runs.length === 0 || state.sideB.runs.length === 0 || !state.metric) { + return; + } + + errorContainer.replaceChildren(); + + // Check which runs need fetching — separate by side for per-suite API calls + const uncachedA = state.sideA.runs.filter(uuid => !sampleCache.has(uuid)); + const uncachedB = state.sideB.runs.filter(uuid => !sampleCache.has(uuid)); + const uncachedShadow = state.shadow + ? state.shadow.sideB.runs.filter(uuid => !sampleCache.has(uuid)) + : []; + + if (uncachedA.length === 0 && uncachedB.length === 0 && uncachedShadow.length === 0) { + // All data cached — recompute immediately without any API calls + recomputeFromCache(); + return; + } + + // Evict stale cache entries (old run UUIDs no longer selected or shadow-referenced) + const shadowRuns = state.shadow ? state.shadow.sideB.runs : []; + const allRunUuids = new Set([...state.sideA.runs, ...state.sideB.runs, ...shadowRuns]); + for (const uuid of sampleCache.keys()) { + if (!allRunUuids.has(uuid)) sampleCache.delete(uuid); + } + for (const uuid of profileCache.keys()) { + if (!allRunUuids.has(uuid)) profileCache.delete(uuid); + } + cachedProfileLinks = undefined; + // Invalidate aggregation caches — data is changing + cachedRawA = null; + cachedRawB = null; + cachedMapA = null; + cachedMapB = null; + cachedRunCountA = null; + cachedRunCountB = null; + clearShadowCaches(); + + // Abort any previous fetch + if (fetchController) fetchController.abort(); + fetchController = new AbortController(); + const { signal } = fetchController; + + // Track per-run cumulative counts for accurate total across parallel fetches + const perRunLoaded = new Map<string, number>(); + progressContainer.replaceChildren( + el('span', { class: 'progress-label' }, 'Loading samples...'), + ); + + function updateSampleProgress(uuid: string, loaded: number): void { + perRunLoaded.set(uuid, loaded); + let total = 0; + for (const n of perRunLoaded.values()) total += n; + progressContainer.replaceChildren( + el('span', { class: 'progress-label' }, `Loading ${total} samples...`), + ); + } + + // Fetch uncached runs — each side uses its own suite + const fetchPromises = [ + ...uncachedA.map(uuid => + getSamples(state.sideA.suite, uuid, signal, (loaded) => updateSampleProgress(uuid, loaded)).then(samples => { + sampleCache.set(uuid, samples); + }), + ), + ...uncachedB.map(uuid => + getSamples(state.sideB.suite, uuid, signal, (loaded) => updateSampleProgress(uuid, loaded)).then(samples => { + sampleCache.set(uuid, samples); + }), + ), + ]; + + // Shadow fetches use shadow's own suite and are isolated so failures don't kill main + const shadowFetchPromises = uncachedShadow.map(uuid => + getSamples(state.shadow!.sideB.suite, uuid, signal, (loaded) => updateSampleProgress(uuid, loaded)).then(samples => { + sampleCache.set(uuid, samples); + }), + ); + + // Fetch profiles for the representative (latest) run on each side + const repA = state.sideA.runs[state.sideA.runs.length - 1]; + const repB = state.sideB.runs[state.sideB.runs.length - 1]; + if (repA && !profileCache.has(repA)) { + fetchPromises.push( + getProfilesForRun(state.sideA.suite, repA, signal) + .then(profiles => { profileCache.set(repA, profiles); cachedProfileLinks = undefined; }) + .catch(() => {}), + ); + } + if (repB && !profileCache.has(repB)) { + fetchPromises.push( + getProfilesForRun(state.sideB.suite, repB, signal) + .then(profiles => { profileCache.set(repB, profiles); cachedProfileLinks = undefined; }) + .catch(() => {}), + ); + } + + // Main fetches fail hard, shadow fetches fail soft + Promise.all([ + Promise.all(fetchPromises), + Promise.allSettled(shadowFetchPromises), + ]) + .then(() => { + progressContainer.replaceChildren(); + recomputeFromCache(); + }) + .catch((err: unknown) => { + progressContainer.replaceChildren(); + if (err instanceof DOMException && err.name === 'AbortError') return; + errorContainer.replaceChildren( + el('p', { class: 'error-banner' }, `Comparison failed: ${err}`), + ); + }); + } + + // ----- Initialize selection with testsuites list ----- + + initSelection(getTestsuites(), doCompare); + + container.append(selectionContainer); + renderSelectionPanel(selectionContainer); + + // Shadow pin button — injected into Side B panel header + const pinBtn = el('button', { + class: 'compare-btn shadow-pin-btn', disabled: true, + }, 'Pin as Shadow') as HTMLButtonElement; + pinBtn.style.display = 'none'; + const sideBHeader = selectionContainer.querySelector('.side-b h3'); + if (sideBHeader) { + const headerRow = el('div', { class: 'side-header-row' }); + sideBHeader.replaceWith(headerRow); + headerRow.append(sideBHeader, pinBtn); + } + + // Shadow chip — toolbar row above chart (outside settings) + const shadowToolbar = el('div', { class: 'action-row shadow-toolbar' }); + shadowToolbar.style.display = 'none'; + const shadowBadge = el('span', { class: 'machine-chip' }); + const dismissBtn = el('button', { class: 'chip-remove', title: 'Remove shadow' }, '\u00d7'); + shadowToolbar.append(shadowBadge); + + function updateShadowToolbar(): void { + const st = getState(); + const hasComparison = st.sideA.runs.length > 0 && + st.sideB.runs.length > 0 && !!st.metric; + + // Pin button in Side B panel + pinBtn.style.display = (hasComparison && !st.shadow) ? '' : 'none'; + pinBtn.disabled = !hasComparison; + + // Shadow chip above chart + if (st.shadow) { + shadowToolbar.style.display = ''; + const label = `Shadow: ${truncate(st.shadow.sideB.commit, 12)} on ${st.shadow.sideB.machine}`; + shadowBadge.replaceChildren(label + ' ', dismissBtn); + } else { + shadowToolbar.style.display = 'none'; + } + } + + pinBtn.addEventListener('click', () => { + clearShadowCaches(); + const snapshot = structuredClone(getState().sideB); + setShadow({ sideB: snapshot }); + recomputeShadow(); + renderTableAndChart(); + }); + + dismissBtn.addEventListener('click', () => { + clearShadow(); + clearShadowCaches(); + renderTableAndChart(); + }); + + container.append(progressContainer, errorContainer, shadowToolbar, chartContainer, summaryContainer, tableContainer); + + // ----- Copy as CSV ----- + copyBtn.addEventListener('click', () => { + const state = getState(); + const rows = sortRows(getVisibleBothRows(), state.sort, state.sortDir); + const geomean = computeGeomean(rows); + navigator.clipboard.writeText(buildCsv(rows, geomean)).then(() => { + copyBtn.textContent = '✓'; + setTimeout(() => { copyBtn.textContent = '\u{1F4CB}'; }, 1500); + }).catch(() => { + copyBtn.textContent = '✗'; + setTimeout(() => { copyBtn.textContent = '\u{1F4CB}'; }, 1500); + }); + }); + + // ----- "Add to Regression" panel ----- + const hasToken = !!getToken(); + const regressionPanel = el('details', { class: 'add-to-regression-panel' }); + // Panel is hidden initially; shown only when both sides have a suite + regressionPanel.style.display = 'none'; + container.append(regressionPanel); + + if (hasToken) { + const summary = el('summary', {}, 'Add to Regression'); + regressionPanel.append(summary); + + const content = el('div', { class: 'add-to-regression-content' }); + regressionPanel.append(content); + + const mismatchMsg = el('p', { class: 'regression-label-muted' }, + 'Regressions can only be created within a single test suite.'); + mismatchMsg.style.display = 'none'; + content.append(mismatchMsg); + + // Tab buttons + const tabBar = el('div', { class: 'regression-mode-tabs' }); + const createTab = el('button', { class: 'tab-btn tab-btn-active' }, 'Create New'); + const existingTab = el('button', { class: 'tab-btn' }, 'Add to Existing'); + tabBar.append(createTab, existingTab); + content.append(tabBar); + + const createContent = el('div', { class: 'create-new-tab' }); + const existingContent = el('div', { class: 'add-existing-tab' }); + existingContent.style.display = 'none'; + content.append(createContent, existingContent); + + createTab.addEventListener('click', () => { + createTab.classList.add('tab-btn-active'); + existingTab.classList.remove('tab-btn-active'); + createContent.style.display = ''; + existingContent.style.display = 'none'; + }); + existingTab.addEventListener('click', () => { + existingTab.classList.add('tab-btn-active'); + createTab.classList.remove('tab-btn-active'); + existingContent.style.display = ''; + createContent.style.display = 'none'; + const st = getState(); + const suite = st.sideA.suite || st.sideB.suite; + if (suite && !regComboHandle) { + createRegCombo(suite); + } + }); + + // --- Create New tab --- + const titleInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: 'Regression title', + }) as HTMLInputElement; + const createInfo = el('p', { class: 'regression-label-muted' }); + const createBtn = el('button', { class: 'compare-btn' }, 'Create Regression') as HTMLButtonElement; + const createFeedback = el('div', {}); + createContent.append(titleInput, createInfo, createBtn, createFeedback); + + function buildIndicatorsFromComparison(): { + machine?: string; commit?: string; + indicators: Array<{ machine: string; test: string; metric: string }>; + } { + const st = getState(); + const commit = st.sideB.commit || st.sideA.commit || undefined; + const machine = st.sideA.machine || st.sideB.machine || undefined; + const tests = computeVisibleTests(); + const indicators = machine && st.metric + ? tests.map(t => ({ machine, test: t, metric: st.metric })) + : []; + return { machine, commit, indicators }; + } + + createBtn.addEventListener('click', async () => { + const st = getState(); + const suite = st.sideA.suite || st.sideB.suite; + if (!suite) return; + + createBtn.disabled = true; + createFeedback.replaceChildren(); + + const { commit, indicators } = buildIndicatorsFromComparison(); + + try { + const created = await createRegression(suite, { + title: titleInput.value.trim() || undefined, + state: 'detected', + commit, + indicators: indicators.length > 0 ? indicators : undefined, + }, fetchController?.signal); + const linkText = created.title || created.uuid.slice(0, 8); + const linkPath = `/${encodeURIComponent(suite)}/regressions/${encodeURIComponent(created.uuid)}`; + const msg = el('p', { class: 'regression-feedback-ok' }, 'Regression created: '); + msg.append(agnosticLink(linkText, linkPath)); + createFeedback.replaceChildren(msg); + titleInput.value = ''; + } catch (err: unknown) { + createFeedback.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + } finally { + createBtn.disabled = false; + } + }); + + // --- Add to Existing tab --- + let selectedRegUuid = ''; + const addExistingBtn = el('button', { class: 'compare-btn', disabled: '' }, 'Add Indicators') as HTMLButtonElement; + const addExistingFeedback = el('div', {}); + + // Regression combobox — fetches data on creation, filters locally + let regComboHandle: ReturnType<typeof renderRegressionCombobox> | null = null; + const regComboContainer = el('div', {}); + + function createRegCombo(suite: string): void { + if (regComboHandle) regComboHandle.destroy(); + regComboContainer.replaceChildren(); + selectedRegUuid = ''; + addExistingBtn.disabled = true; + regComboHandle = renderRegressionCombobox(regComboContainer, { + testsuite: suite, + onSelect: (uuid, _title) => { + selectedRegUuid = uuid; + addExistingBtn.disabled = false; + }, + onClear: () => { + selectedRegUuid = ''; + addExistingBtn.disabled = true; + }, + }); + regComboCleanup = regComboHandle; + } + + existingContent.append(regComboContainer, addExistingBtn, addExistingFeedback); + + addExistingBtn.addEventListener('click', async () => { + if (!selectedRegUuid) return; + const st = getState(); + const suite = st.sideA.suite || st.sideB.suite; + if (!suite) return; + + addExistingBtn.disabled = true; + addExistingFeedback.replaceChildren(); + + const { indicators } = buildIndicatorsFromComparison(); + + if (indicators.length === 0) { + addExistingFeedback.replaceChildren( + el('p', { class: 'error-banner' }, 'No indicators to add (need machine and metric)'), + ); + addExistingBtn.disabled = false; + return; + } + + try { + const updated = await addRegressionIndicators(suite, selectedRegUuid, indicators, fetchController?.signal); + const linkText = updated.title || selectedRegUuid.slice(0, 8); + const linkPath = `/${encodeURIComponent(suite)}/regressions/${encodeURIComponent(selectedRegUuid)}`; + const msg = el('p', { class: 'regression-feedback-ok' }, + `Added ${indicators.length} indicator(s) to `); + msg.append(agnosticLink(linkText, linkPath)); + addExistingFeedback.replaceChildren(msg); + } catch (err: unknown) { + addExistingFeedback.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + } finally { + addExistingBtn.disabled = false; + } + }); + + // Update panel visibility and info when comparison changes + let comboboxSuite = ''; + function updateRegressionPanel(): void { + const st = getState(); + const suite = st.sideA.suite || st.sideB.suite; + const hasSuite = !!suite; + const suitesMatch = !st.sideA.suite || !st.sideB.suite || st.sideA.suite === st.sideB.suite; + + if (!hasSuite) { + regressionPanel.style.display = 'none'; + return; + } + + regressionPanel.style.display = ''; + if (!suitesMatch) { + mismatchMsg.style.display = ''; + tabBar.style.display = 'none'; + createContent.style.display = 'none'; + existingContent.style.display = 'none'; + return; + } + + mismatchMsg.style.display = 'none'; + tabBar.style.display = ''; + // Restore active tab visibility + if (createTab.classList.contains('tab-btn-active')) { + createContent.style.display = ''; + existingContent.style.display = 'none'; + } else { + createContent.style.display = 'none'; + existingContent.style.display = ''; + } + + // Invalidate combobox when suite changes + if (suite !== comboboxSuite) { + if (regComboHandle) regComboHandle.destroy(); + regComboContainer.replaceChildren(); + regComboHandle = null; + regComboCleanup = null; + selectedRegUuid = ''; + addExistingBtn.disabled = true; + comboboxSuite = suite; + + if (existingTab.classList.contains('tab-btn-active')) { + createRegCombo(suite); + } + } + + // Update info text + const machine = st.sideA.machine || st.sideB.machine || '(none)'; + const commit = st.sideB.commit || st.sideA.commit || '(none)'; + const testCount = computeVisibleTests().length; + createInfo.textContent = `Pre-filled: commit=${truncate(commit, 12)}, machine=${machine}, ${testCount} tests`; + } + + // Hook into recompute cycle + onAfterRender = () => { + updateRegressionPanel(); + updateShadowToolbar(); + }; + + // Initial panel setup (creates combobox if suite is available from URL) + updateRegressionPanel(); + } + + // Wire event listeners (all return cleanup functions) + eventCleanups.push( + onCustomEvent<Set<string> | null>(CHART_ZOOM, (tests) => { + chartZoomFilter = tests; + filterToTests(tests); + const testFilter = getState().testFilter ?? ''; + const counts = computeSummaryCounts(lastRows, testFilter, chartZoomFilter); + renderSummaryBar(summaryContainer, counts); + copyBtn.style.display = hasVisibleBothRows() ? '' : 'none'; + }), + onCustomEvent<string | null>(CHART_HOVER, (testName) => { + highlightRow(testName); + }), + onCustomEvent<string | null>(TABLE_HOVER, (testName) => { + highlightPoint(testName); + }), + onCustomEvent(SETTINGS_CHANGE, () => { + // Noise or aggregation settings changed. If only noise/hideNoise changed, + // re-classify from cached maps (no re-aggregation). If aggregation + // changed, full recompute is needed (cachedMapA will be invalidated). + const state = getState(); + if (state.sideA.runs.length > 0 && state.sideB.runs.length > 0 && state.metric) { + if (cachedMapA && cachedMapB) { + reclassifyFromCache(); + } else { + recomputeFromCache(); + } + } else if (lastRows.length > 0) { + renderTableAndChart(); + } + }), + onCustomEvent(TEST_FILTER_CHANGE, () => { + if (lastRows.length === 0) return; + const matchingTests = applyTableFilters(); + syncChartAndSummary(matchingTests); + }), + ); + + // If URL state has suites, trigger per-side data loading + const state = getState(); + if (state.sideA.suite) fetchSideData('a', state.sideA.suite); + if (state.sideB.suite) fetchSideData('b', state.sideB.suite); + }, + + unmount(): void { + // Remove document-level event listeners + for (const cleanup of eventCleanups) cleanup(); + eventCleanups = []; + + // Abort in-flight sample fetches + if (fetchController) { + fetchController.abort(); + fetchController = null; + } + + // Clear state + sampleCache.clear(); + profileCache.clear(); + cachedProfileLinks = undefined; + cachedRawA = null; + cachedRawB = null; + cachedMapA = null; + cachedMapB = null; + cachedRunCountA = null; + cachedRunCountB = null; + shadowRows = []; + cachedShadowRawB = null; + cachedShadowMapB = null; + manuallyHidden = new Set(); + onAfterRender = null; + + // Destroy regression combobox + if (regComboCleanup) { + regComboCleanup.destroy(); + regComboCleanup = null; + } + + // Cancel pending chart RAF + if (pendingChartRAF !== null) { + cancelAnimationFrame(pendingChartRAF); + pendingChartRAF = null; + } + chartRenderGen = 0; + + // Clean up modules with mutable state + destroyChart(); + resetTable(); + }, +}; diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/baselines.ts b/lnt/server/ui/v5/frontend/src/pages/graph/baselines.ts new file mode 100644 index 000000000..94b2dc9f4 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/baselines.ts @@ -0,0 +1,199 @@ +// pages/graph/baselines.ts — Baseline panel with cascading suite→machine→commit +// dropdowns and removable baseline chips. + +import { el } from '../../utils'; +import type { CommitSummary } from '../../types'; +import { renderMachineCombobox } from '../../components/machine-combobox'; +import { createCommitPicker, type CommitPickerHandle } from '../../components/commit-combobox'; +import { commitDisplayValue } from '../../utils'; +import type { BaselineRef } from './state'; + +// ---- Types ---- + +export interface BaselinePanelHandle { + /** Re-render baseline chips with display values. */ + updateChips(baselines: BaselineRef[], displayMap: Map<string, string>): void; + /** Reset the panel (on suite change): collapse, clear cascading selections. */ + reset(): void; + /** The panel DOM element. */ + getElement(): HTMLElement; + /** Destroy sub-component handles. */ + destroy(): void; +} + +export interface BaselinePanelCallbacks { + onBaselineAdd(baseline: BaselineRef): void; + onBaselineRemove(baseline: BaselineRef): void; + getCommitFields(suite: string): Array<{ name: string; display?: boolean }>; + getBaselineCommits(suite: string, machine: string, signal?: AbortSignal): Promise<CommitSummary[]>; +} + +// ---- Implementation ---- + +export function createBaselinePanel( + baselines: BaselineRef[], + displayMap: Map<string, string>, + suites: string[], + callbacks: BaselinePanelCallbacks, +): BaselinePanelHandle { + const panel = el('div', { class: 'baseline-panel control-group' }); + + panel.append(el('label', {}, 'Baselines')); + + // "Add baseline" button (hidden when form is shown) + const addBtn = el('button', { type: 'button', class: 'baseline-add-btn' }, '+ Add baseline'); + panel.append(addBtn); + + // Expandable form — bare selects in a horizontal flex row (no per-field labels) + const form = el('div', { class: 'baseline-form', style: 'display: none' }); + panel.append(form); + + // Chips container (after form, so chips appear below) + const chipsContainer = el('div', { class: 'baseline-chips' }); + panel.append(chipsContainer); + + addBtn.addEventListener('click', () => { + form.style.display = ''; + addBtn.style.display = 'none'; + }); + + // Internal state for cascading dropdowns + let selectedSuite = ''; + let selectedMachine = ''; + let machineHandle: { destroy: () => void; clear: () => void } | null = null; + let commitPicker: CommitPickerHandle | null = null; + let abortCtrl: AbortController | null = null; + let cachedCommitValues: string[] = []; + let cachedCommitDisplayMap: Map<string, string> = new Map(); + + // Suite selector (bare, no label — inside horizontal form row) + const suiteSelect = el('select', { class: 'suite-select' }) as HTMLSelectElement; + suiteSelect.append(el('option', { value: '' }, '-- Suite --')); + for (const s of suites) { + suiteSelect.append(el('option', { value: s }, s)); + } + form.append(suiteSelect); + + // Machine combobox container (bare) + const machineContainer = el('div', {}); + form.append(machineContainer); + + // Commit picker container (bare) + const commitContainer = el('div', {}); + form.append(commitContainer); + + function clearCommitPicker(): void { + if (commitPicker) { commitPicker.destroy(); commitPicker = null; } + commitContainer.replaceChildren(); + cachedCommitValues = []; + cachedCommitDisplayMap = new Map(); + } + + function clearMachine(): void { + if (machineHandle) { machineHandle.destroy(); machineHandle = null; } + machineContainer.replaceChildren(); + selectedMachine = ''; + clearCommitPicker(); + } + + function createMachineCombo(suite: string): void { + clearMachine(); + if (!suite) return; + machineHandle = renderMachineCombobox(machineContainer, { + testsuite: suite, + onSelect(name: string) { + selectedMachine = name; + loadCommits(suite, name); + }, + }); + } + + async function loadCommits(suite: string, machine: string): Promise<void> { + clearCommitPicker(); + if (abortCtrl) abortCtrl.abort(); + abortCtrl = new AbortController(); + + try { + const commits = await callbacks.getBaselineCommits(suite, machine, abortCtrl.signal); + const commitFields = callbacks.getCommitFields(suite); + cachedCommitValues = commits.map(c => c.commit); + cachedCommitDisplayMap = new Map(); + for (const c of commits) { + const display = commitDisplayValue(c, commitFields); + if (display !== c.commit) cachedCommitDisplayMap.set(c.commit, display); + } + + commitPicker = createCommitPicker({ + id: 'baseline-commit', + getCommitData: () => ({ + values: cachedCommitValues, + displayMap: cachedCommitDisplayMap, + }), + onSelect(commit: string) { + // Auto-add baseline on commit selection + callbacks.onBaselineAdd({ suite: selectedSuite, machine: selectedMachine, commit }); + // Reset the form for next baseline + if (commitPicker) { commitPicker.input.value = ''; } + }, + placeholder: 'Select commit...', + }); + commitContainer.append(commitPicker.element); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; + commitContainer.replaceChildren(el('span', { class: 'error-text' }, 'Failed to load commits')); + } + } + + suiteSelect.addEventListener('change', () => { + selectedSuite = suiteSelect.value; + createMachineCombo(selectedSuite); + }); + + // --- Chips rendering --- + + function renderChips(bls: BaselineRef[], dm: Map<string, string>): void { + chipsContainer.replaceChildren(); + for (const bl of bls) { + const commitDisplay = dm.get(bl.commit) ?? bl.commit; + const label = `${bl.suite}/${bl.machine}/${commitDisplay}`; + + const chip = el('span', { class: 'baseline-chip' }); + chip.append(el('span', {}, label)); + const removeBtn = el('button', { + type: 'button', + class: 'chip-remove', + 'aria-label': `Remove baseline ${label}`, + }, '×'); + removeBtn.addEventListener('click', () => callbacks.onBaselineRemove(bl)); + chip.append(removeBtn); + chipsContainer.append(chip); + } + } + + renderChips(baselines, displayMap); + + return { + updateChips(bls: BaselineRef[], dm: Map<string, string>): void { + renderChips(bls, dm); + }, + + reset(): void { + form.style.display = 'none'; + addBtn.style.display = ''; + suiteSelect.value = ''; + selectedSuite = ''; + clearMachine(); + if (abortCtrl) { abortCtrl.abort(); abortCtrl = null; } + }, + + getElement(): HTMLElement { + return panel; + }, + + destroy(): void { + if (machineHandle) machineHandle.destroy(); + if (commitPicker) commitPicker.destroy(); + if (abortCtrl) abortCtrl.abort(); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/controls.ts b/lnt/server/ui/v5/frontend/src/pages/graph/controls.ts new file mode 100644 index 000000000..549be7646 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/controls.ts @@ -0,0 +1,237 @@ +// pages/graph/controls.ts — Control panel for the Graph page. +// Suite dropdown, machine chip input, metric selector, test filter, +// aggregation dropdowns, regression toggle. + +import { el, debounce, updateFilterValidation } from '../../utils'; +import type { FieldInfo, AggFn } from '../../types'; +import { renderMachineCombobox } from '../../components/machine-combobox'; +import { filterMetricFields, renderMetricSelector, renderEmptyMetricSelector } from '../../components/metric-selector'; +import type { GraphState, RegressionAnnotationMode } from './state'; +import { assignSymbolChar } from './traces'; + +// ---- Types ---- + +export interface ControlsHandle { + /** Replace the metric selector with new fields. */ + updateMetricSelector(fields: FieldInfo[], currentMetric: string): void; + /** Re-render machine chips (after add/remove). */ + updateMachineChips(machines: string[]): void; + /** Enable or disable all controls (disabled when no suite). */ + setEnabled(enabled: boolean): void; + /** Update the machine combobox for a new suite. */ + setSuite(suite: string): void; + /** Programmatically set the regression mode dropdown (does NOT fire callback). */ + setRegressionMode(mode: RegressionAnnotationMode): void; + /** Embed an element (e.g. baseline panel) at the end of the first controls row. */ + embedInRow1(element: HTMLElement): void; + /** The controls panel DOM element. */ + getElement(): HTMLElement; + /** Destroy all sub-component handles. */ + destroy(): void; +} + +export interface ControlsCallbacks { + onSuiteChange(suite: string): void; + onMachineAdd(name: string): void; + onMachineRemove(name: string): void; + onMetricChange(metric: string): void; + onFilterChange(filter: string): void; + onRunAggChange(agg: AggFn): void; + onSampleAggChange(agg: AggFn): void; + onRegressionModeChange(mode: RegressionAnnotationMode): void; +} + +// ---- Helpers ---- + +function createAggSelect(label: string, current: AggFn, onChange: (agg: AggFn) => void): HTMLElement { + const group = el('div', { class: 'control-group' }); + group.append(el('label', {}, label)); + const select = el('select', {}) as HTMLSelectElement; + for (const opt of ['median', 'mean', 'min', 'max'] as AggFn[]) { + const option = el('option', { value: opt }, opt); + if (opt === current) (option as HTMLOptionElement).selected = true; + select.append(option); + } + select.addEventListener('change', () => onChange(select.value as AggFn)); + group.append(select); + return group; +} + +function createRegressionToggle(current: RegressionAnnotationMode, onChange: (mode: RegressionAnnotationMode) => void): { element: HTMLElement; select: HTMLSelectElement } { + const group = el('div', { class: 'control-group' }); + group.append(el('label', {}, 'Regressions')); + const select = el('select', { class: 'metric-select' }) as HTMLSelectElement; + for (const [value, label] of [['off', 'Off'], ['active', 'Active'], ['all', 'All']] as const) { + const option = el('option', { value }, label); + if (value === current) (option as HTMLOptionElement).selected = true; + select.append(option); + } + select.addEventListener('change', () => onChange(select.value as RegressionAnnotationMode)); + group.append(select); + return { element: group, select }; +} + +// ---- Main export ---- + +export function createControls( + state: GraphState, + suites: string[], + callbacks: ControlsCallbacks, +): ControlsHandle { + const panel = el('div', { class: 'controls-panel' }); + + // Row 1: Suite + Machine combobox + Machine chips + const row1 = el('div', { class: 'controls-row controls-row-top' }); + + // Suite selector + const suiteGroup = el('div', { class: 'control-group' }); + suiteGroup.append(el('label', {}, 'Suite')); + const suiteSelect = el('select', { class: 'suite-select' }) as HTMLSelectElement; + suiteSelect.append(el('option', { value: '' }, '-- Select suite --')); + for (const s of suites) { + const opt = el('option', { value: s }, s); + if (s === state.suite) (opt as HTMLOptionElement).selected = true; + suiteSelect.append(opt); + } + suiteSelect.addEventListener('change', () => callbacks.onSuiteChange(suiteSelect.value)); + suiteGroup.append(suiteSelect); + row1.append(suiteGroup); + + // Machine combobox + const machineGroup = el('div', { class: 'control-group machine-control' }); + machineGroup.append(el('label', {}, 'Machines')); + const machineComboContainer = el('div', {}); + const chipsContainer = el('div', { class: 'machine-chips' }); + machineGroup.append(machineComboContainer, chipsContainer); + row1.append(machineGroup); + + panel.append(row1); + + // Row 2: Metric + Aggregation + Filter + Regressions + const row2 = el('div', { class: 'controls-row' }); + + // Metric selector placeholder + const metricContainer = el('div', { class: 'metric-container' }); + renderEmptyMetricSelector(metricContainer); + row2.append(metricContainer); + + // Aggregation selectors + row2.append(createAggSelect('Run aggregation', state.runAgg, callbacks.onRunAggChange)); + row2.append(createAggSelect('Sample aggregation', state.sampleAgg, callbacks.onSampleAggChange)); + + // Test filter + const filterGroup = el('div', { class: 'control-group' }); + filterGroup.append(el('label', {}, 'Filter tests')); + const filterInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter tests...', + value: state.testFilter, + }) as HTMLInputElement; + const debouncedFilter = debounce(() => callbacks.onFilterChange(filterInput.value), 200); + filterInput.addEventListener('input', () => { + updateFilterValidation(filterInput); + debouncedFilter(); + }); + filterGroup.append(filterInput); + row2.append(filterGroup); + + // Regression toggle + const regressionToggle = createRegressionToggle(state.regressionMode, callbacks.onRegressionModeChange); + row2.append(regressionToggle.element); + + panel.append(row2); + + // --- Machine combobox handle --- + let machineComboHandle: { destroy: () => void; clear: () => void } | null = null; + + function createMachineCombo(suite: string): void { + if (machineComboHandle) { + machineComboHandle.destroy(); + machineComboHandle = null; + } + machineComboContainer.replaceChildren(); + machineComboHandle = renderMachineCombobox(machineComboContainer, { + testsuite: suite, + onSelect(name: string) { + callbacks.onMachineAdd(name); + machineComboHandle?.clear(); + }, + }); + } + + createMachineCombo(state.suite); + + // --- Machine chips rendering --- + + function renderChips(machines: string[]): void { + chipsContainer.replaceChildren(); + for (let i = 0; i < machines.length; i++) { + const m = machines[i]; + const chip = el('span', { class: 'machine-chip' }); + const symbolSpan = el('span', { class: 'chip-symbol' }, assignSymbolChar(i)); + const nameSpan = el('span', {}, m); + const removeBtn = el('button', { + type: 'button', + class: 'chip-remove', + 'aria-label': `Remove ${m}`, + }, '×'); + removeBtn.addEventListener('click', () => callbacks.onMachineRemove(m)); + chip.append(symbolSpan, nameSpan, removeBtn); + chipsContainer.append(chip); + } + } + + renderChips(state.machines); + + // --- Enable/disable --- + + function setEnabled(enabled: boolean): void { + const inputs = panel.querySelectorAll<HTMLInputElement | HTMLSelectElement>('input, select'); + for (const inp of inputs) { + if (inp === suiteSelect) continue; // suite selector always enabled + inp.disabled = !enabled; + } + } + + if (!state.suite) setEnabled(false); + + return { + updateMetricSelector(fields: FieldInfo[], currentMetric: string): void { + metricContainer.replaceChildren(); + const metricFields = filterMetricFields(fields); + if (metricFields.length > 0) { + renderMetricSelector(metricContainer, metricFields, callbacks.onMetricChange, currentMetric, { placeholder: true }); + } else { + renderEmptyMetricSelector(metricContainer); + } + }, + + updateMachineChips(machines: string[]): void { + renderChips(machines); + }, + + setEnabled, + + setSuite(suite: string): void { + createMachineCombo(suite); + setEnabled(!!suite); + }, + + setRegressionMode(mode: RegressionAnnotationMode): void { + regressionToggle.select.value = mode; + }, + + embedInRow1(element: HTMLElement): void { + row1.append(element); + }, + + getElement(): HTMLElement { + return panel; + }, + + destroy(): void { + if (machineComboHandle) machineComboHandle.destroy(); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/data-cache.ts b/lnt/server/ui/v5/frontend/src/pages/graph/data-cache.ts new file mode 100644 index 000000000..f566f794f --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/data-cache.ts @@ -0,0 +1,334 @@ +// pages/graph/data-cache.ts — Centralized data cache for the graph page. +// Module-level instance survives mount/unmount for instant back-nav. + +import type { QueryDataPoint, CommitSummary, RegressionListItem } from '../../types'; +import type { CursorPageResult } from '../../api'; +import { commitDisplayValue } from '../../utils'; + +export interface GraphDataApi { + apiUrl: (suite: string, path: string) => string; + fetchOneCursorPage: <T>(url: string, params?: Record<string, string | string[]>, signal?: AbortSignal) => Promise<CursorPageResult<T>>; + postOneCursorPage: <T>(url: string, body: Record<string, unknown>, signal?: AbortSignal) => Promise<CursorPageResult<T>>; +} + +const PAGE_LIMIT = 10000; + +function dataKey(suite: string, machine: string, metric: string, test: string): string { + return `${suite}\0${machine}\0${metric}\0${test}`; +} + +function baselineKey(suite: string, machine: string, commit: string, metric: string): string { + return `${suite}\0${machine}\0${commit}\0${metric}`; +} + +function testNamesKey(suite: string, machine: string, metric: string): string { + return `${suite}\0${machine}\0${metric}`; +} + +function scaffoldKey(suite: string, machine: string): string { + return `${suite}\0${machine}`; +} + +function regressionKey(suite: string, mode: string): string { + return `${suite}\0${mode}`; +} + +interface ScaffoldEntry { commit: string; ordinal: number; tag: string | null; fields: Record<string, string>; } + +interface ScaffoldCache { entries: ScaffoldEntry[]; commits: string[]; } + +export class GraphDataCache { + private data = new Map<string, { points: QueryDataPoint[]; complete: boolean }>(); + private baselineData = new Map<string, { points: QueryDataPoint[]; fetchedTests: Set<string> }>(); + private baselineCommits = new Map<string, CommitSummary[]>(); + private testNames = new Map<string, string[]>(); + private scaffolds = new Map<string, ScaffoldCache>(); + private regressions = new Map<string, RegressionListItem[]>(); + private api: GraphDataApi; + + constructor(api: GraphDataApi) { + this.api = api; + } + + // ---- Scaffold ---- + + async getScaffold( + suite: string, + machine: string, + signal?: AbortSignal, + ): Promise<string[]> { + const key = scaffoldKey(suite, machine); + const cached = this.scaffolds.get(key); + if (cached) return cached.commits; + + const entries: ScaffoldEntry[] = []; + let cursor: string | undefined; + const commitsUrl = this.api.apiUrl(suite, 'commits'); + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const params: Record<string, string> = { machine, sort: 'ordinal', limit: '10000' }; + if (cursor) params.cursor = cursor; + const page = await this.api.fetchOneCursorPage<CommitSummary>(commitsUrl, params, signal); + for (const item of page.items) { + if (item.ordinal != null) { + entries.push({ commit: item.commit, ordinal: item.ordinal, tag: item.tag, fields: item.fields }); + } + } + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + const commits = entries.map(e => e.commit); + this.scaffolds.set(key, { entries, commits }); + return commits; + } + + // ---- Test Discovery ---- + + async discoverTests(suite: string, machine: string, metric: string, signal?: AbortSignal): Promise<string[]> { + const key = testNamesKey(suite, machine, metric); + const cached = this.testNames.get(key); + if (cached) return cached; + + const allNames: string[] = []; + let cursor: string | undefined; + const url = this.api.apiUrl(suite, 'tests'); + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const params: Record<string, string | string[]> = { + machine, + metric, + limit: '10000', + }; + if (cursor) params.cursor = cursor; + const page = await this.api.fetchOneCursorPage<{ name: string }>(url, params, signal); + for (const t of page.items) allNames.push(t.name); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + allNames.sort((a, b) => a.localeCompare(b)); + this.testNames.set(key, allNames); + return allNames; + } + + // ---- Query Data ---- + + async ensureTestData( + suite: string, machine: string, metric: string, tests: string[], + opts?: { signal?: AbortSignal; onProgress?: () => void }, + ): Promise<void> { + const uncached = tests.filter(t => { + const key = dataKey(suite, machine, metric, t); + const entry = this.data.get(key); + return !entry || !entry.complete; + }); + if (uncached.length === 0) return; + + for (const t of uncached) { + const key = dataKey(suite, machine, metric, t); + this.data.set(key, { points: [], complete: false }); + } + + const queryUrl = this.api.apiUrl(suite, 'query'); + let cursor: string | undefined; + while (true) { + if (opts?.signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const body: Record<string, unknown> = { + machine, + metric, + test: uncached, + sort: 'test,commit', + limit: PAGE_LIMIT, + }; + if (cursor) body.cursor = cursor; + + const page = await this.api.postOneCursorPage<QueryDataPoint>(queryUrl, body, opts?.signal); + + for (const pt of page.items) { + const key = dataKey(suite, machine, metric, pt.test); + const entry = this.data.get(key); + if (entry) entry.points.push(pt); + } + + if (!page.nextCursor) break; + cursor = page.nextCursor; + + if (opts?.onProgress) opts.onProgress(); + } + + for (const t of uncached) { + const key = dataKey(suite, machine, metric, t); + const entry = this.data.get(key); + if (entry) entry.complete = true; + } + + if (opts?.onProgress) opts.onProgress(); + } + + readCachedTestData(suite: string, machine: string, metric: string, test: string): QueryDataPoint[] { + const key = dataKey(suite, machine, metric, test); + const entry = this.data.get(key); + return entry ? entry.points : []; + } + + isComplete(suite: string, machine: string, metric: string, test: string): boolean { + const key = dataKey(suite, machine, metric, test); + const entry = this.data.get(key); + return entry?.complete ?? false; + } + + // ---- Baseline Data (cross-suite, delta-fetch) ---- + + async getBaselineData( + suite: string, machine: string, commit: string, metric: string, + tests: string[], signal?: AbortSignal, + ): Promise<QueryDataPoint[]> { + const key = baselineKey(suite, machine, commit, metric); + const cached = this.baselineData.get(key); + + // Delta-fetch: only request tests not already cached + const newTests = cached + ? tests.filter(t => !cached.fetchedTests.has(t)) + : tests; + + if (newTests.length === 0 && cached) return cached.points; + + const queryUrl = this.api.apiUrl(suite, 'query'); + const fetched: QueryDataPoint[] = []; + let cursor: string | undefined; + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const body: Record<string, unknown> = { + machine, + metric, + commit, + test: newTests, + limit: PAGE_LIMIT, + }; + if (cursor) body.cursor = cursor; + const page = await this.api.postOneCursorPage<QueryDataPoint>(queryUrl, body, signal); + for (const pt of page.items) fetched.push(pt); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + + // Merge into existing cache + if (cached) { + for (const pt of fetched) cached.points.push(pt); + for (const t of newTests) cached.fetchedTests.add(t); + return cached.points; + } else { + const entry = { points: fetched, fetchedTests: new Set(tests) }; + this.baselineData.set(key, entry); + return fetched; + } + } + + readCachedBaselineData(suite: string, machine: string, commit: string, metric: string): QueryDataPoint[] { + const key = baselineKey(suite, machine, commit, metric); + const entry = this.baselineData.get(key); + return entry ? entry.points : []; + } + + scaffoldUnion( + suite: string, + machineList: string[], + commitFields?: Array<{ name: string; display?: boolean }>, + ): { commits: string[]; displayMap: Map<string, string> } | null { + const byCommit = new Map<string, number>(); + const displayMap = new Map<string, string>(); + for (const m of machineList) { + const key = scaffoldKey(suite, m); + const cached = this.scaffolds.get(key); + if (cached) { + for (const entry of cached.entries) { + if (!byCommit.has(entry.commit)) { + byCommit.set(entry.commit, entry.ordinal); + if (commitFields) { + const display = commitDisplayValue(entry, commitFields); + if (display !== entry.commit) { + displayMap.set(entry.commit, display); + } + } + } + } + } + } + if (byCommit.size === 0) return null; + const sorted = [...byCommit.entries()].sort((a, b) => a[1] - b[1]); + return { commits: sorted.map(([commit]) => commit), displayMap }; + } + + // ---- Baseline Commits (cross-suite, not cleared on suite change) ---- + + async getBaselineCommits( + suite: string, machine: string, signal?: AbortSignal, + ): Promise<CommitSummary[]> { + const key = scaffoldKey(suite, machine); + const cached = this.baselineCommits.get(key); + if (cached) return cached; + + const allCommits: CommitSummary[] = []; + let cursor: string | undefined; + const url = this.api.apiUrl(suite, 'commits'); + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const params: Record<string, string> = { machine, sort: 'ordinal', limit: '10000' }; + if (cursor) params.cursor = cursor; + const page = await this.api.fetchOneCursorPage<CommitSummary>(url, params, signal); + for (const item of page.items) allCommits.push(item); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + this.baselineCommits.set(key, allCommits); + return allCommits; + } + + // ---- Regressions ---- + + async getRegressions( + suite: string, mode: 'active' | 'all', signal?: AbortSignal, + ): Promise<RegressionListItem[]> { + const key = regressionKey(suite, mode); + const cached = this.regressions.get(key); + if (cached) return cached; + + const all: RegressionListItem[] = []; + let cursor: string | undefined; + const url = this.api.apiUrl(suite, 'regressions'); + const stateFilter = mode === 'active' ? 'detected,active' : ''; + while (true) { + if (signal?.aborted) throw new DOMException('Aborted', 'AbortError'); + const params: Record<string, string> = { limit: String(PAGE_LIMIT) }; + if (stateFilter) params.state = stateFilter; + if (cursor) params.cursor = cursor; + const page = await this.api.fetchOneCursorPage<RegressionListItem>(url, params, signal); + for (const item of page.items) all.push(item); + if (!page.nextCursor) break; + cursor = page.nextCursor; + } + this.regressions.set(key, all); + return all; + } + + readCachedRegressions(suite: string, mode: 'active' | 'all'): RegressionListItem[] | null { + return this.regressions.get(regressionKey(suite, mode)) ?? null; + } + + // ---- Cache Management ---- + + /** Clear suite-specific caches (scaffolds, tests, query data, regressions). + * Preserves cross-suite baseline data and baseline commit caches. */ + clearSuite(): void { + this.data.clear(); + this.testNames.clear(); + this.scaffolds.clear(); + this.regressions.clear(); + } + + /** Clear all caches including cross-suite baselines. */ + clear(): void { + this.clearSuite(); + this.baselineData.clear(); + this.baselineCommits.clear(); + } +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/index.ts b/lnt/server/ui/v5/frontend/src/pages/graph/index.ts new file mode 100644 index 000000000..051102258 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/index.ts @@ -0,0 +1,665 @@ +// pages/graph/index.ts — Graph page orchestrator. +// Wires state, cache, controls, baselines, chart, and table together. + +import type { PageModule, RouteParams } from '../../router'; +import type { AggFn } from '../../types'; +import { fetchOneCursorPage, postOneCursorPage, apiUrl, getTestSuiteInfoCached } from '../../api'; +import { el, getAggFn, TRACE_SEP, resolveDisplayMap, matchesFilter } from '../../utils'; +import { getTestsuites } from '../../router'; +import { onCustomEvent, GRAPH_TABLE_HOVER, GRAPH_CHART_HOVER, GRAPH_CHART_DBLCLICK } from '../../events'; + +import { decodeGraphState, replaceGraphUrl, type BaselineRef, type RegressionAnnotationMode } from './state'; +import { GraphDataCache } from './data-cache'; +import { + buildChartData, buildBaselinesFromData, buildRegressionOverlays, + buildRawValuesCallback, buildColorMap, assignSymbolChar, +} from './traces'; +import { createControls, type ControlsHandle } from './controls'; +import { createBaselinePanel, type BaselinePanelHandle } from './baselines'; +import { createTimeSeriesChart, type ChartHandle } from './time-series-chart'; +import { + createTestSelectionTable, type TestSelectionTableHandle, type TestSelectionEntry, +} from './test-selection-table'; + +// --------------------------------------------------------------------------- +// Module-level state — survives unmount/remount for instant back-nav +// --------------------------------------------------------------------------- + +let cache = new GraphDataCache({ apiUrl, fetchOneCursorPage, postOneCursorPage }); +/** Full unfiltered test list across all machines (for stable color assignment). */ +let allDiscoveredTests: string[] = []; +/** Filtered test list (for table display). */ +let allMatchingTests: string[] = []; +/** User's explicit test selection. */ +let selectedTests = new Set<string>(); +/** Current suite for cache scope. */ +let currentSuite = ''; +/** Selected machines. */ +let machines: string[] = []; +/** Current metric. */ +let metric = ''; +/** Resolved display values for baseline commits (survives unmount/remount). */ +let baselineResolvedMap = new Map<string, string>(); + +// --------------------------------------------------------------------------- +// Helper: extract test name from trace name +// --------------------------------------------------------------------------- + +function testNameFromTrace(tn: string): string { + const idx = tn.lastIndexOf(TRACE_SEP); + return idx >= 0 ? tn.slice(0, idx) : tn; +} + +// Module-level cleanup function — set by mount(), called by unmount() +let cleanupFn: (() => void) | null = null; + +// --------------------------------------------------------------------------- +// Page module +// --------------------------------------------------------------------------- + +export const graphPage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + // ---- Parse URL state ---- + const state = decodeGraphState(window.location.search); + currentSuite = state.suite; + machines = state.machines; + metric = state.metric; + let testFilter = state.testFilter; + let runAgg = state.runAgg; + let sampleAgg = state.sampleAgg; + const baselines: BaselineRef[] = [...state.baselines]; + let regressionMode: RegressionAnnotationMode = state.regressionMode; + + // ---- Transient state (per-mount) ---- + let loadingTests = new Set<string>(); + let chartHandle: ChartHandle | null = null; + let tableHandle: TestSelectionTableHandle | null = null; + let controlsHandle: ControlsHandle | null = null; + let baselinePanelHandle: BaselinePanelHandle | null = null; + let pendingChartRAF: number | null = null; + let chartRenderGen = 0; + let suiteGeneration = 0; + let plotGeneration = 0; + let selectionAbort: AbortController | null = null; + const machineAborts = new Map<string, AbortController>(); + let globalAbort = new AbortController(); + let commitFields: Array<{ name: string; display?: boolean }> = []; + let currentDisplayMap = new Map<string, string>(); + /** Cached scaffold union — only recomputed when machines or scaffolds change. */ + let cachedCategoryOrder: string[] | undefined; + /** Cached color map — only recomputed when allDiscoveredTests changes. */ + let cachedColorMap = new Map<string, string>(); + let cleanupTableHover: (() => void) | null = null; + let cleanupChartHover: (() => void) | null = null; + let cleanupChartDblClick: (() => void) | null = null; + + function getSignal(): AbortSignal { return globalAbort.signal; } + + function getMachineSignal(machine: string): AbortSignal { + let ctrl = machineAborts.get(machine); + if (!ctrl || ctrl.signal.aborted) { + ctrl = new AbortController(); + machineAborts.set(machine, ctrl); + } + return ctrl.signal; + } + + function abortMachine(machine: string): void { + const ctrl = machineAborts.get(machine); + if (ctrl) { ctrl.abort(); machineAborts.delete(machine); } + } + + function abortInFlight(): void { + for (const [, ctrl] of machineAborts) ctrl.abort(); + machineAborts.clear(); + if (selectionAbort) { selectionAbort.abort(); selectionAbort = null; } + } + + function abortAll(): void { + globalAbort.abort(); + globalAbort = new AbortController(); + abortInFlight(); + } + + // ---- URL state management ---- + function updateUrlState(): void { + replaceGraphUrl({ + suite: currentSuite, + machines, + metric, + testFilter, + runAgg, + sampleAgg, + baselines, + regressionMode, + }); + } + + // ---- Baseline display resolution ---- + + function combinedDisplayMap(): Map<string, string> { + if (baselineResolvedMap.size === 0) return currentDisplayMap; + const merged = new Map(currentDisplayMap); + for (const [k, v] of baselineResolvedMap) merged.set(k, v); + return merged; + } + + async function resolveBaselineDisplayValues(): Promise<void> { + if (baselines.length === 0) return; + const gen = suiteGeneration; + const known = combinedDisplayMap(); + const bySuite = new Map<string, string[]>(); + for (const bl of baselines) { + if (known.has(bl.commit)) continue; + const list = bySuite.get(bl.suite) ?? []; + list.push(bl.commit); + bySuite.set(bl.suite, list); + } + if (bySuite.size === 0) { + baselinePanelHandle?.updateChips(baselines, known); + return; + } + const results = await Promise.all( + [...bySuite.entries()].map(([suite, commits]) => + resolveDisplayMap(suite, commits, getSignal()) + ), + ); + if (gen !== suiteGeneration) return; + for (const dm of results) { + for (const [k, v] of dm) baselineResolvedMap.set(k, v); + } + baselinePanelHandle?.updateChips(baselines, combinedDisplayMap()); + } + + // ---- DOM skeleton ---- + container.append(el('h2', { class: 'page-header' }, 'Graph')); + const errorBanner = el('div', { class: 'error-banner', style: 'display: none' }); + const progressEl = el('p', { class: 'progress-label', style: 'display: none' }, + 'Discovering tests...'); + const chartContainer = el('div', {}, + el('p', { class: 'no-chart-data' }, 'No data to plot.'), + ); + const tableContainer = el('div', { class: 'test-table-container' }); + + // ---- Controls ---- + const suites = getTestsuites(); + controlsHandle = createControls(state, suites, { + onSuiteChange: handleSuiteChange, + onMachineAdd: handleMachineAdd, + onMachineRemove: handleMachineRemove, + onMetricChange: handleMetricChange, + onFilterChange: handleFilterChange, + onRunAggChange(agg: AggFn) { + runAgg = agg; + renderFromSelection(); + updateUrlState(); + }, + onSampleAggChange(agg: AggFn) { + sampleAgg = agg; + renderFromSelection(); + updateUrlState(); + }, + onRegressionModeChange(mode: RegressionAnnotationMode) { + regressionMode = mode; + handleRegressionModeChange(); + updateUrlState(); + }, + }); + container.append(controlsHandle.getElement()); + + // ---- Baseline panel (embedded in controls row 1) ---- + baselinePanelHandle = createBaselinePanel(baselines, combinedDisplayMap(), suites, { + onBaselineAdd: handleBaselineAdd, + onBaselineRemove: handleBaselineRemove, + getCommitFields: (suite: string) => { + return suite === currentSuite ? commitFields : []; + }, + getBaselineCommits: (suite, machine, signal) => + cache.getBaselineCommits(suite, machine, signal), + }); + controlsHandle.embedInRow1(baselinePanelHandle.getElement()); + + if (baselines.length > 0) { + resolveBaselineDisplayValues().catch(() => {}); + } + + container.append(errorBanner, progressEl, chartContainer, tableContainer); + + // ---- Hover sync ---- + cleanupTableHover = onCustomEvent<string | null>(GRAPH_TABLE_HOVER, (testName) => { + if (!chartHandle) return; + if (!testName) { + chartHandle.hoverTrace(null); + return; + } + // Highlight all machines' traces for this test + const traceNames = machines.map(m => `${testName}${TRACE_SEP}${m}`); + chartHandle.hoverTrace(traceNames); + }); + + cleanupChartHover = onCustomEvent<string | null>(GRAPH_CHART_HOVER, (traceName) => { + if (!tableHandle) return; + const testName = traceName ? testNameFromTrace(traceName) : null; + tableHandle.highlightRow(testName); + }); + + cleanupChartDblClick = onCustomEvent<string>(GRAPH_CHART_DBLCLICK, (testName) => { + if (!testName) return; + handleSelectionChange(new Set([testName])); + }); + + // ---- Data pipeline functions ---- + + /** Full reconfigure: fetch scaffolds, discover tests, populate table. */ + async function doPlot(): Promise<void> { + if (!currentSuite || machines.length === 0 || !metric) return; + + plotGeneration++; + abortInFlight(); + progressEl.style.display = 'none'; + + const gen = plotGeneration; + + try { + // 1. Fetch scaffolds for all machines in parallel + await Promise.all(machines.map(m => + cache.getScaffold(currentSuite, m, getMachineSignal(m)), + )); + if (gen !== plotGeneration) return; + + progressEl.style.display = ''; + + // 2. Discover tests for ALL machines in parallel, then union + const perMachine = await Promise.all(machines.map(m => + cache.discoverTests(currentSuite, m, metric, getMachineSignal(m)), + )); + if (gen !== plotGeneration) return; + progressEl.style.display = 'none'; + + // Union all test lists, sorted alphabetically + const testSet = new Set<string>(); + for (const list of perMachine) { + for (const name of list) testSet.add(name); + } + allDiscoveredTests = [...testSet].sort((a, b) => a.localeCompare(b)); + + // Cache color map (stable: only changes when allDiscoveredTests changes) + cachedColorMap = buildColorMap(allDiscoveredTests); + + // Cache scaffold union (only changes when machines or scaffolds change) + const union = cache.scaffoldUnion(currentSuite, machines, commitFields); + if (union) { + currentDisplayMap = union.displayMap; + cachedCategoryOrder = union.commits; + } else { + cachedCategoryOrder = undefined; + } + + // Refresh baseline chips with scaffold-derived display values + if (baselines.length > 0) { + baselinePanelHandle?.updateChips(baselines, combinedDisplayMap()); + } + + // Apply filter + applyFilter(); + + // Restore previous selections that are still valid + const validSelected = new Set<string>(); + for (const t of selectedTests) { + if (testSet.has(t)) validSelected.add(t); + } + selectedTests = validSelected; + + // Render table and chart + renderFromSelection(); + + // Fetch data for selected tests + if (selectedTests.size > 0) { + await handleSelectionChange(selectedTests); + } + } catch (e) { + progressEl.style.display = 'none'; + if (e instanceof DOMException && e.name === 'AbortError') return; + showError(`Failed to load data: ${e instanceof Error ? e.message : String(e)}`); + } + } + + function applyFilter(): void { + if (testFilter) { + allMatchingTests = allDiscoveredTests.filter(t => matchesFilter(t, testFilter)); + } else { + allMatchingTests = [...allDiscoveredTests]; + } + } + + function handleFilterChange(filter: string): void { + testFilter = filter; + updateUrlState(); + if (machines.length === 0 || !metric) return; + + applyFilter(); + + // Prune selection to matching tests + const matchingSet = new Set(allMatchingTests); + const newSelected = new Set<string>(); + for (const t of selectedTests) { + if (matchingSet.has(t)) newSelected.add(t); + } + selectedTests = newSelected; + + // Fast path: toggle display:none on existing rows, don't rebuild DOM + if (tableHandle) { + tableHandle.setFilter(testFilter); + } + + scheduleChartUpdate(); + } + + async function handleSelectionChange(newSelected: Set<string>): Promise<void> { + if (selectionAbort) selectionAbort.abort(); + selectionAbort = new AbortController(); + const selSignal = selectionAbort.signal; + loadingTests = new Set(); + + selectedTests = newSelected; + const gen = plotGeneration; + + // Identify uncached tests (check all machines, not just the first) + const uncached: string[] = []; + for (const t of selectedTests) { + if (machines.some(m => !cache.isComplete(currentSuite, m, metric, t))) { + uncached.push(t); + } + } + + // Always rebuild the table so checkboxes reflect the new selection, + // even when every selected test is already cached. + renderFromSelection(); + + if (uncached.length > 0) { + for (const t of uncached) loadingTests.add(t); + renderFromSelection(); + + try { + // Fetch data for each machine in parallel + await Promise.all(machines.map(m => + cache.ensureTestData(currentSuite, m, metric, uncached, { + signal: selSignal, + onProgress: () => scheduleChartUpdate(), + }), + )); + if (gen !== plotGeneration || selSignal.aborted) return; + + for (const t of uncached) loadingTests.delete(t); + + // Fetch baseline data for newly-selected tests (in parallel) + await Promise.all(baselines.map(bl => + cache.getBaselineData(bl.suite, bl.machine, bl.commit, metric, uncached, selSignal), + )); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; + loadingTests = new Set(); + } + + if (gen !== plotGeneration || selSignal.aborted) return; + renderFromSelection(); + } + + scheduleChartUpdate(); + } + + /** RAF-batched chart render. Does NOT rebuild the table. */ + function scheduleChartUpdate(): void { + chartRenderGen++; + const gen = chartRenderGen; + if (pendingChartRAF !== null) cancelAnimationFrame(pendingChartRAF); + + pendingChartRAF = requestAnimationFrame(() => { + pendingChartRAF = null; + if (gen !== chartRenderGen) return; // stale + if (!currentSuite || !metric) return; + + // Build traces from cache + const { traces, rawValuesIndex } = buildChartData({ + selectedTests, + machines, + metric, + runAgg, + sampleAgg, + readCachedTestData: (s, m, met, t) => cache.readCachedTestData(s, m, met, t), + suite: currentSuite, + colorMap: cachedColorMap, + }); + + // Build baselines + const pinnedBaselines = buildBaselinesFromData( + baselines, + (s, m, c, met) => cache.readCachedBaselineData(s, m, c, met), + metric, + getAggFn(runAgg), + combinedDisplayMap(), ); + + // Build regression overlays + let overlays = undefined; + if (regressionMode !== 'off') { + const regs = cache.readCachedRegressions(currentSuite, regressionMode === 'active' ? 'active' : 'all'); + if (regs) { + overlays = buildRegressionOverlays(regs, currentDisplayMap); + } + } + + // Use cached scaffold (computed in doPlot, doesn't change during loading) + const chartOpts = { + traces, + yAxisLabel: metric, + baselines: pinnedBaselines.length > 0 ? pinnedBaselines : undefined, + categoryOrder: cachedCategoryOrder, + displayMap: currentDisplayMap.size > 0 ? currentDisplayMap : undefined, + getRawValues: buildRawValuesCallback(rawValuesIndex), + overlays, + }; + + if (chartHandle) { + chartHandle.update(chartOpts); + } else { + chartHandle = createTimeSeriesChart(chartContainer, chartOpts); + } + }); + } + + /** Rebuild table + schedule chart update. */ + function renderFromSelection(): void { + const entries: TestSelectionEntry[] = allDiscoveredTests.map(testName => ({ + testName, + selected: selectedTests.has(testName), + color: cachedColorMap.get(testName), + symbolChar: selectedTests.has(testName) ? assignSymbolChar(0) : undefined, + loading: loadingTests.has(testName), + })); + + const matchingCount = allMatchingTests.length; + const selCount = selectedTests.size; + const loadingCount = loadingTests.size; + let message = `${selCount} of ${matchingCount} tests selected`; + if (loadingCount > 0) message += ', loading...'; + + if (tableHandle) { + tableHandle.update(entries, message); + } else { + tableHandle = createTestSelectionTable(tableContainer, { + entries, + onSelectionChange(selected: Set<string>) { + handleSelectionChange(selected); + }, + message, + }); + } + + scheduleChartUpdate(); + } + + // ---- Event handlers ---- + + function handleSuiteChange(suite: string): void { + suiteGeneration++; + abortAll(); + cache.clearSuite(); + + currentSuite = suite; + machines = []; + metric = ''; + testFilter = ''; + allDiscoveredTests = []; + allMatchingTests = []; + selectedTests = new Set(); + loadingTests = new Set(); + commitFields = []; + currentDisplayMap = new Map(); + baselineResolvedMap = new Map(); + regressionMode = 'off'; + + if (chartHandle) { chartHandle.destroy(); chartHandle = null; } + if (tableHandle) { tableHandle.destroy(); tableHandle = null; } + chartContainer.replaceChildren(el('p', { class: 'no-chart-data' }, 'No data to plot.')); + tableContainer.replaceChildren(); + progressEl.style.display = 'none'; + + controlsHandle?.setSuite(suite); + controlsHandle?.updateMachineChips([]); + controlsHandle?.setEnabled(!!suite); + controlsHandle?.setRegressionMode('off'); + baselinePanelHandle?.reset(); + + if (suite) { + loadSuiteFields(suite); + } + + updateUrlState(); + } + + function handleMachineAdd(name: string): void { + if (machines.includes(name)) return; + machines.push(name); + controlsHandle?.updateMachineChips(machines); + updateUrlState(); + if (metric) doPlot(); + } + + function handleMachineRemove(name: string): void { + abortMachine(name); + machines = machines.filter(m => m !== name); + controlsHandle?.updateMachineChips(machines); + updateUrlState(); + if (machines.length > 0 && metric) { + doPlot(); + } else { + progressEl.style.display = 'none'; + renderFromSelection(); + } + } + + function handleMetricChange(newMetric: string): void { + metric = newMetric; + updateUrlState(); + // Clear test data when metric changes (different metric, different data) + allDiscoveredTests = []; + allMatchingTests = []; + selectedTests = new Set(); + loadingTests = new Set(); + if (tableHandle) { tableHandle.destroy(); tableHandle = null; } + tableContainer.replaceChildren(); + progressEl.style.display = 'none'; + if (machines.length > 0 && metric) doPlot(); + } + + function handleBaselineAdd(bl: BaselineRef): void { + if (baselines.some(b => b.suite === bl.suite && b.machine === bl.machine && b.commit === bl.commit)) return; + baselines.push(bl); + baselinePanelHandle?.updateChips(baselines, combinedDisplayMap()); + updateUrlState(); + + resolveBaselineDisplayValues().catch(() => {}); + + // Fetch baseline data for current selection + if (metric && selectedTests.size > 0) { + cache.getBaselineData(bl.suite, bl.machine, bl.commit, metric, [...selectedTests], getSignal()) + .then(() => scheduleChartUpdate()) + .catch(e => { + if (e instanceof DOMException && e.name === 'AbortError') return; + }); + } + } + + function handleBaselineRemove(bl: BaselineRef): void { + const idx = baselines.findIndex(b => b.suite === bl.suite && b.machine === bl.machine && b.commit === bl.commit); + if (idx >= 0) baselines.splice(idx, 1); + baselinePanelHandle?.updateChips(baselines, combinedDisplayMap()); + updateUrlState(); + scheduleChartUpdate(); + } + + async function handleRegressionModeChange(): Promise<void> { + if (regressionMode === 'off') { + scheduleChartUpdate(); + return; + } + if (!currentSuite) return; + const gen = suiteGeneration; + try { + const mode = regressionMode === 'active' ? 'active' as const : 'all' as const; + await cache.getRegressions(currentSuite, mode, getSignal()); + if (gen !== suiteGeneration) return; + scheduleChartUpdate(); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; + // Silently ignore regression fetch failures + } + } + + async function loadSuiteFields(suite: string): Promise<void> { + const gen = suiteGeneration; + try { + const info = await getTestSuiteInfoCached(suite, getSignal()); + if (gen !== suiteGeneration) return; + commitFields = info.schema.commit_fields || []; + controlsHandle?.updateMetricSelector(info.schema.metrics, metric); + controlsHandle?.setEnabled(true); + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') return; + showError('Failed to load suite fields'); + } + } + + function showError(msg: string): void { + errorBanner.textContent = msg; + errorBanner.style.display = ''; + setTimeout(() => { errorBanner.style.display = 'none'; }, 5000); + } + + // ---- Initial load ---- + if (currentSuite) { + loadSuiteFields(currentSuite).then(() => { + if (machines.length > 0 && metric) doPlot(); + }); + } + + // ---- Store cleanup for unmount ---- + cleanupFn = () => { + abortAll(); + if (chartHandle) { chartHandle.destroy(); chartHandle = null; } + if (tableHandle) { tableHandle.destroy(); tableHandle = null; } + if (controlsHandle) { controlsHandle.destroy(); controlsHandle = null; } + if (baselinePanelHandle) { baselinePanelHandle.destroy(); baselinePanelHandle = null; } + if (cleanupTableHover) { cleanupTableHover(); cleanupTableHover = null; } + if (cleanupChartHover) { cleanupChartHover(); cleanupChartHover = null; } + if (cleanupChartDblClick) { cleanupChartDblClick(); cleanupChartDblClick = null; } + if (pendingChartRAF !== null) { cancelAnimationFrame(pendingChartRAF); pendingChartRAF = null; } + loadingTests = new Set(); + // Preserve: cache, allDiscoveredTests, allMatchingTests, selectedTests, machines, metric, currentSuite + }; + }, + + unmount(): void { + if (cleanupFn) { cleanupFn(); cleanupFn = null; } + }, +}; diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/state.ts b/lnt/server/ui/v5/frontend/src/pages/graph/state.ts new file mode 100644 index 000000000..b6eb6fd44 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/state.ts @@ -0,0 +1,99 @@ +// pages/graph/state.ts — URL state management for the Graph page. +// Pure encode/decode functions for the URL query string. + +import type { AggFn } from '../../types'; + +/** A pinned baseline reference (suite, machine, commit). */ +export interface BaselineRef { + suite: string; + machine: string; + commit: string; +} + +/** Regression annotation display mode. */ +export type RegressionAnnotationMode = 'off' | 'active' | 'all'; + +/** Complete URL-reflected state for the Graph page. */ +export interface GraphState { + suite: string; + machines: string[]; + metric: string; + testFilter: string; + runAgg: AggFn; + sampleAgg: AggFn; + baselines: BaselineRef[]; + regressionMode: RegressionAnnotationMode; +} + +const VALID_AGG: AggFn[] = ['median', 'mean', 'min', 'max']; +const VALID_REG_MODE: RegressionAnnotationMode[] = ['off', 'active', 'all']; +const DEFAULT_AGG: AggFn = 'median'; +const DEFAULT_REG_MODE: RegressionAnnotationMode = 'off'; +const BASELINE_SEP = '::'; + +function parseAgg(value: string | null): AggFn { + if (value && VALID_AGG.includes(value as AggFn)) return value as AggFn; + return DEFAULT_AGG; +} + +function parseRegMode(value: string | null): RegressionAnnotationMode { + if (value && VALID_REG_MODE.includes(value as RegressionAnnotationMode)) { + return value as RegressionAnnotationMode; + } + return DEFAULT_REG_MODE; +} + +function parseBaseline(encoded: string): BaselineRef | null { + const parts = encoded.split(BASELINE_SEP); + if (parts.length !== 3) return null; + const [suite, machine, commit] = parts; + if (!suite || !machine || !commit) return null; + return { suite, machine, commit }; +} + +function encodeBaseline(b: BaselineRef): string { + return `${b.suite}${BASELINE_SEP}${b.machine}${BASELINE_SEP}${b.commit}`; +} + +/** Decode URL search string into typed GraphState. */ +export function decodeGraphState(search: string): GraphState { + const params = new URLSearchParams(search); + return { + suite: params.get('suite') || '', + machines: params.getAll('machine').filter(m => m.length > 0), + metric: params.get('metric') || '', + testFilter: params.get('test_filter') || '', + runAgg: parseAgg(params.get('run_agg')), + sampleAgg: parseAgg(params.get('sample_agg')), + baselines: params.getAll('baseline') + .map(parseBaseline) + .filter((b): b is BaselineRef => b !== null), + regressionMode: parseRegMode(params.get('regressions')), + }; +} + +/** Encode GraphState to URL search string. Omits default values. */ +export function encodeGraphState(state: GraphState): string { + const params = new URLSearchParams(); + + if (state.suite) params.set('suite', state.suite); + for (const m of state.machines) params.append('machine', m); + if (state.metric) params.set('metric', state.metric); + if (state.testFilter) params.set('test_filter', state.testFilter); + if (state.runAgg !== DEFAULT_AGG) params.set('run_agg', state.runAgg); + if (state.sampleAgg !== DEFAULT_AGG) params.set('sample_agg', state.sampleAgg); + for (const b of state.baselines) params.append('baseline', encodeBaseline(b)); + if (state.regressionMode !== DEFAULT_REG_MODE) { + params.set('regressions', state.regressionMode); + } + + const str = params.toString(); + return str ? `?${str}` : ''; +} + +/** Update the browser URL with the encoded state (no navigation). */ +export function replaceGraphUrl(state: GraphState): void { + const search = encodeGraphState(state); + const url = window.location.pathname + search; + window.history.replaceState(null, '', url); +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/test-selection-table.ts b/lnt/server/ui/v5/frontend/src/pages/graph/test-selection-table.ts new file mode 100644 index 000000000..c763bcad6 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/test-selection-table.ts @@ -0,0 +1,303 @@ +// pages/graph/test-selection-table.ts — Test selection table for the graph page. +// Shows all matching tests with checkboxes for explicit plot selection. + +import { el, DOUBLE_CLICK_DELAY_MS, matchesFilter } from '../../utils'; +import { GRAPH_TABLE_HOVER } from '../../events'; + +export interface TestSelectionEntry { + testName: string; + selected: boolean; + /** Color assigned to this test (only set when selected). */ + color?: string; + /** Unicode marker symbol character (e.g., '●') — only shown when selected. */ + symbolChar?: string; + /** Whether data is currently loading for this test. */ + loading?: boolean; +} + +export interface TestSelectionTableOptions { + entries: TestSelectionEntry[]; + /** Called when the selection changes. Receives the full new selection set. */ + onSelectionChange: (selected: Set<string>) => void; + /** Optional message shown above the table rows. */ + message?: string; +} + +export interface TestSelectionTableHandle { + /** Re-render the table with new entries and optional message. */ + update(entries: TestSelectionEntry[], message?: string): void; + /** Apply a text filter, toggling display:none on rows. Fast path — no DOM rebuild. */ + setFilter(filter: string): void; + /** Highlight (or un-highlight) a row by test name. */ + highlightRow(testName: string | null): void; + /** Remove the table and clean up listeners. */ + destroy(): void; +} + +/** + * Create a test selection table. + * + * One row per test name with a checkbox for selection. Click to toggle, + * shift-click for range selection, double-click to isolate/restore. + * Hover dispatches GRAPH_TABLE_HOVER with the bare test name. + */ +export function createTestSelectionTable( + container: HTMLElement, + options: TestSelectionTableOptions, +): TestSelectionTableHandle { + const wrapper = el('div', {}); + const messageEl = el('div', { class: 'test-selection-message' }); + const table = el('table', { class: 'test-selection-table' }); + + // Header with "check all" checkbox + const thead = el('thead'); + const headerRow = el('tr'); + const headerCbCell = el('th', { class: 'sel-checkbox-cell' }); + const headerCb = el('input', { type: 'checkbox' }) as HTMLInputElement; + headerCbCell.append(headerCb); + headerRow.append(headerCbCell, el('th'), el('th')); + thead.append(headerRow); + + const tbody = el('tbody'); + table.append(thead, tbody); + wrapper.append(messageEl, table); + container.append(wrapper); + + let currentEntries: TestSelectionEntry[] = options.entries; + let currentOnSelectionChange = options.onSelectionChange; + /** Last-clicked test name (not index) — survives update() rebuilds. */ + let lastClickedTest: string | null = null; + /** Map of rendered rows for display:none fast path. */ + let renderedRowMap: Map<string, HTMLTableRowElement> = new Map(); + /** Current filter string for display:none toggling. */ + let currentFilter = ''; + + function currentSelection(): Set<string> { + const sel = new Set<string>(); + for (const e of currentEntries) { + if (e.selected) sel.add(e.testName); + } + return sel; + } + + function visibleEntryNames(): Set<string> { + const names = new Set<string>(); + for (const e of currentEntries) { + if (!currentFilter || matchesFilter(e.testName, currentFilter)) { + names.add(e.testName); + } + } + return names; + } + + function setMessage(msg?: string): void { + if (msg) { + messageEl.textContent = msg; + messageEl.style.display = ''; + } else { + messageEl.textContent = ''; + messageEl.style.display = 'none'; + } + } + + function buildRows(entries: TestSelectionEntry[]): void { + tbody.replaceChildren(); + renderedRowMap.clear(); + for (const entry of entries) { + const tr = el('tr', { 'data-test': entry.testName }); + if (entry.selected) tr.classList.add('row-selected'); + if (entry.loading) tr.classList.add('row-loading'); + + // Checkbox cell + const cbCell = el('td', { class: 'sel-checkbox-cell' }); + const cb = el('input', { type: 'checkbox' }) as HTMLInputElement; + cb.checked = entry.selected; + if (entry.loading) cb.disabled = true; + cbCell.append(cb); + + // Symbol cell — colored marker when selected, empty otherwise + const symbolCell = el('td', { class: 'sel-symbol-cell' }); + if (entry.selected && entry.color) { + const symbolSpan = el('span', { class: 'legend-symbol' }, entry.symbolChar || '●'); + (symbolSpan as HTMLElement).style.color = entry.color; + symbolCell.append(symbolSpan); + } + + // Test name cell + const nameCell = el('td', { class: 'sel-test-name' }, entry.testName); + + tr.append(cbCell, symbolCell, nameCell); + renderedRowMap.set(entry.testName, tr); + tbody.append(tr); + } + applyCurrentFilter(); + } + + function applyCurrentFilter(): void { + let visibleTotal = 0; + let visibleSelected = 0; + + for (const entry of currentEntries) { + const tr = renderedRowMap.get(entry.testName); + if (!tr) continue; + + const visible = !currentFilter || matchesFilter(entry.testName, currentFilter); + tr.style.display = visible ? '' : 'none'; + + if (visible) { + visibleTotal++; + const cb = tr.querySelector('input[type="checkbox"]') as HTMLInputElement | null; + if (cb) cb.checked = entry.selected; + if (entry.selected) visibleSelected++; + } + } + + headerCb.checked = visibleTotal > 0 && visibleSelected === visibleTotal; + headerCb.indeterminate = visibleSelected > 0 && visibleSelected < visibleTotal; + } + + buildRows(currentEntries); + setMessage(options.message); + + // --- Header "check all" checkbox --- + headerCb.addEventListener('click', () => { + const visible = visibleEntryNames(); + const allVisibleSelected = visible.size > 0 && + [...visible].every(name => currentEntries.find(e => e.testName === name)?.selected); + if (allVisibleSelected) { + currentOnSelectionChange(new Set()); + } else { + const sel = currentSelection(); + for (const name of visible) sel.add(name); + currentOnSelectionChange(sel); + } + }); + + // --- Interaction: click, shift-click, double-click --- + // Single-click uses a 200ms delay to distinguish from double-click. + // Shift-click bypasses the delay (modifier key makes intent unambiguous). + let clickTimer: ReturnType<typeof setTimeout> | null = null; + + function findRowIndex(testName: string): number { + return currentEntries.findIndex(e => e.testName === testName); + } + + tbody.addEventListener('click', (e) => { + const target = e.target as HTMLElement; + // Undo native checkbox toggle — selection is driven by data, not DOM state. + // The table rebuild after onSelectionChange will set the correct checked state. + if (target instanceof HTMLInputElement && target.type === 'checkbox') { + target.checked = !target.checked; + } + const tr = target.closest('tr[data-test]'); + if (!tr) return; + const testName = tr.getAttribute('data-test')!; + + if (e.shiftKey && lastClickedTest !== null) { + // Shift-click: immediate range selection (no delay) + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + + const fromIdx = findRowIndex(lastClickedTest); + const toIdx = findRowIndex(testName); + + if (fromIdx < 0) { + // lastClickedTest no longer in entries — treat as normal click + lastClickedTest = testName; + const sel = currentSelection(); + if (sel.has(testName)) { sel.delete(testName); } else { sel.add(testName); } + currentOnSelectionChange(sel); + return; + } + + const lo = Math.min(fromIdx, toIdx); + const hi = Math.max(fromIdx, toIdx); + const sel = currentSelection(); + for (let i = lo; i <= hi; i++) { + sel.add(currentEntries[i].testName); + } + lastClickedTest = testName; + currentOnSelectionChange(sel); + return; + } + + // Normal click: 200ms delay to distinguish from double-click + if (clickTimer) clearTimeout(clickTimer); + clickTimer = setTimeout(() => { + clickTimer = null; + lastClickedTest = testName; + const sel = currentSelection(); + if (sel.has(testName)) { sel.delete(testName); } else { sel.add(testName); } + currentOnSelectionChange(sel); + }, DOUBLE_CLICK_DELAY_MS); + }); + + tbody.addEventListener('dblclick', (e) => { + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (!tr) return; + const testName = tr.getAttribute('data-test')!; + lastClickedTest = testName; + + const sel = currentSelection(); + if (sel.size === 1 && sel.has(testName)) { + // Already isolated — restore all visible tests + const allSel = visibleEntryNames(); + currentOnSelectionChange(allSel); + } else { + // Isolate: select only this test + currentOnSelectionChange(new Set([testName])); + } + }); + + // --- Hover delegation --- + tbody.addEventListener('mouseenter', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (tr) { + document.dispatchEvent(new CustomEvent(GRAPH_TABLE_HOVER, { + detail: tr.getAttribute('data-test'), + })); + } + }, true); + + tbody.addEventListener('mouseleave', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (tr) { + document.dispatchEvent(new CustomEvent(GRAPH_TABLE_HOVER, { + detail: null, + })); + } + }, true); + + return { + update(entries: TestSelectionEntry[], message?: string): void { + currentEntries = entries; + if (lastClickedTest !== null && !entries.some(e => e.testName === lastClickedTest)) { + lastClickedTest = null; + } + buildRows(entries); + setMessage(message); + }, + + setFilter(filter: string): void { + currentFilter = filter; + applyCurrentFilter(); + }, + + highlightRow(testName: string | null): void { + const prev = tbody.querySelectorAll('.row-highlighted'); + for (const el of prev) el.classList.remove('row-highlighted'); + + if (testName) { + const row = tbody.querySelector(`tr[data-test="${CSS.escape(testName)}"]`); + if (row) row.classList.add('row-highlighted'); + } + }, + + destroy(): void { + if (clickTimer) { clearTimeout(clickTimer); clickTimer = null; } + renderedRowMap.clear(); + currentFilter = ''; + wrapper.remove(); + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/time-series-chart.ts b/lnt/server/ui/v5/frontend/src/pages/graph/time-series-chart.ts new file mode 100644 index 000000000..8b71e44b3 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/time-series-chart.ts @@ -0,0 +1,517 @@ +// pages/graph/time-series-chart.ts — Plotly time-series line chart. + +import { el, TRACE_SEP, DOUBLE_CLICK_DELAY_MS } from '../../utils'; +import { GRAPH_CHART_HOVER, GRAPH_CHART_DBLCLICK } from '../../events'; + +/** Escape HTML special characters to prevent XSS in Plotly hover templates. */ +function escapeHtml(s: string): string { + return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'); +} + +declare const Plotly: { + newPlot(el: HTMLElement, data: unknown[], layout: unknown, config?: unknown): Promise<HTMLElement>; + react(el: HTMLElement, data: unknown[], layout: unknown, config?: unknown): Promise<HTMLElement>; + addTraces(el: HTMLElement, traces: unknown | unknown[], index?: number): void; + deleteTraces(el: HTMLElement, indices: number | number[]): void; + relayout(el: HTMLElement, update: Record<string, unknown>): void; + restyle(el: HTMLElement, update: Record<string, unknown>, traces?: number | number[]): void; + purge(el: HTMLElement): void; + Fx: { + hover(gd: HTMLElement, data: Array<{ curveNumber: number; pointNumber: number }>): void; + unhover(gd: HTMLElement): void; + }; +}; + +export interface TimeSeriesTrace { + testName: string; + /** Machine name for this trace. */ + machine: string; + /** Explicit color for the trace line and markers. */ + color?: string; + /** Plotly marker symbol (e.g., 'circle', 'triangle-up', 'square'). */ + markerSymbol?: string; + points: Array<{ + commit: string; + value: number; + runCount: number; + submitted_at: string | null; + }>; +} + +export interface PinnedBaseline { + /** Display label, e.g. "libstdc++/gcc-x86/v13.2". */ + label: string; + /** Per-test values at this baseline. */ + values: Map<string, number>; +} + +export interface TimeSeriesChartOptions { + traces: TimeSeriesTrace[]; + yAxisLabel: string; + baselines?: PinnedBaseline[]; + onClick?: (commit: string) => void; + /** Fixed x-axis category order. When set, the x-axis shows exactly these + * categories in this order and does not resize as data loads progressively. */ + categoryOrder?: string[]; + /** Map from raw commit string to display value (e.g. short SHA). + * When set, axis tick labels and hover tooltips show the display value + * while internal coordinates remain raw commit strings. */ + displayMap?: Map<string, string>; + /** Lazy callback to get individual pre-aggregation values for a data point. + * Called on hover; if it returns >1 values, a scatter of the raw values + * is shown at the hovered x-position. */ + getRawValues?: (testName: string, machine: string, commit: string) => number[]; + /** Additional Plotly shapes and annotations to overlay on the chart + * (e.g. regression markers). Merged into layout on each update. */ + overlays?: ChartOverlays; +} + +/** Extra Plotly layout elements to overlay on the chart. */ +export interface ChartOverlays { + shapes?: unknown[]; + annotations?: unknown[]; +} + +/** + * Build Plotly trace objects and layout from our domain types. + * Exported for testing (data preparation logic). + */ +export function buildPlotlyData(options: TimeSeriesChartOptions): { + data: unknown[]; + layout: unknown; +} { + const data: unknown[] = []; + + // When a display map is available, map raw commit strings to display + // values (e.g. short SHAs) for all categorical x-coordinates. This + // lets Plotly's auto-tick-density work naturally on the display values. + const dm = options.displayMap; + const dx = dm && dm.size > 0 ? (c: string) => dm.get(c) ?? c : (c: string) => c; + + // Collect all unique commit values across all traces (for consistent x-axis) + const allCommits: string[] = []; + const commitSet = new Set<string>(); + for (const trace of options.traces) { + for (const pt of trace.points) { + const mapped = dx(pt.commit); + if (!commitSet.has(mapped)) { + commitSet.add(mapped); + allCommits.push(mapped); + } + } + } + + for (const trace of options.traces) { + const x = trace.points.map(p => dx(p.commit)); + const y = trace.points.map(p => p.value); + const traceName = `${trace.testName}${TRACE_SEP}${trace.machine}`; + const customdata = trace.points.map(p => [ + p.commit, + traceName, + p.value.toPrecision(4), + String(p.runCount), + trace.testName, + trace.machine, + dx(p.commit), + ]); + + const marker: Record<string, unknown> = { size: 4 }; + if (trace.color) marker.color = trace.color; + if (trace.markerSymbol) marker.symbol = trace.markerSymbol; + + const traceObj: Record<string, unknown> = { + x, + y, + name: traceName, + mode: 'lines+markers', + type: 'scatter', + marker, + line: { width: 1.5, ...(trace.color ? { color: trace.color } : {}) }, + customdata, + hovertemplate: + '<b>%{customdata[4]}</b><br>' + + 'Machine: %{customdata[5]}<br>' + + 'Commit: %{customdata[6]}<br>' + + 'Value: %{customdata[2]}<br>' + + 'Runs: %{customdata[3]}<extra></extra>', + }; + + data.push(traceObj); + } + + // Baseline traces (horizontal dashed lines with hover tooltips). + // These are actual Plotly traces (not shapes) so they support hover. + // Each trace is populated with a data point at every x-category so that + // hover detection works anywhere along the line (not just at 2 endpoints). + if (options.baselines) { + const pinXValues = options.categoryOrder + ? options.categoryOrder.map(dx) + : (allCommits.length > 0 ? allCommits : null); + + if (pinXValues) { + for (const ref of options.baselines) { + for (const [testName, value] of ref.values) { + const trace = options.traces.find(t => t.testName === testName); + if (!trace || trace.points.length === 0) continue; + + data.push({ + x: pinXValues, + y: Array(pinXValues.length).fill(value), + mode: 'lines', + type: 'scatter', + line: { color: trace.color || '#999', width: 1.5, dash: 'dot' }, + showlegend: false, + hovertemplate: + `<b>Baseline: ${escapeHtml(ref.label)}</b><br>` + + `Test: ${escapeHtml(testName)}<br>` + + `Value: ${value.toPrecision(4)}<extra></extra>`, + }); + } + } + } + } + + const xaxis: Record<string, unknown> = { + type: 'category', + title: 'Commit', + tickangle: -45, + automargin: true, + }; + if (options.categoryOrder) { + xaxis.categoryorder = 'array'; + xaxis.categoryarray = options.categoryOrder.map(dx); + // Lock the visible range to show all categories — autorange ignores null + // y-values in the scaffold trace, so we must set the range explicitly. + xaxis.autorange = false; + xaxis.range = [-0.5, options.categoryOrder.length - 0.5]; + } + + // Overlay "No data to plot" when chart has a scaffold but no actual traces + const annotations: unknown[] = []; + if (options.traces.length === 0 && options.categoryOrder) { + annotations.push({ + text: 'No data to plot.', + xref: 'paper', + yref: 'paper', + x: 0.5, + y: 0.5, + showarrow: false, + font: { size: 16, color: '#999' }, + }); + } + + const layout = { + xaxis, + yaxis: { + title: options.yAxisLabel, + automargin: true, + }, + annotations: [ + ...annotations, + ...(options.overlays?.annotations ?? []), + ], + shapes: options.overlays?.shapes ?? [], + margin: { t: 30, r: 20 }, + hovermode: 'closest' as const, + hoverdistance: 5, + showlegend: false, + autosize: true, + }; + + return { data, layout }; +} + +/** + * Handle returned by createTimeSeriesChart for incremental updates. + */ +export interface ChartHandle { + /** Update the chart with new options using Plotly.react() (preserves zoom/pan). */ + update(options: TimeSeriesChartOptions): void; + /** Programmatically highlight traces by name '{test} · {machine}' (or clear). + * Accepts a single name, an array (to highlight multiple machines for one test), + * or null to clear highlighting. */ + hoverTrace(traceName: string | string[] | null): void; + /** Destroy the chart and free resources. */ + destroy(): void; +} + +type PlotlyGd = HTMLElement & { + on: (evt: string, cb: (data: { points: Array<{ customdata?: string[]; curveNumber: number; pointNumber: number }> }) => void) => void; +}; + +/** + * Create a time-series chart that supports efficient incremental updates. + * First call uses Plotly.newPlot(); subsequent update() calls use Plotly.react(). + */ +export function createTimeSeriesChart( + container: HTMLElement, + options: TimeSeriesChartOptions, +): ChartHandle { + let chartDiv: HTMLElement | null = null; + let initialized = false; + let plotReady: Promise<void> = Promise.resolve(); + const config = { responsive: true, displayModeBar: true }; + /** Ordered list of main trace test names (excludes baseline traces). */ + let traceNames: string[] = []; + /** Total number of Plotly traces (main + baseline traces). */ + let totalTraceCount = 0; + /** Whether a temporary scatter trace is currently appended. */ + let hasScatterTrace = false; + /** Saved y-axis state before scatter trace was added (restored on unhover). */ + let savedYAxis: { range?: [number, number]; autorange?: unknown } | null = null; + /** Current getRawValues callback (updated on each doPlot). */ + let getRawValues: TimeSeriesChartOptions['getRawValues']; + /** Color map from test name to trace color (updated on each doPlot). */ + let traceColorMap = new Map<string, string>(); + /** Current display map for commit value mapping (updated on each doPlot). */ + let currentDisplayMap: Map<string, string> | undefined; + + let lastClickTest: string | null = null; + let lastClickTime = 0; + + function attachHandlers(gd: PlotlyGd, opts: TimeSeriesChartOptions): void { + gd.on('plotly_click', (eventData) => { + const pt = eventData.points[0]; + if (!pt?.customdata) return; + + const testName = pt.customdata[4] as string | undefined; + const now = Date.now(); + + if (testName && lastClickTest === testName && now - lastClickTime < DOUBLE_CLICK_DELAY_MS) { + lastClickTest = null; + lastClickTime = 0; + document.dispatchEvent(new CustomEvent(GRAPH_CHART_DBLCLICK, { detail: testName })); + return; + } + + lastClickTest = testName ?? null; + lastClickTime = now; + + if (opts.onClick && pt.customdata[0]) { + opts.onClick(pt.customdata[0] as string); + } + }); + + // Dispatch hover events for bidirectional sync with test-selection table + // and show raw value scatter for aggregated points + gd.on('plotly_hover', (eventData) => { + const pt = eventData.points[0]; + const traceName = pt?.customdata?.[1]; + if (traceName) { + document.dispatchEvent(new CustomEvent(GRAPH_CHART_HOVER, { detail: traceName })); + } + + // Show raw value scatter if getRawValues is available + if (!getRawValues || !chartDiv) return; + const commitValue = pt?.customdata?.[0]; + const testName = pt?.customdata?.[4]; + const machineName = pt?.customdata?.[5]; + if (!testName || !machineName || !commitValue) return; + + // Remove any existing scatter trace first + plotReady = plotReady.then(() => { + if (!chartDiv) return; + if (hasScatterTrace) { + try { + Plotly.deleteTraces(chartDiv, [-1]); + if (savedYAxis) { + Plotly.relayout(chartDiv, { + 'yaxis.autorange': savedYAxis.autorange ?? true, + ...(savedYAxis.range ? { 'yaxis.range': savedYAxis.range } : {}), + }); + savedYAxis = null; + } + } catch { /* ok */ } + hasScatterTrace = false; + } + if (!getRawValues) return; + const rawValues = getRawValues(testName, machineName, commitValue); + if (rawValues.length <= 1) return; + + const color = traceColorMap.get(`${testName}${TRACE_SEP}${machineName}`) || '#999'; + const displayCommit = currentDisplayMap?.get(commitValue) ?? commitValue; + const scatter = { + x: rawValues.map(() => displayCommit), + y: rawValues, + mode: 'markers', + type: 'scatter', + marker: { size: 6, color, opacity: 0.3 }, + showlegend: false, + hoverinfo: 'skip' as const, + }; + try { + // Save y-axis state and lock range before adding scatter + const curLayout = (chartDiv as unknown as { layout?: { + yaxis?: { range?: [number, number]; autorange?: unknown }; + } }).layout; + if (curLayout?.yaxis) { + savedYAxis = { + range: curLayout.yaxis.range ? [...curLayout.yaxis.range] as [number, number] : undefined, + autorange: curLayout.yaxis.autorange, + }; + if (curLayout.yaxis.range) { + Plotly.relayout(chartDiv, { + 'yaxis.autorange': false, + 'yaxis.range': [...curLayout.yaxis.range], + }); + } + } + Plotly.addTraces(chartDiv, scatter); + hasScatterTrace = true; + } catch { /* ok */ } + }).catch(err => { + console.warn('Chart operation failed:', err); + }); + }); + gd.on('plotly_unhover', () => { + document.dispatchEvent(new CustomEvent(GRAPH_CHART_HOVER, { detail: null })); + + // Remove scatter trace and restore y-axis + if (hasScatterTrace && chartDiv) { + plotReady = plotReady.then(() => { + if (!chartDiv || !hasScatterTrace) return; + try { + Plotly.deleteTraces(chartDiv, [-1]); + if (savedYAxis) { + Plotly.relayout(chartDiv, { + 'yaxis.autorange': savedYAxis.autorange ?? true, + ...(savedYAxis.range ? { 'yaxis.range': savedYAxis.range } : {}), + }); + savedYAxis = null; + } + } catch { /* ok */ } + hasScatterTrace = false; + }).catch(err => { + console.warn('Chart operation failed:', err); + }); + } + }); + } + + function doPlot(opts: TimeSeriesChartOptions): void { + if (opts.traces.length === 0 && !opts.categoryOrder) { + if (chartDiv && initialized) { + try { Plotly.purge(chartDiv); } catch { /* ok */ } + } + container.replaceChildren(el('p', { class: 'no-chart-data' }, 'No data to plot.')); + chartDiv = null; + initialized = false; + traceNames = []; + plotReady = Promise.resolve(); + return; + } + + // Track main trace names for hoverTrace() mapping + traceNames = opts.traces.map(t => `${t.testName}${TRACE_SEP}${t.machine}`); + + // Update callback and color map for scatter-on-hover + getRawValues = opts.getRawValues; + currentDisplayMap = opts.displayMap; + traceColorMap = new Map<string, string>(); + for (const t of opts.traces) { + if (t.color) traceColorMap.set(`${t.testName}${TRACE_SEP}${t.machine}`, t.color); + } + + const { data, layout } = buildPlotlyData(opts); + + // Track total trace count (main + reference) for restyle operations + totalTraceCount = (data as unknown[]).length; + + // react()/newPlot() replaces all traces, so any scatter trace is gone + hasScatterTrace = false; + + if (initialized && chartDiv && chartDiv.parentElement) { + // Chain react() after any pending newPlot() to avoid race conditions + plotReady = plotReady.then(() => { + if (!chartDiv) return; + // Preserve current axis ranges so user zoom is not reset by the + // canonical layout from buildPlotlyData(). Read chartDiv.layout + // inside the .then() because it may not be populated until the + // previous newPlot()/react() resolves. + const cur = (chartDiv as unknown as { layout?: { + xaxis?: { range?: unknown; autorange?: unknown }; + yaxis?: { range?: unknown; autorange?: unknown }; + } }).layout; + const lx = layout as { xaxis?: Record<string, unknown>; yaxis?: Record<string, unknown> }; + if (cur?.xaxis && lx.xaxis) { + lx.xaxis.range = cur.xaxis.range; + lx.xaxis.autorange = cur.xaxis.autorange; + } + if (cur?.yaxis && cur.yaxis.autorange === false) { + // User has explicitly zoomed the y-axis — preserve their range. + if (lx.yaxis) { + lx.yaxis.range = cur.yaxis.range; + lx.yaxis.autorange = false; + } + } + Plotly.react(chartDiv, data, layout, config); + }).catch(err => { + console.warn('Chart operation failed:', err); + }); + } else { + chartDiv = el('div', { class: 'graph-chart' }); + container.replaceChildren(chartDiv); + initialized = true; + plotReady = Plotly.newPlot(chartDiv, data, layout, config).then((gd) => { + attachHandlers(gd as PlotlyGd, opts); + }).catch(err => { + console.warn('Chart operation failed:', err); + }); + } + } + + doPlot(options); + + return { + update(opts: TimeSeriesChartOptions): void { + doPlot(opts); + }, + hoverTrace(traceName: string | string[] | null): void { + if (!chartDiv || !initialized) return; + plotReady.then(() => { + if (!chartDiv) return; + if (!traceName || (Array.isArray(traceName) && traceName.length === 0)) { + // Restore all traces to normal appearance + const allIndices = Array.from({ length: totalTraceCount }, (_, i) => i); + if (allIndices.length > 0) { + try { + Plotly.restyle(chartDiv, { opacity: 1.0, 'line.width': 1.5 }, allIndices); + } catch { /* ok */ } + } + return; + } + const names = Array.isArray(traceName) ? traceName : [traceName]; + const curveNumbers = names + .map(n => traceNames.indexOf(n)) + .filter(i => i >= 0); + if (curveNumbers.length === 0) return; + try { + // Dim all traces + const allIndices = Array.from({ length: totalTraceCount }, (_, i) => i); + if (allIndices.length > 0) { + Plotly.restyle(chartDiv, { opacity: 0.2, 'line.width': 1.5 }, allIndices); + } + // Emphasize the hovered trace(s) + Plotly.restyle(chartDiv, { opacity: 1.0, 'line.width': 3 }, curveNumbers); + } catch { /* ok */ } + }).catch(err => { + console.warn('Chart operation failed:', err); + }); + }, + destroy(): void { + if (chartDiv && initialized) { + try { Plotly.purge(chartDiv); } catch { /* ok */ } + } + chartDiv = null; + initialized = false; + traceNames = []; + totalTraceCount = 0; + hasScatterTrace = false; + savedYAxis = null; + getRawValues = undefined; + traceColorMap = new Map(); + lastClickTest = null; + lastClickTime = 0; + }, + }; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/graph/traces.ts b/lnt/server/ui/v5/frontend/src/pages/graph/traces.ts new file mode 100644 index 000000000..abeeb33ff --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/graph/traces.ts @@ -0,0 +1,253 @@ +// pages/graph/traces.ts — Pure functions for building chart-ready structures. +// Transforms cached data points into TimeSeriesTrace[], PinnedBaseline[], and +// ChartOverlays. No state, no DOM, no side effects. + +import type { AggFn, QueryDataPoint, RegressionListItem } from '../../types'; +import type { TimeSeriesTrace, PinnedBaseline, ChartOverlays } from './time-series-chart'; +import { getAggFn, machineColor, TRACE_SEP } from '../../utils'; + +// ---- Symbol constants ---- + +/** Plotly marker symbols for machine differentiation. */ +export const MACHINE_SYMBOLS = [ + 'circle', 'triangle-up', 'square', 'diamond', 'x', + 'cross', 'star', 'pentagon', 'hexagon', 'hexagram', +]; + +/** Unicode characters matching MACHINE_SYMBOLS for display in chips and legend. */ +export const SYMBOL_CHARS = ['●', '▲', '■', '◆', '✕', '+', '★', '⬠', '⬡', '✡']; + +export function assignSymbol(machineIndex: number): string { + return MACHINE_SYMBOLS[machineIndex % MACHINE_SYMBOLS.length]; +} + +export function assignSymbolChar(machineIndex: number): string { + return SYMBOL_CHARS[machineIndex % SYMBOL_CHARS.length]; +} + +// ---- Color assignment ---- + +/** Build a stable color map from the FULL unfiltered test list. + * Colors are assigned by alphabetical position so they don't shift + * when the test filter changes. */ +export function buildColorMap(allDiscoveredTests: string[]): Map<string, string> { + const map = new Map<string, string>(); + for (let i = 0; i < allDiscoveredTests.length; i++) { + map.set(allDiscoveredTests[i], machineColor(i)); + } + return map; +} + +// ---- Trace building ---- + +/** + * Group data points by test and commit, apply two-level aggregation + * (sample within run, then run across runs), and return one trace per test. + * + * The `machine` field on each trace is set to '' — the caller assigns it. + */ +export function buildTraces( + points: QueryDataPoint[], + runAgg: AggFn, + sampleAgg: AggFn, +): TimeSeriesTrace[] { + const testMap = new Map<string, QueryDataPoint[]>(); + for (const pt of points) { + let arr = testMap.get(pt.test); + if (!arr) { arr = []; testMap.set(pt.test, arr); } + arr.push(pt); + } + + const runAggFn = getAggFn(runAgg); + const sampleAggFn = getAggFn(sampleAgg); + const traces: TimeSeriesTrace[] = []; + + for (const [testName, testPoints] of testMap) { + const commitMap = new Map<string, QueryDataPoint[]>(); + for (const pt of testPoints) { + let arr = commitMap.get(pt.commit); + if (!arr) { arr = []; commitMap.set(pt.commit, arr); } + arr.push(pt); + } + + const tracePoints: TimeSeriesTrace['points'] = []; + for (const [commitValue, commitPoints] of commitMap) { + // Step 1: group by run_uuid + const byRun = new Map<string, number[]>(); + for (const pt of commitPoints) { + let arr = byRun.get(pt.run_uuid); + if (!arr) { arr = []; byRun.set(pt.run_uuid, arr); } + arr.push(pt.value); + } + // Step 2: aggregate samples within each run + const perRunValues = [...byRun.values()].map(v => sampleAggFn(v)); + // Step 3: aggregate across runs + tracePoints.push({ + commit: commitValue, + value: runAggFn(perRunValues), + runCount: byRun.size, + submitted_at: commitPoints[0].submitted_at, + }); + } + + traces.push({ testName, machine: '', points: tracePoints }); + } + + traces.sort((a, b) => a.testName.localeCompare(b.testName)); + return traces; +} + +// ---- Chart data orchestration ---- + +export interface BuildChartDataOpts { + selectedTests: Set<string>; + machines: string[]; + metric: string; + runAgg: AggFn; + sampleAgg: AggFn; + /** Sync reader for cached data points. */ + readCachedTestData: (suite: string, machine: string, metric: string, test: string) => QueryDataPoint[]; + suite: string; + /** Pre-built color map (from buildColorMap). */ + colorMap: Map<string, string>; +} + +/** + * Build all traces across machines with color and symbol assignment. + * Returns traces plus an indexed map for O(1) raw-values hover lookup. + */ +export function buildChartData(opts: BuildChartDataOpts): { + traces: TimeSeriesTrace[]; + rawValuesIndex: Map<string, number[]>; +} { + const colorMap = opts.colorMap; + const allTraces: TimeSeriesTrace[] = []; + const rawValuesIndex = new Map<string, number[]>(); + + const selectedSorted = [...opts.selectedTests].sort((a, b) => a.localeCompare(b)); + + for (let mi = 0; mi < opts.machines.length; mi++) { + const m = opts.machines[mi]; + const symbol = assignSymbol(mi); + + for (const testName of selectedSorted) { + const points = opts.readCachedTestData(opts.suite, m, opts.metric, testName); + if (points.length === 0) continue; + + // Build raw values index for hover scatter + for (const pt of points) { + const key = `${pt.test}|${m}|${pt.commit}`; + let arr = rawValuesIndex.get(key); + if (!arr) { arr = []; rawValuesIndex.set(key, arr); } + arr.push(pt.value); + } + + const machineTraces = buildTraces(points, opts.runAgg, opts.sampleAgg); + for (const t of machineTraces) { + allTraces.push({ + ...t, + machine: m, + color: colorMap.get(t.testName), + markerSymbol: symbol, + }); + } + } + } + + allTraces.sort((a, b) => + `${a.testName}${TRACE_SEP}${a.machine}`.localeCompare(`${b.testName}${TRACE_SEP}${b.machine}`)); + + return { traces: allTraces, rawValuesIndex }; +} + +/** + * Build a getRawValues callback from the index. Used by the chart's + * hover scatter feature. + */ +export function buildRawValuesCallback( + rawValuesIndex: Map<string, number[]>, +): (testName: string, machine: string, commit: string) => number[] { + return (testName, machine, commit) => { + return rawValuesIndex.get(`${testName}|${machine}|${commit}`) ?? []; + }; +} + +// ---- Baseline building ---- + +/** + * Build baseline reference lines from cached data. + */ +export function buildBaselinesFromData( + baselines: Array<{ suite: string; machine: string; commit: string }>, + getPoints: (suite: string, machine: string, commit: string, metric: string) => QueryDataPoint[], + metric: string, + aggFn: (values: number[]) => number, + displayMap?: Map<string, string>, +): PinnedBaseline[] { + return baselines.map((bl) => { + const points = getPoints(bl.suite, bl.machine, bl.commit, metric); + + const rawPerTest = new Map<string, number[]>(); + for (const pt of points) { + let arr = rawPerTest.get(pt.test); + if (!arr) { arr = []; rawPerTest.set(pt.test, arr); } + arr.push(pt.value); + } + + const values = new Map<string, number>(); + for (const [test, raw] of rawPerTest) { + values.set(test, aggFn(raw)); + } + + const commitDisplay = displayMap?.get(bl.commit) ?? bl.commit; + const label = `${bl.suite}/${bl.machine}/${commitDisplay}`; + + return { label, values }; + }); +} + +// ---- Regression overlays ---- + +/** + * Build Plotly shapes + annotations for regression markers. + * Vertical dashed lines color-coded by state. + */ +export function buildRegressionOverlays( + regressions: RegressionListItem[], + displayMap: Map<string, string>, +): ChartOverlays { + const shapes: unknown[] = []; + const annotations: unknown[] = []; + + for (const r of regressions) { + if (!r.commit) continue; + + const xVal = displayMap.get(r.commit) ?? r.commit; + const color = r.state === 'active' ? '#d62728' + : r.state === 'detected' ? '#ff7f0e' + : '#999'; + + shapes.push({ + type: 'line', + x0: xVal, + x1: xVal, + y0: 0, + y1: 1, + yref: 'paper', + line: { color, width: 1.5, dash: 'dash' }, + }); + + annotations.push({ + x: xVal, + y: 1, + yref: 'paper', + text: r.title || 'Regression', + showarrow: false, + font: { size: 10, color }, + yanchor: 'bottom', + captureevents: true, + }); + } + + return { shapes, annotations }; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/home.ts b/lnt/server/ui/v5/frontend/src/pages/home.ts new file mode 100644 index 000000000..b9b5f7ea9 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/home.ts @@ -0,0 +1,293 @@ +// pages/home.ts — Dashboard page with sparkline trend overview. +// Suite-agnostic — served at /v5/. + +import type { PageModule, RouteParams } from '../router'; +import type { FieldInfo } from '../types'; +import { getTestsuites } from '../router'; +import { getTestSuiteInfo, getRunsPage, fetchTrends } from '../api'; +import { el, agnosticUrl } from '../utils'; +import { filterMetricFields } from '../components/metric-selector'; +import type { SparklineTrace } from '../components/sparkline-card'; +import { + createSparklineCard, createSparklineLoading, createSparklineError, + machineColor, +} from '../components/sparkline-card'; + +const MAX_MACHINES = 5; + +type RangePreset = '100' | '500' | '1000'; +const RANGE_COMMITS: Record<RangePreset, number> = { '100': 100, '500': 500, '1000': 1000 }; +const RANGE_PRESETS: RangePreset[] = ['100', '500', '1000']; + +function isValidRange(s: string): s is RangePreset { + return RANGE_PRESETS.includes(s as RangePreset); +} + +// --------------------------------------------------------------------------- +// Data fetching — uses server-side trends endpoint for geomean aggregation +// --------------------------------------------------------------------------- + +/** + * Fetch trend data for one metric across multiple machines. + * Returns sparkline traces with server-computed geomean values per commit. + * Points are assigned sequential x-indices for even spacing on the chart. + */ +async function fetchSuiteTrends( + suite: string, + metric: string, + machines: string[], + lastN: number, + signal: AbortSignal, +): Promise<SparklineTrace[]> { + const items = await fetchTrends(suite, { metric, machine: machines, lastN }, signal); + + // Group API response by machine + const byMachine = new Map<string, Array<{ ordinal: number; value: number; commit: string }>>(); + for (const item of items) { + let points = byMachine.get(item.machine); + if (!points) { points = []; byMachine.set(item.machine, points); } + points.push({ ordinal: item.ordinal, value: item.value, commit: item.commit }); + } + + // Build a global ordinal -> sequential index mapping for even spacing. + // All machines share the same mapping so traces align at the same commits. + const allOrdinals = [...new Set(items.map(i => i.ordinal))].sort((a, b) => a - b); + const ordinalToX = new Map(allOrdinals.map((ord, idx) => [ord, idx])); + + const traces: SparklineTrace[] = []; + for (const [machine, points] of byMachine) { + if (points.length === 0) continue; + const idx = machines.indexOf(machine); + traces.push({ + machine, + color: machineColor(idx >= 0 ? idx : traces.length), + points: points + .sort((a, b) => a.ordinal - b.ordinal) + .map(p => ({ x: ordinalToX.get(p.ordinal)!, value: p.value, commit: p.commit })), + }); + } + return traces; +} + +// --------------------------------------------------------------------------- +// Dashboard page module +// --------------------------------------------------------------------------- + +/** Track all Plotly card destroy callbacks for cleanup on unmount. */ +let destroyFns: Array<() => void> = []; +let abortController: AbortController | null = null; + +export const homePage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + // Clean up any previous state + cleanup(); + + abortController = new AbortController(); + const signal = abortController.signal; + + const suites = getTestsuites(); + + // Read range from URL + const urlParams = new URLSearchParams(window.location.search); + let activeRange: RangePreset = '500'; + const rangeParam = urlParams.get('range') || ''; + if (isValidRange(rangeParam)) activeRange = rangeParam; + + // Header with commit range buttons + const rangeGroup = el('div', { class: 'dashboard-range-group' }); + const rangeButtons = new Map<RangePreset, HTMLButtonElement>(); + for (const preset of RANGE_PRESETS) { + const btn = el('button', { + class: `dashboard-range-btn${preset === activeRange ? ' dashboard-range-btn-active' : ''}`, + }, `Last ${preset}`); + btn.addEventListener('click', () => { + if (preset === activeRange) return; + activeRange = preset; + syncUrl(); + for (const [p, b] of rangeButtons) { + b.className = `dashboard-range-btn${p === activeRange ? ' dashboard-range-btn-active' : ''}`; + } + reloadAll(); + }); + rangeButtons.set(preset, btn); + rangeGroup.append(btn); + } + + const header = el('div', { class: 'dashboard-header' }, + el('h2', { class: 'page-header' }, 'Dashboard'), + rangeGroup, + ); + container.append(header); + + if (suites.length === 0) { + container.append(el('p', {}, 'No test suites available.')); + return; + } + + // Suite sections + const suiteSections = new Map<string, HTMLElement>(); + for (const suite of suites) { + const grid = el('div', { class: 'sparkline-grid' }); + const section = el('div', { class: 'suite-section' }, + el('h3', {}, suite), + grid, + ); + suiteSections.set(suite, grid); + container.append(section); + } + + function syncUrl(): void { + const params = new URLSearchParams(); + if (activeRange !== '500') params.set('range', activeRange); + const qs = params.toString(); + window.history.replaceState(null, '', + window.location.pathname + (qs ? '?' + qs : '')); + } + + function reloadAll(): void { + // Abort previous requests + if (abortController) abortController.abort(); + abortController = new AbortController(); + const sig = abortController.signal; + + // Destroy existing sparkline cards + for (const fn of destroyFns) fn(); + destroyFns = []; + + // Clear grids + for (const grid of suiteSections.values()) { + grid.replaceChildren(); + } + + loadAllSuites(sig); + } + + function loadAllSuites(sig: AbortSignal): void { + for (const suite of suites) { + const grid = suiteSections.get(suite)!; + loadSuite(suite, grid, sig); + } + } + + async function loadSuite(suite: string, grid: HTMLElement, sig: AbortSignal): Promise<void> { + try { + // Fetch suite info and recent runs in parallel + const [suiteInfo, runsPage] = await Promise.all([ + getTestSuiteInfo(suite, sig), + getRunsPage(suite, { sort: '-submitted_at', limit: 50 }, sig), + ]); + + if (sig.aborted) return; + + const metrics = filterMetricFields(suiteInfo.schema.metrics); + if (metrics.length === 0) { + grid.append(el('p', { class: 'sparkline-loading' }, 'No metrics defined.')); + return; + } + + // Find top N most recently active machines + const seen = new Set<string>(); + const topMachines: string[] = []; + for (const run of runsPage.items) { + if (!seen.has(run.machine)) { + seen.add(run.machine); + topMachines.push(run.machine); + if (topMachines.length >= MAX_MACHINES) break; + } + } + + if (topMachines.length === 0) { + grid.append(el('p', { class: 'sparkline-loading' }, 'No recent runs.')); + return; + } + + // Create loading placeholders for each metric + const placeholders = new Map<string, HTMLElement>(); + for (const metric of metrics) { + const placeholder = createSparklineLoading( + metric.display_name || metric.name, + metric.unit_abbrev || metric.unit || undefined, + ); + placeholders.set(metric.name, placeholder); + grid.append(placeholder); + } + + // Fetch and render each metric's sparkline + const lastN = RANGE_COMMITS[activeRange]; + for (const metric of metrics) { + loadMetricSparkline(suite, metric, topMachines, lastN, grid, placeholders, sig); + } + } catch (err) { + if (sig.aborted) return; + grid.append(el('p', { class: 'sparkline-error' }, `Error loading suite: ${err}`)); + } + } + + async function loadMetricSparkline( + suite: string, + metric: FieldInfo, + machines: string[], + lastN: number, + grid: HTMLElement, + placeholders: Map<string, HTMLElement>, + sig: AbortSignal, + ): Promise<void> { + const metricName = metric.name; + const displayName = metric.display_name || metric.name; + const unit = metric.unit_abbrev || metric.unit || undefined; + + try { + const traces = await fetchSuiteTrends(suite, metricName, machines, lastN, sig); + if (sig.aborted) return; + + const { element, destroy } = createSparklineCard({ + title: displayName, + unit, + traces, + onClick: (machine?: string) => { + const params = new URLSearchParams(); + params.set('suite', suite); + if (machine) { + params.append('machine', machine); + } else { + for (const m of machines) params.append('machine', m); + } + params.set('metric', metricName); + window.location.href = agnosticUrl(`/graph?${params.toString()}`); + }, + }); + + destroyFns.push(destroy); + + // Replace loading placeholder with the rendered card + const placeholder = placeholders.get(metricName); + if (placeholder && placeholder.parentElement === grid) { + grid.replaceChild(element, placeholder); + } + } catch (err) { + if (sig.aborted) return; + const errorCard = createSparklineError(displayName, unit); + const placeholder = placeholders.get(metricName); + if (placeholder && placeholder.parentElement === grid) { + grid.replaceChild(errorCard, placeholder); + } + } + } + + // Initial load + loadAllSuites(signal); + }, + + unmount(): void { + cleanup(); + }, +}; + +function cleanup(): void { + if (abortController) { + abortController.abort(); + abortController = null; + } + for (const fn of destroyFns) fn(); + destroyFns = []; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/machine-detail.ts b/lnt/server/ui/v5/frontend/src/pages/machine-detail.ts new file mode 100644 index 000000000..b784b3941 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/machine-detail.ts @@ -0,0 +1,195 @@ +// pages/machine-detail.ts — Machine metadata and run history. + +import type { PageModule, RouteParams } from '../router'; +import type { MachineRunInfo, RegressionListItem } from '../types'; +import { getMachine, getMachineRuns, deleteMachine, getRegressions } from '../api'; +import { el, spaLink, agnosticLink, agnosticUrl, formatTime, truncate, resolveDisplayMap } from '../utils'; +import { renderDataTable } from '../components/data-table'; +import { renderPagination } from '../components/pagination'; +import { renderDeleteConfirm } from '../components/delete-confirm'; +import { UNRESOLVED_STATES, renderStateBadge } from '../regression-utils'; + +const PAGE_SIZE = 25; + +let controller: AbortController | null = null; + +export const machineDetailPage: PageModule = { + mount(container: HTMLElement, params: RouteParams): void { + if (controller) controller.abort(); + controller = new AbortController(); + const { signal } = controller; + + const ts = params.testsuite; + const name = params.name; + + container.append(el('h2', { class: 'page-header' }, `Machine: ${name}`)); + + const metaContainer = el('div', {}); + const actionsContainer = el('div', { class: 'action-links' }); + const runsContainer = el('div', {}); + const regressionsContainer = el('div', { class: 'machine-regressions-section' }); + const deleteConfirmDiv = el('div', {}); + container.append(metaContainer, actionsContainer, deleteConfirmDiv, regressionsContainer, runsContainer); + + // Load metadata + getMachine(ts, name, signal).then(machine => { + const entries = Object.entries(machine.info || {}); + if (entries.length > 0) { + const dl = el('dl', { class: 'metadata-dl' }); + for (const [k, v] of entries) { + dl.append(el('dt', {}, k), el('dd', {}, v)); + } + metaContainer.append(dl); + } else { + metaContainer.append(el('p', { class: 'no-results' }, 'No metadata available.')); + } + }).catch(e => { + metaContainer.append(el('p', { class: 'error-banner' }, `Failed to load machine: ${e}`)); + }); + + // Action links + const graphLink = agnosticLink('View Graph', `/graph?suite=${encodeURIComponent(ts)}&machine=${encodeURIComponent(name)}`); + const compareLink = agnosticLink('Compare', `/compare?suite_a=${encodeURIComponent(ts)}&machine_a=${encodeURIComponent(name)}`); + graphLink.classList.add('action-link'); + compareLink.classList.add('action-link'); + actionsContainer.append(graphLink, compareLink); + + // Load runs with cursor-stack pagination + const cursorStack: string[] = []; + let currentCursor: string | undefined; + + async function loadRuns(): Promise<void> { + runsContainer.replaceChildren(); + runsContainer.append(el('h3', {}, 'Run History')); + runsContainer.append(el('p', { class: 'progress-label' }, 'Loading runs...')); + + try { + const result = await getMachineRuns(ts, name, { + sort: '-submitted_at', + limit: PAGE_SIZE, + cursor: currentCursor, + }, signal); + + // Resolve commit display values + const commits = [...new Set(result.items.map(r => r.commit))]; + const displayMap = await resolveDisplayMap(ts, commits, signal); + + runsContainer.replaceChildren(); + runsContainer.append(el('h3', {}, 'Run History')); + + const tableDiv = el('div', {}); + renderDataTable(tableDiv, { + columns: [ + { key: 'uuid', label: 'Run UUID', + render: (r: MachineRunInfo) => spaLink(r.uuid.slice(0, 8), `/runs/${encodeURIComponent(r.uuid)}`) }, + { key: 'commit', label: 'Commit', + render: (r: MachineRunInfo) => + spaLink(truncate(displayMap.get(r.commit) ?? r.commit, 12), `/commits/${encodeURIComponent(r.commit)}`) }, + { key: 'submitted_at', label: 'Submitted', + render: (r: MachineRunInfo) => formatTime(r.submitted_at) }, + ], + rows: result.items, + emptyMessage: 'No runs found.', + }); + runsContainer.append(tableDiv); + + const paginationDiv = el('div', {}); + renderPagination(paginationDiv, { + hasPrevious: cursorStack.length > 0, + hasNext: result.cursor.next !== null, + onPrevious: () => { currentCursor = cursorStack.pop(); loadRuns(); }, + onNext: () => { + cursorStack.push(currentCursor || ''); + currentCursor = result.cursor.next!; + loadRuns(); + }, + }); + runsContainer.append(paginationDiv); + } catch (e: unknown) { + runsContainer.replaceChildren(); + runsContainer.append(el('h3', {}, 'Run History')); + runsContainer.append(el('p', { class: 'error-banner' }, `Failed to load runs: ${e}`)); + } + } + + loadRuns(); + + // Active regressions on this machine + loadMachineRegressions(ts, name, regressionsContainer, signal); + + // Delete section (button in actions row, confirmation below) + renderDeleteConfirm(actionsContainer, { + label: 'Delete Machine', + prompt: `Type "${name}" to confirm deletion. This will delete all runs and data for this machine.`, + confirmValue: name, + placeholder: 'Machine name', + deletingMessage: 'This may take a while for machines with many runs.', + onDelete: () => deleteMachine(ts, name), + onSuccess: () => { + window.location.assign(agnosticUrl(`/test-suites?suite=${encodeURIComponent(ts)}`)); + }, + confirmContainer: deleteConfirmDiv, + }); + }, + + unmount(): void { + if (controller) { controller.abort(); controller = null; } + }, +}; + +async function loadMachineRegressions( + ts: string, + machineName: string, + container: HTMLElement, + signal: AbortSignal, +): Promise<void> { + container.append(el('h3', {}, 'Active Regressions')); + + try { + const result = await getRegressions(ts, { + machine: machineName, + state: UNRESOLVED_STATES, + limit: 25, + }, signal); + const regressions = result.items; + + if (regressions.length === 0) { + container.append(el('p', { class: 'no-results' }, + 'No active regressions on this machine.')); + return; + } + + renderDataTable(container, { + columns: [ + { + key: 'title', + label: 'Regression', + render: (r: RegressionListItem) => spaLink( + truncate(r.title || '(untitled)', 50), + `/regressions/${encodeURIComponent(r.uuid)}`), + }, + { + key: 'state', + label: 'State', + render: (r: RegressionListItem) => renderStateBadge(r.state), + }, + { + key: 'test_count', + label: 'Tests', + cellClass: 'col-num', + }, + ], + rows: regressions, + emptyMessage: 'No active regressions.', + }); + + container.append( + agnosticLink('Show all regressions', + `/test-suites?suite=${encodeURIComponent(ts)}&tab=regressions`), + ); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') return; + container.append(el('p', { class: 'error-banner' }, + `Failed to load regressions: ${e}`)); + } +} diff --git a/lnt/server/ui/v5/frontend/src/pages/profiles.ts b/lnt/server/ui/v5/frontend/src/pages/profiles.ts new file mode 100644 index 000000000..35c65a3c4 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/profiles.ts @@ -0,0 +1,930 @@ +// pages/profiles.ts — Profiles page: A/B hardware counter profile viewer. + +import type { PageModule, RouteParams } from '../router'; +import type { + ProfileListItem, ProfileMetadata, ProfileFunctionInfo, + ProfileFunctionDetail, RunInfo, CommitSummary, +} from '../types'; +import { + getRun, getRuns, getCommits, getProfilesForRun, + getProfileMetadata, getProfileFunctions, getProfileFunctionDetail, + getTestSuiteInfoCached, +} from '../api'; +import { getTestsuites } from '../router'; +import { el, matchesFilter, updateFilterValidation, commitDisplayValue } from '../utils'; +import { renderMachineCombobox } from '../components/machine-combobox'; +import { createCommitPicker } from '../components/commit-combobox'; +import { renderProfileStats } from '../components/profile-stats'; +import { renderProfileViewer, type DisplayMode } from '../components/profile-viewer'; +import { heatGradient } from '../components/profile-colors'; + +// --------------------------------------------------------------------------- +// Module state +// --------------------------------------------------------------------------- + +let controller: AbortController | null = null; + +const commitFieldsCache = new Map<string, Array<{ name: string; display?: boolean }>>(); + +// Per-side cascading selector containers (avoids hacky DOM property injection) +interface CascadeRefs { runContainer: HTMLElement; testContainer: HTMLElement } +const cascadeRefs = new Map<'a' | 'b', CascadeRefs>(); + +// Shared state +let selectedCounter = ''; +let displayMode: DisplayMode = 'relative'; + +// Cleanup handles +let machineComboA: ReturnType<typeof renderMachineCombobox> | null = null; +let machineComboB: ReturnType<typeof renderMachineCombobox> | null = null; +let commitPickerA: ReturnType<typeof createCommitPicker> | null = null; +let commitPickerB: ReturnType<typeof createCommitPicker> | null = null; +let statsHandle: { destroy: () => void } | null = null; +let viewerHandleA: { destroy: () => void; isShowAll: () => boolean } | null = null; +let viewerHandleB: { destroy: () => void; isShowAll: () => boolean } | null = null; +let machineCommitsAbortA: AbortController | null = null; +let machineCommitsAbortB: AbortController | null = null; + +interface SideState { + suite: string; + machine: string; + commit: string; + runUuid: string; + testName: string; + profileUuid: string; + metadata: ProfileMetadata | null; + functions: ProfileFunctionInfo[]; + selectedFunction: string; + functionDetail: ProfileFunctionDetail | null; + machineCommits: CommitSummary[] | null; + machineCommitsLoading: boolean; + profiles: ProfileListItem[]; // cached profiles for the selected run + runs: RunInfo[]; // cached runs for machine+commit +} + +function initialSideState(): SideState { + return { + suite: '', machine: '', commit: '', runUuid: '', testName: '', profileUuid: '', + metadata: null, functions: [], selectedFunction: '', + functionDetail: null, machineCommits: null, machineCommitsLoading: false, + profiles: [], runs: [], + }; +} + +let sideA: SideState = initialSideState(); +let sideB: SideState = initialSideState(); + +// DOM references +let sideAContainer: HTMLElement | null = null; +let sideBContainer: HTMLElement | null = null; +let statsContainer: HTMLElement | null = null; +let controlsContainer: HTMLElement | null = null; +let fnSelectorAContainer: HTMLElement | null = null; +let fnSelectorBContainer: HTMLElement | null = null; +let viewerAContainer: HTMLElement | null = null; +let viewerBContainer: HTMLElement | null = null; +let counterSelect: HTMLSelectElement | null = null; +let displayModeSelect: HTMLSelectElement | null = null; + +// --------------------------------------------------------------------------- +// Page module +// --------------------------------------------------------------------------- + +export const profilesPage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + cleanup(); + controller = new AbortController(); + + container.append(el('h2', { class: 'page-header' }, 'Profiles')); + + // Read URL params + const urlParams = new URLSearchParams(window.location.search); + const urlSuiteA = urlParams.get('suite_a') || ''; + const urlSuiteB = urlParams.get('suite_b') || ''; + const urlRunA = urlParams.get('run_a') || ''; + const urlTestA = urlParams.get('test_a') || ''; + const urlRunB = urlParams.get('run_b') || ''; + const urlTestB = urlParams.get('test_b') || ''; + + // A/B Picker (suite selector is per-side, inside each picker) + const pickerRow = el('div', { class: 'profile-picker' }); + sideAContainer = el('div', { class: 'profile-side' }); + sideAContainer.append(el('h3', {}, 'Side A')); + sideBContainer = el('div', { class: 'profile-side' }); + sideBContainer.append(el('h3', {}, 'Side B')); + pickerRow.append(sideAContainer, sideBContainer); + container.append(pickerRow); + + // Stats bar + statsContainer = el('div', { class: 'profile-stats-container' }); + container.append(statsContainer); + + // Global controls (counter + display mode) + controlsContainer = el('div', { class: 'profile-viewer-controls' }); + container.append(controlsContainer); + + // Function selectors + viewers + const columnsRow = el('div', { class: 'profile-columns' }); + const colA = el('div', { class: 'profile-column' }); + fnSelectorAContainer = el('div', { class: 'profile-fn-selector' }); + viewerAContainer = el('div', { class: 'profile-viewer-container' }); + colA.append(fnSelectorAContainer, viewerAContainer); + + const colB = el('div', { class: 'profile-column' }); + fnSelectorBContainer = el('div', { class: 'profile-fn-selector' }); + viewerBContainer = el('div', { class: 'profile-viewer-container' }); + colB.append(fnSelectorBContainer, viewerBContainer); + + columnsRow.append(colA, colB); + container.append(columnsRow); + + // Set initial suite from URL and render + sideA.suite = urlSuiteA; + sideB.suite = urlSuiteB; + + // Pre-fetch commit_fields for URL-restored suites + for (const suite of [urlSuiteA, urlSuiteB]) { + if (suite && !commitFieldsCache.has(suite)) { + getTestSuiteInfoCached(suite) + .then(info => { commitFieldsCache.set(suite, info.schema.commit_fields); }) + .catch(() => {}); + } + } + + // Render immediately; commits are loaded on-demand when a machine is + // selected (via loadMachineCommits). + renderSidePickers(); + if (urlRunA || urlRunB) { + restoreFromUrl(urlRunA, urlTestA, urlRunB, urlTestB); + } + }, + + unmount(): void { + cleanup(); + }, +}; + +// --------------------------------------------------------------------------- +// Data loading +// --------------------------------------------------------------------------- + +async function loadMachineCommits(side: 'a' | 'b', suite: string, machine: string): Promise<void> { + const state = side === 'a' ? sideA : sideB; + state.machineCommitsLoading = true; + + // Abort previous for this side + const prevCtrl = side === 'a' ? machineCommitsAbortA : machineCommitsAbortB; + if (prevCtrl) prevCtrl.abort(); + const ctrl = new AbortController(); + if (side === 'a') machineCommitsAbortA = ctrl; + else machineCommitsAbortB = ctrl; + + try { + const commits = await getCommits(suite, { + machine, + has_profiles: true, + signal: ctrl.signal, + }); + state.machineCommits = commits; + state.machineCommitsLoading = false; + } catch (e: unknown) { + if (isAbort(e)) return; + state.machineCommits = null; + state.machineCommitsLoading = false; + } +} + +async function loadRuns(side: 'a' | 'b'): Promise<void> { + const state = side === 'a' ? sideA : sideB; + if (!state.suite || !state.machine || !state.commit) return; + + try { + state.runs = await getRuns( + state.suite, + { machine: state.machine, commit: state.commit, has_profiles: true }, + controller?.signal, + ); + renderRunSelect(side); + } catch (e: unknown) { + if (isAbort(e)) return; + state.runs = []; + renderRunSelect(side); + } +} + +async function loadProfiles(side: 'a' | 'b'): Promise<void> { + const state = side === 'a' ? sideA : sideB; + if (!state.suite || !state.runUuid) return; + + try { + const profiles = await getProfilesForRun(state.suite, state.runUuid, controller?.signal); + state.profiles = profiles; + renderTestSelect(side); + } catch (e: unknown) { + if (isAbort(e)) return; + state.profiles = []; + renderTestSelect(side); + } +} + +async function loadProfile(side: 'a' | 'b'): Promise<void> { + const state = side === 'a' ? sideA : sideB; + if (!state.suite || !state.profileUuid) return; + + const requestedUuid = state.profileUuid; + const container = side === 'a' ? fnSelectorAContainer! : fnSelectorBContainer!; + container.replaceChildren(el('span', { class: 'profile-loading' }, 'Loading profile...')); + + try { + const [metadata, funcResp] = await Promise.all([ + getProfileMetadata(state.suite, requestedUuid, controller?.signal), + getProfileFunctions(state.suite, requestedUuid, controller?.signal), + ]); + // Guard against stale response if user changed selection during fetch + if (state.profileUuid !== requestedUuid) return; + state.metadata = metadata; + state.functions = funcResp.functions; + state.selectedFunction = ''; + state.functionDetail = null; + + updateCounterNames(); + renderStats(); + renderGlobalControls(); + renderFunctionSelector(side); + } catch (e: unknown) { + if (isAbort(e)) return; + container.replaceChildren(el('span', { class: 'error-banner' }, `Failed to load profile: ${e}`)); + } +} + +async function loadFunctionDetail(side: 'a' | 'b', fnName: string): Promise<void> { + const state = side === 'a' ? sideA : sideB; + if (!state.suite || !state.profileUuid) return; + + const requestedUuid = state.profileUuid; + const viewerContainer = side === 'a' ? viewerAContainer! : viewerBContainer!; + viewerContainer.replaceChildren(el('span', { class: 'profile-loading' }, 'Loading disassembly...')); + + try { + const detail = await getProfileFunctionDetail( + state.suite, requestedUuid, fnName, controller?.signal); + // Guard against stale response if user changed profile during fetch + if (state.profileUuid !== requestedUuid) return; + state.functionDetail = detail; + state.selectedFunction = fnName; + renderViewer(side); + } catch (e: unknown) { + if (isAbort(e)) return; + viewerContainer.replaceChildren(el('span', { class: 'error-banner' }, `Failed to load function: ${e}`)); + } +} + +// --------------------------------------------------------------------------- +// URL state restoration +// --------------------------------------------------------------------------- + +async function restoreFromUrl( + runA: string, testA: string, runB: string, testB: string, +): Promise<void> { + const promises: Promise<void>[] = []; + if (runA) promises.push(restoreSide('a', runA, testA)); + if (runB) promises.push(restoreSide('b', runB, testB)); + await Promise.all(promises); +} + +async function restoreSide(side: 'a' | 'b', runUuid: string, testName: string): Promise<void> { + const state = side === 'a' ? sideA : sideB; + + try { + // 1. Fetch run details to get machine + commit + const runDetail = await getRun(state.suite, runUuid, controller?.signal); + state.machine = runDetail.machine; + state.commit = runDetail.commit; + + // 2. Load machine-commit set + await loadMachineCommits(side, state.suite, state.machine); + + // 3. Fetch runs for machine+commit (only profile-bearing runs) + const runs = await getRuns( + state.suite, + { machine: state.machine, commit: state.commit, has_profiles: true }, + controller?.signal, + ); + state.runs = runs; + + // 4. Set run + const matchedRun = runs.find(r => r.uuid === runUuid); + if (matchedRun) { + state.runUuid = runUuid; + } + + // 5. Fetch profiles + const profiles = await getProfilesForRun(state.suite, runUuid, controller?.signal); + state.profiles = profiles; + + // 6. Match test + if (testName) { + const matchedProfile = profiles.find(p => p.test === testName); + if (matchedProfile) { + state.testName = testName; + state.profileUuid = matchedProfile.uuid; + } + } + + // Re-render the side picker with restored state + renderSidePickers(); + + // Load profile data if test was matched + if (state.profileUuid) { + await loadProfile(side); + } + } catch (e: unknown) { + if (isAbort(e)) return; + // Show error and re-render with whatever state we have + renderSidePickers(); + } +} + +// --------------------------------------------------------------------------- +// Rendering: Side pickers +// --------------------------------------------------------------------------- + +function renderSidePickers(): void { + renderSidePicker('a'); + renderSidePicker('b'); +} + +function renderSidePicker(side: 'a' | 'b'): void { + const container = side === 'a' ? sideAContainer! : sideBContainer!; + const state = side === 'a' ? sideA : sideB; + + // Clear everything after the h3 + const heading = container.querySelector('h3'); + container.replaceChildren(); + if (heading) container.append(heading); + + // Suite selector (per-side) + const suiteRow = el('div', { class: 'profile-cascade-row control-group' }); + suiteRow.append(el('label', {}, 'Suite')); + const suiteSelect = el('select', { class: 'admin-input' }) as HTMLSelectElement; + suiteSelect.append(el('option', { value: '' }, '-- Select suite --') as HTMLOptionElement); + for (const ts of getTestsuites()) { + const opt = el('option', { value: ts }, ts) as HTMLOptionElement; + if (ts === state.suite) opt.selected = true; + suiteSelect.append(opt); + } + suiteSelect.addEventListener('change', () => { + const newSuite = suiteSelect.value; + state.suite = newSuite; + resetStateFrom(state, 'machine'); + clearDownstream(side, 'machine'); + clearProfileDisplay(side); + if (newSuite && !commitFieldsCache.has(newSuite)) { + getTestSuiteInfoCached(newSuite) + .then(info => { commitFieldsCache.set(newSuite, info.schema.commit_fields); }) + .catch(() => {}); + } + syncUrl(); + renderSidePicker(side); + }); + suiteRow.append(suiteSelect); + container.append(suiteRow); + + if (!state.suite) { + container.append(el('span', { class: 'no-results' }, 'Select a suite first.')); + return; + } + + // Machine combobox + const machineRow = el('div', { class: 'profile-cascade-row control-group' }); + machineRow.append(el('label', {}, 'Machine')); + const machineContainer = el('div'); + const combo = renderMachineCombobox(machineContainer, { + testsuite: state.suite, + initialValue: state.machine, + onSelect(name: string) { + resetStateFrom(state, 'commit'); + state.machine = name; + state.machineCommitsLoading = true; + clearDownstream(side, 'machine'); + loadMachineCommits(side, state.suite, name).then(() => { + const picker = side === 'a' ? commitPickerA : commitPickerB; + if (picker) { + picker.input.disabled = false; + picker.input.placeholder = 'Type to search commits...'; + } + }); + syncUrl(); + }, + onClear() { + resetStateFrom(state, 'machine'); + clearDownstream(side, 'machine'); + syncUrl(); + }, + }); + + if (side === 'a') { + machineComboA?.destroy(); + machineComboA = combo; + } else { + machineComboB?.destroy(); + machineComboB = combo; + } + + machineRow.append(machineContainer); + container.append(machineRow); + + // Commit picker + const commitRow = el('div', { class: 'profile-cascade-row control-group' }); + commitRow.append(el('label', {}, 'Commit')); + const commitContainer = el('div'); + const cpId = `profiles-commit-${side}`; + const picker = createCommitPicker({ + id: cpId, + getCommitData: () => { + const commits = state.machineCommits ?? []; + const values = commits.map(c => c.commit); + const cf = state.suite ? commitFieldsCache.get(state.suite) : undefined; + let displayMap: Map<string, string> | undefined; + if (cf) { + displayMap = new Map<string, string>(); + for (const c of commits) { + const display = commitDisplayValue(c, cf); + if (display !== c.commit) displayMap.set(c.commit, display); + } + if (displayMap.size === 0) displayMap = undefined; + } + return { values, displayMap }; + }, + initialValue: state.commit, + placeholder: state.machine + ? (state.machineCommitsLoading ? 'Loading commits...' : 'Type to search commits...') + : 'Select a machine first', + onSelect(value: string) { + resetStateFrom(state, 'run'); + state.commit = value; + clearDownstream(side, 'commit'); + loadRuns(side); + syncUrl(); + }, + }); + + if (!state.machine) { + picker.input.disabled = true; + } + + if (side === 'a') { + commitPickerA?.destroy(); + commitPickerA = picker; + } else { + commitPickerB?.destroy(); + commitPickerB = picker; + } + + commitContainer.append(picker.element); + commitRow.append(commitContainer); + container.append(commitRow); + + // Run select + const runRow = el('div', { class: 'profile-cascade-row control-group' }); + runRow.append(el('label', {}, 'Run')); + const runSelectContainer = el('div'); + runRow.append(runSelectContainer); + container.append(runRow); + + // Test select + const testRow = el('div', { class: 'profile-cascade-row control-group' }); + testRow.append(el('label', {}, 'Test')); + const testSelectContainer = el('div'); + testRow.append(testSelectContainer); + container.append(testRow); + + // Store containers for dynamic updates + cascadeRefs.set(side, { runContainer: runSelectContainer, testContainer: testSelectContainer }); + + // Render run/test selects if state is already populated (URL restoration) + if (state.runs.length > 0) { + renderRunSelectInto(side, runSelectContainer); + } else { + renderDisabledSelect(runSelectContainer, 'Select a commit first'); + } + + if (state.profiles.length > 0) { + renderTestSelectInto(side, testSelectContainer); + } else if (state.runUuid && state.profiles.length === 0 && state.runs.length > 0) { + renderEmptyMessage(testSelectContainer, 'No tests with profiles in this run.'); + } else { + renderDisabledSelect(testSelectContainer, 'Select a run first'); + } +} + +// --------------------------------------------------------------------------- +// Rendering: Run and Test selects +// --------------------------------------------------------------------------- + +function getRunContainer(side: 'a' | 'b'): HTMLElement | null { + return cascadeRefs.get(side)?.runContainer ?? null; +} + +function getTestContainer(side: 'a' | 'b'): HTMLElement | null { + return cascadeRefs.get(side)?.testContainer ?? null; +} + +function renderRunSelect(side: 'a' | 'b'): void { + const container = getRunContainer(side); + if (!container) return; + renderRunSelectInto(side, container); +} + +function renderRunSelectInto(side: 'a' | 'b', container: HTMLElement): void { + const state = side === 'a' ? sideA : sideB; + container.replaceChildren(); + + if (state.runs.length === 0) { + container.append(el('span', { class: 'no-results' }, 'No runs found.')); + return; + } + + const select = el('select', { class: 'admin-input' }) as HTMLSelectElement; + select.append(el('option', { value: '' }, '-- Select run --') as HTMLOptionElement); + for (const run of state.runs) { + const label = `${run.submitted_at || 'unknown'} ${run.uuid.slice(0, 8)}`; + const opt = el('option', { value: run.uuid }, label) as HTMLOptionElement; + if (run.uuid === state.runUuid) opt.selected = true; + select.append(opt); + } + + select.addEventListener('change', () => { + resetStateFrom(state, 'test'); + state.runUuid = select.value; + clearDownstream(side, 'run'); + syncUrl(); + if (select.value) { + loadProfiles(side); + } else { + renderTestSelect(side); + } + }); + + container.append(select); +} + +function renderTestSelect(side: 'a' | 'b'): void { + const container = getTestContainer(side); + if (!container) return; + renderTestSelectInto(side, container); +} + +function renderTestSelectInto(side: 'a' | 'b', container: HTMLElement): void { + const state = side === 'a' ? sideA : sideB; + container.replaceChildren(); + + if (!state.runUuid) { + renderDisabledSelect(container, 'Select a run first'); + return; + } + + if (state.profiles.length === 0) { + container.append(el('span', { class: 'no-results' }, 'No tests with profiles in this run.')); + return; + } + + const select = el('select', { class: 'admin-input' }) as HTMLSelectElement; + select.append(el('option', { value: '' }, '-- Select test --') as HTMLOptionElement); + for (const profile of state.profiles) { + const opt = el('option', { value: profile.test }, profile.test) as HTMLOptionElement; + if (profile.test === state.testName) opt.selected = true; + select.append(opt); + } + + select.addEventListener('change', () => { + const testName = select.value; + const matched = state.profiles.find(p => p.test === testName); + state.testName = testName; + state.profileUuid = matched?.uuid || ''; + state.metadata = null; + state.functions = []; + state.selectedFunction = ''; + state.functionDetail = null; + syncUrl(); + if (state.profileUuid) { + loadProfile(side); + } else { + clearProfileDisplay(side); + } + }); + + container.append(select); +} + +function renderDisabledSelect(container: HTMLElement, placeholder: string): void { + const select = el('select', { class: 'admin-input', disabled: 'true' }) as HTMLSelectElement; + select.append(el('option', { value: '' }, placeholder) as HTMLOptionElement); + container.replaceChildren(select); +} + +function renderEmptyMessage(container: HTMLElement, msg: string): void { + container.replaceChildren(el('span', { class: 'no-results' }, msg)); +} + +// --------------------------------------------------------------------------- +// Rendering: Stats, controls, function selectors, viewers +// --------------------------------------------------------------------------- + +function updateCounterNames(): void { + const namesA = sideA.metadata ? Object.keys(sideA.metadata.counters) : []; + const namesB = sideB.metadata ? Object.keys(sideB.metadata.counters) : []; + const allNames = [...new Set([...namesA, ...namesB])].sort(); + + if (allNames.length > 0 && (!selectedCounter || !allNames.includes(selectedCounter))) { + selectedCounter = allNames[0]; + } +} + +function renderStats(): void { + if (!statsContainer) return; + statsHandle?.destroy(); + statsHandle = null; + + if (sideA.metadata && sideB.metadata) { + statsHandle = renderProfileStats(statsContainer, sideA.metadata.counters, sideB.metadata.counters); + } else if (sideA.metadata) { + statsHandle = renderProfileStats(statsContainer, sideA.metadata.counters); + } else { + statsContainer.replaceChildren(); + } +} + +function renderGlobalControls(): void { + if (!controlsContainer) return; + controlsContainer.replaceChildren(); + + const allCounterNames = getAllCounterNames(); + if (allCounterNames.length === 0) return; + + // Counter selector + const counterGroup = el('div', { class: 'control-group' }); + counterGroup.append(el('label', {}, 'Counter')); + counterSelect = el('select', { class: 'admin-input' }) as HTMLSelectElement; + for (const name of allCounterNames) { + const opt = el('option', { value: name }, name) as HTMLOptionElement; + if (name === selectedCounter) opt.selected = true; + counterSelect.append(opt); + } + counterSelect.addEventListener('change', () => { + selectedCounter = counterSelect!.value; + rerenderViewers(); + rerenderFunctionSelectors(); + }); + counterGroup.append(counterSelect); + controlsContainer.append(counterGroup); + + // Display mode selector + const modeGroup = el('div', { class: 'control-group' }); + modeGroup.append(el('label', {}, 'Display')); + displayModeSelect = el('select', { class: 'admin-input' }) as HTMLSelectElement; + for (const mode of ['relative', 'absolute', 'cumulative'] as DisplayMode[]) { + const opt = el('option', { value: mode }, mode.charAt(0).toUpperCase() + mode.slice(1)) as HTMLOptionElement; + if (mode === displayMode) opt.selected = true; + displayModeSelect.append(opt); + } + displayModeSelect.addEventListener('change', () => { + displayMode = displayModeSelect!.value as DisplayMode; + rerenderViewers(); + }); + modeGroup.append(displayModeSelect); + controlsContainer.append(modeGroup); +} + +function getAllCounterNames(): string[] { + const names = new Set<string>(); + for (const fn of sideA.functions) { + for (const k of Object.keys(fn.counters)) names.add(k); + } + for (const fn of sideB.functions) { + for (const k of Object.keys(fn.counters)) names.add(k); + } + // Also add from metadata + if (sideA.metadata) for (const k of Object.keys(sideA.metadata.counters)) names.add(k); + if (sideB.metadata) for (const k of Object.keys(sideB.metadata.counters)) names.add(k); + return [...names].sort(); +} + +function renderFunctionSelector(side: 'a' | 'b'): void { + const container = side === 'a' ? fnSelectorAContainer! : fnSelectorBContainer!; + const state = side === 'a' ? sideA : sideB; + container.replaceChildren(); + + if (state.functions.length === 0) return; + + // Sort by selected counter value (hottest first) + const sorted = [...state.functions].sort((a, b) => { + const va = a.counters[selectedCounter] ?? 0; + const vb = b.counters[selectedCounter] ?? 0; + return vb - va; + }); + + const wrapper = el('div', { class: 'profile-fn-combobox combobox' }); + const input = el('input', { + type: 'text', + class: 'combobox-input', + placeholder: 'Type to search functions...', + autocomplete: 'off', + }) as HTMLInputElement; + const dropdown = el('ul', { class: 'combobox-dropdown' }); + wrapper.append(input, dropdown); + + function showDropdown(filter: string): void { + dropdown.replaceChildren(); + const matches = filter.trim() + ? sorted.filter(fn => matchesFilter(fn.name, filter)) + : sorted; + + for (const fn of matches.slice(0, 100)) { + const pct = fn.counters[selectedCounter] ?? 0; + const li = el('li', { class: 'combobox-item', tabindex: '-1' }); + const badge = el('span', { class: 'profile-fn-badge' }, `${pct.toFixed(1)}%`); + badge.style.backgroundColor = heatGradient(pct / 100); + li.append(badge, document.createTextNode(` ${fn.name}`)); + li.addEventListener('click', () => { + input.value = fn.name; + dropdown.classList.remove('open'); + loadFunctionDetail(side, fn.name); + }); + dropdown.append(li); + } + + dropdown.classList.toggle('open', matches.length > 0); + } + + input.addEventListener('input', () => { + updateFilterValidation(input); + showDropdown(input.value); + }); + input.addEventListener('focus', () => showDropdown(input.value)); + input.addEventListener('blur', (e: FocusEvent) => { + if (wrapper.contains(e.relatedTarget as Node)) return; + dropdown.classList.remove('open'); + }); + + // Prevent dropdown clicks from blurring input + dropdown.addEventListener('mousedown', (e) => e.preventDefault()); + + // Keyboard navigation + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { + e.preventDefault(); + const first = dropdown.querySelector<HTMLElement>('.combobox-item'); + if (first) first.focus(); + } else if (e.key === 'Escape') { + dropdown.classList.remove('open'); + } + }); + dropdown.addEventListener('keydown', (e) => { + const target = e.target as HTMLElement; + if (e.key === 'ArrowDown') { + e.preventDefault(); + const next = target.nextElementSibling as HTMLElement | null; + if (next) next.focus(); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + const prev = target.previousElementSibling as HTMLElement | null; + if (prev) prev.focus(); + else input.focus(); + } else if (e.key === 'Enter') { + e.preventDefault(); + target.click(); + } else if (e.key === 'Escape') { + dropdown.classList.remove('open'); + input.focus(); + } + }); + + container.append(wrapper); + + // If a function was already selected (restoration), set it + if (state.selectedFunction) { + input.value = state.selectedFunction; + } +} + +function rerenderFunctionSelectors(): void { + if (sideA.functions.length > 0) renderFunctionSelector('a'); + if (sideB.functions.length > 0) renderFunctionSelector('b'); +} + +function renderViewer(side: 'a' | 'b'): void { + const container = side === 'a' ? viewerAContainer! : viewerBContainer!; + const state = side === 'a' ? sideA : sideB; + const handle = side === 'a' ? viewerHandleA : viewerHandleB; + + // Preserve "show all" state across re-renders + const prevShowAll = handle?.isShowAll() ?? false; + handle?.destroy(); + + if (!state.functionDetail) { + container.replaceChildren(); + return; + } + + const newHandle = renderProfileViewer(container, state.functionDetail, { + counter: selectedCounter, + displayMode, + showAll: prevShowAll, + }); + + if (side === 'a') viewerHandleA = newHandle; + else viewerHandleB = newHandle; +} + +function rerenderViewers(): void { + if (sideA.functionDetail) renderViewer('a'); + if (sideB.functionDetail) renderViewer('b'); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Reset all state downstream of (and including) the given level. */ +function resetStateFrom(state: SideState, level: 'machine' | 'commit' | 'run' | 'test'): void { + if (level === 'machine') { state.machine = ''; state.machineCommits = null; state.machineCommitsLoading = false; } + if (level === 'machine' || level === 'commit') { state.commit = ''; state.runs = []; } + if (level === 'machine' || level === 'commit' || level === 'run') { state.runUuid = ''; state.profiles = []; } + state.testName = ''; + state.profileUuid = ''; + state.metadata = null; + state.functions = []; + state.selectedFunction = ''; + state.functionDetail = null; +} + +function clearDownstream(side: 'a' | 'b', from: 'machine' | 'commit' | 'run'): void { + if (from === 'machine' || from === 'commit') { + const runContainer = getRunContainer(side); + if (runContainer) renderDisabledSelect(runContainer, 'Select a commit first'); + } + if (from === 'machine' || from === 'commit' || from === 'run') { + const testContainer = getTestContainer(side); + if (testContainer) renderDisabledSelect(testContainer, 'Select a run first'); + } + + clearProfileDisplay(side); +} + +function clearProfileDisplay(side: 'a' | 'b'): void { + const fnContainer = side === 'a' ? fnSelectorAContainer : fnSelectorBContainer; + const viewerContainer = side === 'a' ? viewerAContainer : viewerBContainer; + if (fnContainer) fnContainer.replaceChildren(); + if (viewerContainer) viewerContainer.replaceChildren(); + + // Update stats if needed + renderStats(); + renderGlobalControls(); +} + +function syncUrl(): void { + const params = new URLSearchParams(); + if (sideA.suite) params.set('suite_a', sideA.suite); + if (sideA.runUuid) params.set('run_a', sideA.runUuid); + if (sideA.testName) params.set('test_a', sideA.testName); + if (sideB.suite) params.set('suite_b', sideB.suite); + if (sideB.runUuid) params.set('run_b', sideB.runUuid); + if (sideB.testName) params.set('test_b', sideB.testName); + + const search = params.toString(); + const newUrl = `${window.location.pathname}${search ? '?' + search : ''}`; + history.replaceState(null, '', newUrl); +} + +function isAbort(e: unknown): boolean { + return e instanceof DOMException && e.name === 'AbortError'; +} + +function cleanup(): void { + if (controller) { controller.abort(); controller = null; } + machineComboA?.destroy(); machineComboA = null; + machineComboB?.destroy(); machineComboB = null; + commitPickerA?.destroy(); commitPickerA = null; + commitPickerB?.destroy(); commitPickerB = null; + statsHandle?.destroy(); statsHandle = null; + viewerHandleA?.destroy(); viewerHandleA = null; + viewerHandleB?.destroy(); viewerHandleB = null; + if (machineCommitsAbortA) { machineCommitsAbortA.abort(); machineCommitsAbortA = null; } + if (machineCommitsAbortB) { machineCommitsAbortB.abort(); machineCommitsAbortB = null; } + sideA = initialSideState(); + sideB = initialSideState(); + cascadeRefs.clear(); + selectedCounter = ''; + displayMode = 'relative'; + sideAContainer = null; + sideBContainer = null; + statsContainer = null; + controlsContainer = null; + fnSelectorAContainer = null; + fnSelectorBContainer = null; + viewerAContainer = null; + viewerBContainer = null; + counterSelect = null; + displayModeSelect = null; +} diff --git a/lnt/server/ui/v5/frontend/src/pages/regression-detail.ts b/lnt/server/ui/v5/frontend/src/pages/regression-detail.ts new file mode 100644 index 000000000..f6751bf60 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/regression-detail.ts @@ -0,0 +1,883 @@ +// pages/regression-detail.ts — Regression detail page with editable fields, +// indicators table, add indicators panel, and delete section. + +import type { PageModule, RouteParams } from '../router'; +import type { RegressionDetail as RegressionDetailType, RegressionIndicator, RegressionState, FieldInfo } from '../types'; +import { + getRegression, updateRegression, deleteRegression, + addRegressionIndicators, removeRegressionIndicators, + getFields, getMachines, getTests, getToken, authErrorMessage, + getTestSuiteInfoCached, + getCommit, +} from '../api'; +import { el, spaLink, agnosticLink, agnosticUrl, truncate, ensureProtocol, commitDisplayValue, matchesFilter, updateFilterValidation } from '../utils'; +import { renderDataTable, type Column } from '../components/data-table'; +import { renderDeleteConfirm } from '../components/delete-confirm'; +import { renderMetricSelector, filterMetricFields } from '../components/metric-selector'; +import { renderCommitSearch } from '../components/commit-search'; +import { setupCheckboxRange } from '../components/checkbox-range'; +import { ALL_STATES, STATE_META, renderStateBadge } from '../regression-utils'; + +let controller: AbortController | null = null; +/** Track component cleanup handles to prevent resource leaks on unmount. */ +let cleanupFns: (() => void)[] = []; +/** Track the current commit-search cleanup handle separately. */ +let commitSearchCleanup: (() => void) | null = null; +/** Track checkbox range selection cleanup. */ +let checkboxRangeCleanup: (() => void) | null = null; +/** AbortController for in-flight test fetches in Add Indicators. */ +let refreshAbort: AbortController | null = null; +/** Pending debounce timer for indicator filter. */ +let filterDebounceTimer: ReturnType<typeof setTimeout> | null = null; + +export const regressionDetailPage: PageModule = { + mount(container: HTMLElement, params: RouteParams): void { + if (controller) controller.abort(); + controller = new AbortController(); + const { signal } = controller; + + const ts = params.testsuite; + const uuid = params.uuid; + const hasToken = !!getToken(); + + container.append(el('h2', { class: 'page-header' }, `Regression: ${uuid.slice(0, 8)}\u2026`)); + + const loading = el('p', { class: 'progress-label' }, 'Loading regression...'); + container.append(loading); + + let regression: RegressionDetailType; + let fields: FieldInfo[] = []; + const commitFields: Array<{ name: string; display?: boolean }> = []; + const commitDisplayMap = new Map<string, string>(); + + // --- Main layout containers (created lazily after data loads) --- + const headerDiv = el('div', { class: 'regression-header' }); + const headerErrorDiv = el('div', { class: 'regression-header-error' }); + const indicatorsHeading = el('h3', {}, 'Indicators'); + const indicatorFilterDiv = el('div', { class: 'indicator-filter' }); + const indicatorActionsDiv = el('div', { class: 'indicator-actions' }); + const indicatorTableDiv = el('div', { class: 'indicator-table-container' }); + const addHeading = el('h3', {}, 'Add Indicators'); + const addPanelDiv = el('div', { class: 'add-indicators-panel' }); + const addErrorDiv = el('div', { class: 'add-indicators-error' }); + const deleteDiv = el('div', { class: 'delete-section' }); + + function showError(msg: string): void { + headerErrorDiv.replaceChildren(el('p', { class: 'error-banner' }, msg)); + } + + function updatePageTitle(): void { + const headerEl = container.querySelector('.page-header'); + if (headerEl) { + headerEl.textContent = regression.title + ? `Regression: ${regression.title}` + : `Regression: ${uuid.slice(0, 8)}\u2026`; + } + } + + const fetchPromises: [Promise<RegressionDetailType>, Promise<FieldInfo[] | null>] = [ + getRegression(ts, uuid, signal), + hasToken ? getFields(ts, signal) : Promise.resolve(null), + ]; + // Fetch schema in parallel for commit display resolution + const schemaPromise = getTestSuiteInfoCached(ts, signal).then(info => { + commitFields.push(...info.schema.commit_fields); + }).catch(() => {}); + Promise.all([...fetchPromises, schemaPromise]).then(async ([reg, f]) => { + loading.remove(); + regression = reg; + fields = f ?? []; + + // Resolve commit display value if the regression has a commit + if (regression.commit && commitFields.length > 0) { + try { + const detail = await getCommit(ts, regression.commit, signal); + const display = commitDisplayValue(detail, commitFields); + commitDisplayMap.set(regression.commit, display); + } catch { /* graceful degradation */ } + } + + updatePageTitle(); + + container.append(headerDiv, headerErrorDiv); + + if (hasToken) { + container.append(deleteDiv, addHeading, addPanelDiv, addErrorDiv); + } + + container.append( + indicatorsHeading, indicatorFilterDiv, indicatorActionsDiv, indicatorTableDiv, + ); + + renderHeader(); + renderIndicators(); + if (hasToken) { + renderAddPanel(); + renderDeleteSection(); + } + }).catch(e => { + loading.remove(); + if (e instanceof DOMException && e.name === 'AbortError') return; + container.append(el('p', { class: 'error-banner' }, + `Failed to load regression: ${e}`)); + }); + + // --------------------------------------------------------------- + // Header fields + // --------------------------------------------------------------- + + function renderHeader(): void { + headerDiv.replaceChildren(); + + const titleRow = el('div', { class: 'field-row' }); + titleRow.append(el('label', {}, 'Title')); + const titleDisplay = el('span', { class: 'editable-value' }, + regression.title || '(untitled)'); + titleRow.append(titleDisplay); + if (hasToken) { + const editBtn = el('button', { class: 'edit-btn' }, 'Edit'); + editBtn.addEventListener('click', () => renderTitleEdit(titleRow)); + titleRow.append(editBtn); + } + headerDiv.append(titleRow); + + const stateRow = el('div', { class: 'field-row' }); + stateRow.append(el('label', {}, 'State')); + if (hasToken) { + const stateSelect = el('select', { class: 'metric-select' }) as HTMLSelectElement; + for (const s of ALL_STATES) { + const opt = el('option', { value: s }, STATE_META[s].label); + if (s === regression.state) (opt as HTMLOptionElement).selected = true; + stateSelect.append(opt); + } + stateSelect.addEventListener('change', async () => { + const prev = regression.state; + try { + const updated = await updateRegression(ts, uuid, + { state: stateSelect.value as RegressionState }, signal); + regression = updated; + } catch (err: unknown) { + stateSelect.value = prev; + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }); + stateRow.append(stateSelect); + } else { + stateRow.append(renderStateBadge(regression.state)); + } + headerDiv.append(stateRow); + + const bugRow = el('div', { class: 'field-row' }); + bugRow.append(el('label', {}, 'Bug')); + renderBugDisplay(bugRow); + headerDiv.append(bugRow); + + const commitRow = el('div', { class: 'field-row' }); + commitRow.append(el('label', {}, 'Commit')); + renderCommitDisplay(commitRow); + headerDiv.append(commitRow); + + const notesRow = el('div', { class: 'field-row regression-notes' }); + notesRow.append(el('label', {}, 'Notes')); + renderNotesDisplay(notesRow); + headerDiv.append(notesRow); + } + + function renderTitleEdit(row: HTMLElement): void { + const label = row.querySelector('label')!; + row.replaceChildren(); + row.append(label); + + const input = el('input', { + type: 'text', + class: 'admin-input', + }) as HTMLInputElement; + input.value = regression.title || ''; + input.style.flex = '1'; + + const saveBtn = el('button', { class: 'compare-btn' }, 'Save') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + + cancelBtn.addEventListener('click', () => renderHeader()); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + try { + const updated = await updateRegression(ts, uuid, + { title: input.value.trim() }, signal); + regression = updated; + renderHeader(); + updatePageTitle(); + } catch (err: unknown) { + saveBtn.disabled = false; + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }); + + row.append(input, saveBtn, cancelBtn); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveBtn.click(); }); + input.focus(); + } + + function renderBugDisplay(row: HTMLElement): void { + // Keep the label + const label = row.querySelector('label'); + row.replaceChildren(); + if (label) row.append(label); + else row.append(el('label', {}, 'Bug')); + + if (regression.bug) { + const bugUrl = ensureProtocol(regression.bug); + row.append( + el('a', { href: bugUrl, target: '_blank', rel: 'noopener' }, + truncate(regression.bug, 50)), + ); + } else { + row.append(el('span', {}, '(none)')); + } + + if (hasToken) { + const editBtn = el('button', { class: 'edit-btn' }, 'Edit'); + editBtn.addEventListener('click', () => renderBugEdit(row)); + row.append(editBtn); + } + } + + function renderBugEdit(row: HTMLElement): void { + const label = row.querySelector('label')!; + row.replaceChildren(); + row.append(label); + + const input = el('input', { + type: 'url', + class: 'admin-input', + placeholder: 'Bug URL', + }) as HTMLInputElement; + input.value = regression.bug || ''; + + const saveBtn = el('button', { class: 'compare-btn' }, 'Save') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + + cancelBtn.addEventListener('click', () => renderBugDisplay(row)); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + try { + const updated = await updateRegression(ts, uuid, + { bug: input.value.trim() || null }, signal); + regression = updated; + renderBugDisplay(row); + } catch (err: unknown) { + saveBtn.disabled = false; + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }); + + row.append(input, saveBtn, cancelBtn); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') saveBtn.click(); }); + input.focus(); + } + + function renderNotesDisplay(row: HTMLElement): void { + const label = row.querySelector('label'); + row.replaceChildren(); + if (label) row.append(label); + else row.append(el('label', {}, 'Notes')); + + const span = el('span', { class: 'notes-display' }, + regression.notes || '(none)'); + row.append(span); + + if (hasToken) { + const editBtn = el('button', { class: 'edit-btn' }, 'Edit'); + editBtn.addEventListener('click', () => renderNotesEdit(row)); + row.append(editBtn); + } + } + + function renderNotesEdit(row: HTMLElement): void { + const label = row.querySelector('label')!; + row.replaceChildren(); + row.append(label); + + const textarea = el('textarea', { + class: 'regression-notes-input', + }) as HTMLTextAreaElement; + textarea.rows = 3; + textarea.value = regression.notes || ''; + + const saveBtn = el('button', { class: 'compare-btn' }, 'Save') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + + cancelBtn.addEventListener('click', () => renderNotesDisplay(row)); + + saveBtn.addEventListener('click', async () => { + saveBtn.disabled = true; + try { + const updated = await updateRegression(ts, uuid, + { notes: textarea.value || null }, signal); + regression = updated; + renderNotesDisplay(row); + } catch (err: unknown) { + saveBtn.disabled = false; + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }); + + row.append(textarea, saveBtn, cancelBtn); + textarea.addEventListener('keydown', (e) => { + if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) saveBtn.click(); + }); + textarea.focus(); + } + + function renderCommitDisplay(row: HTMLElement): void { + const label = row.querySelector('label'); + row.replaceChildren(); + if (label) row.append(label); + else row.append(el('label', {}, 'Commit')); + + if (regression.commit) { + const display = commitDisplayMap.get(regression.commit) ?? regression.commit; + row.append(spaLink(display, `/commits/${encodeURIComponent(regression.commit)}`)); + if (hasToken) { + const changeBtn = el('button', { class: 'edit-btn' }, 'Change'); + changeBtn.addEventListener('click', () => renderCommitEdit(row)); + const clearBtn = el('button', { class: 'edit-btn' }, 'Clear'); + clearBtn.addEventListener('click', async () => { + try { + const updated = await updateRegression(ts, uuid, { commit: null }, signal); + regression = updated; + renderCommitDisplay(row); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }); + row.append(changeBtn, clearBtn); + } + } else { + row.append(el('span', {}, '(none)')); + if (hasToken) { + const setBtn = el('button', { class: 'edit-btn' }, 'Set'); + setBtn.addEventListener('click', () => renderCommitEdit(row)); + row.append(setBtn); + } + } + } + + function renderCommitEdit(row: HTMLElement): void { + // Destroy previous commit-search instance if any + if (commitSearchCleanup) { commitSearchCleanup(); commitSearchCleanup = null; } + + const label = row.querySelector('label')!; + row.replaceChildren(); + row.append(label); + + const commitDiv = el('div', {}); + const handle = renderCommitSearch(commitDiv, { + testsuite: ts, + placeholder: 'Search commit...', + commitFields, + onSelect: async (value) => { + try { + const updated = await updateRegression(ts, uuid, { commit: value }, signal); + regression = updated; + renderCommitDisplay(row); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + }, + }); + commitSearchCleanup = handle.destroy; + + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + cancelBtn.addEventListener('click', () => { + if (commitSearchCleanup) { commitSearchCleanup(); commitSearchCleanup = null; } + renderCommitDisplay(row); + }); + + row.append(commitDiv, cancelBtn); + } + + // --------------------------------------------------------------- + // Indicators table + // --------------------------------------------------------------- + + function renderIndicators(): void { + if (filterDebounceTimer) { clearTimeout(filterDebounceTimer); filterDebounceTimer = null; } + indicatorActionsDiv.replaceChildren(); + indicatorTableDiv.replaceChildren(); + indicatorFilterDiv.replaceChildren(); + + if (regression.indicators.length === 0) { + indicatorsHeading.textContent = 'Indicators'; + indicatorTableDiv.append(el('p', { class: 'no-results' }, 'No indicators.')); + return; + } + + const filterInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter indicators...', + }) as HTMLInputElement; + indicatorFilterDiv.append(filterInput); + + const allMachines = new Set(regression.indicators.map(i => i.machine).filter(Boolean)); + const allTests = new Set(regression.indicators.map(i => i.test).filter(Boolean)); + const allMetrics = new Set(regression.indicators.map(i => i.metric)); + + function getFilteredIndicators(): RegressionIndicator[] { + const q = filterInput.value.trim(); + if (!q) return regression.indicators; + return regression.indicators.filter(ind => + matchesFilter(ind.machine ?? '', q) || + matchesFilter(ind.test ?? '', q) || + matchesFilter(ind.metric, q), + ); + } + + function plural(n: number, word: string): string { + return `${n} ${word}${n === 1 ? '' : 's'}`; + } + + function updateHeading(filtered: RegressionIndicator[]): void { + if (filterInput.value.trim() && filtered.length < regression.indicators.length) { + const filtMachines = new Set(filtered.map(i => i.machine).filter(Boolean)); + const filtTests = new Set(filtered.map(i => i.test).filter(Boolean)); + const filtMetrics = new Set(filtered.map(i => i.metric)); + indicatorsHeading.textContent = + `Indicators (showing ${filtTests.size} of ${plural(allTests.size, 'test')}` + + ` across ${filtMachines.size} of ${plural(allMachines.size, 'machine')}` + + ` across ${filtMetrics.size} of ${plural(allMetrics.size, 'metric')})`; + } else { + indicatorsHeading.textContent = + `Indicators (${plural(allTests.size, 'test')} across ${plural(allMachines.size, 'machine')} across ${plural(allMetrics.size, 'metric')})`; + } + } + + // Batch remove button + let batchRemoveBtn: HTMLButtonElement | null = null; + if (hasToken) { + batchRemoveBtn = el('button', { + class: 'compare-btn', + disabled: '', + }, 'Remove selected') as HTMLButtonElement; + + batchRemoveBtn.addEventListener('click', () => { + const uuids = Array.from( + indicatorTableDiv.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"][data-uuid]:checked')) + .map(cb => cb.getAttribute('data-uuid')!); + if (uuids.length === 0) return; + doRemoveIndicators(uuids); + }); + indicatorActionsDiv.append(batchRemoveBtn); + } + + function updateBatchSelection(): void { + if (!batchRemoveBtn) return; + const all = indicatorTableDiv.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"][data-uuid]'); + const checked = indicatorTableDiv.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"][data-uuid]:checked'); + batchRemoveBtn.disabled = checked.length === 0; + if (selectAllCb) { + selectAllCb.checked = checked.length === all.length && all.length > 0; + selectAllCb.indeterminate = checked.length > 0 && checked.length < all.length; + } + } + + let selectAllCb: HTMLInputElement | null = null; + const columns: Column<RegressionIndicator>[] = []; + + if (hasToken) { + columns.push({ + key: 'select', + label: '', + sortable: false, + headerRender: () => { + selectAllCb = el('input', { type: 'checkbox' }) as HTMLInputElement; + selectAllCb.addEventListener('change', () => { + const boxes = indicatorTableDiv.querySelectorAll<HTMLInputElement>( + 'input[type="checkbox"][data-uuid]'); + boxes.forEach(box => { box.checked = selectAllCb!.checked; }); + updateBatchSelection(); + }); + return selectAllCb; + }, + render: (ind) => { + const cb = el('input', { type: 'checkbox', 'data-uuid': ind.uuid }) as HTMLInputElement; + cb.addEventListener('change', () => updateBatchSelection()); + return cb; + }, + }); + } + + columns.push( + { + key: 'machine', + label: 'Machine', + render: (ind) => ind.machine + ? spaLink(ind.machine, `/machines/${encodeURIComponent(ind.machine)}`) + : el('span', { class: 'no-results' }, '(deleted)'), + }, + { + key: 'test', + label: 'Test', + render: (ind) => ind.test ?? '(deleted)', + }, + { + key: 'metric', + label: 'Metric', + }, + { + key: 'graph', + label: '', + sortable: false, + render: (ind) => { + if (!ind.machine || !ind.test) return el('span', {}); + const qs = new URLSearchParams({ + suite: ts, + machine: ind.machine, + metric: ind.metric, + test_filter: ind.test, + }); + if (regression.commit) { + qs.set('commit', regression.commit); + } + return agnosticLink('View on graph', `/graph?${qs.toString()}`); + }, + }, + ); + + if (hasToken) { + columns.push({ + key: 'remove', + label: '', + sortable: false, + render: (ind) => { + const btn = el('button', { class: 'row-delete-btn', title: 'Remove indicator' }, '\u00d7'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + doRemoveIndicators([ind.uuid]); + }); + return btn; + }, + }); + } + + function rebuildTable(): void { + indicatorTableDiv.replaceChildren(); + if (checkboxRangeCleanup) { checkboxRangeCleanup(); checkboxRangeCleanup = null; } + + const filtered = getFilteredIndicators(); + updateHeading(filtered); + + const isFiltered = filterInput.value.trim().length > 0; + renderDataTable(indicatorTableDiv, { + columns, + rows: filtered, + emptyMessage: isFiltered ? 'No matching indicators.' : 'No indicators.', + }); + + if (hasToken) { + const handle = setupCheckboxRange( + indicatorTableDiv, + 'input[type="checkbox"][data-uuid]', + () => updateBatchSelection(), + ); + checkboxRangeCleanup = handle.destroy; + updateBatchSelection(); + } + } + + filterInput.addEventListener('input', () => { + updateFilterValidation(filterInput); + if (filterDebounceTimer) clearTimeout(filterDebounceTimer); + filterDebounceTimer = setTimeout(() => { filterDebounceTimer = null; rebuildTable(); }, 200); + }); + rebuildTable(); + } + + async function doRemoveIndicators(uuids: string[]): Promise<void> { + try { + const updated = await removeRegressionIndicators(ts, uuid, uuids, signal); + regression = updated; + renderIndicators(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + showError(authErrorMessage(err)); + } + } + + // --------------------------------------------------------------- + // Add indicators panel + // --------------------------------------------------------------- + + function renderAddPanel(): void { + addPanelDiv.replaceChildren(); + + const selectorsDiv = el('div', { class: 'add-indicator-selectors' }); + + // Metric selector + let selectedMetric = ''; + const metricContainer = el('div', {}); + renderMetricSelector(metricContainer, filterMetricFields(fields), (m) => { + selectedMetric = m; + refreshTests(); + updatePreview(); + }, undefined, { placeholder: true }); + selectorsDiv.append(metricContainer); + + // --- Shared checkbox filter list helper --- + function renderCheckboxFilterList(opts: { + container: HTMLElement; + filterInput: HTMLInputElement; + allItems: () => string[]; + selected: Set<string>; + dataAttr: string; + emptyHint: string; + onChange: () => void; + }): { rerender: () => void; destroy: () => void } { + let rangeHandle: { destroy: () => void } | null = null; + const selector = `input[type="checkbox"][${opts.dataAttr}]`; + + function render(): void { + if (rangeHandle) { rangeHandle.destroy(); rangeHandle = null; } + const filter = opts.filterInput.value; + const items = opts.allItems(); + const filtered = filter + ? items.filter(m => matchesFilter(m, filter)) + : items; + + opts.container.replaceChildren(); + if (filtered.length === 0) { + opts.container.append(el('span', { class: 'test-list-hint' }, + items.length === 0 ? opts.emptyHint : 'No matches')); + return; + } + + for (const name of filtered) { + const row = el('div', { class: 'test-list-row' }); + const cb = el('input', { type: 'checkbox', [opts.dataAttr]: name }) as HTMLInputElement; + cb.checked = opts.selected.has(name); + cb.addEventListener('change', () => { + if (cb.checked) opts.selected.add(name); + else opts.selected.delete(name); + opts.onChange(); + }); + row.append(cb, name); + opts.container.append(row); + } + + rangeHandle = setupCheckboxRange(opts.container, selector, () => { + opts.container.querySelectorAll<HTMLInputElement>(selector).forEach(box => { + const n = box.getAttribute(opts.dataAttr)!; + if (box.checked) opts.selected.add(n); + else opts.selected.delete(n); + }); + opts.onChange(); + }); + } + + opts.filterInput.addEventListener('input', () => { + updateFilterValidation(opts.filterInput); + render(); + }); + + return { + rerender: render, + destroy() { if (rangeHandle) { rangeHandle.destroy(); rangeHandle = null; } }, + }; + } + + // Machine selector (checkbox list with filter) + const selectedMachines = new Set<string>(); + let allMachines: string[] = []; + const machineGroupAdd = el('div', { class: 'control-group' }); + machineGroupAdd.append(el('label', {}, 'Machines')); + const machineFilterInput = el('input', { + type: 'text', + class: 'combobox-input', + placeholder: 'Search machines...', + }) as HTMLInputElement; + const machineListDiv = el('div', { class: 'checkbox-list-container' }); + machineGroupAdd.append(machineFilterInput, machineListDiv); + selectorsDiv.append(machineGroupAdd); + + const machineList = renderCheckboxFilterList({ + container: machineListDiv, + filterInput: machineFilterInput, + allItems: () => allMachines, + selected: selectedMachines, + dataAttr: 'data-machine', + emptyHint: 'Loading machines...', + onChange: () => { refreshTests(); updatePreview(); }, + }); + cleanupFns.push(machineList.destroy); + + getMachines(ts, { limit: 500 }, signal).then(result => { + allMachines = result.items.map(m => m.name); + machineList.rerender(); + }).catch((e: unknown) => { + if (e instanceof DOMException && e.name === 'AbortError') return; + machineListDiv.replaceChildren(el('span', { class: 'error-banner' }, `Failed: ${e}`)); + }); + + // Test selector (checkbox list with filter) + const selectedTests = new Set<string>(); + let allTests: string[] = []; + const testGroup = el('div', { class: 'control-group' }); + testGroup.append(el('label', {}, 'Tests')); + const testFilterInput = el('input', { + type: 'text', + class: 'combobox-input', + placeholder: 'Search tests...', + }) as HTMLInputElement; + const testListDiv = el('div', { class: 'checkbox-list-container' }); + testGroup.append(testFilterInput, testListDiv); + selectorsDiv.append(testGroup); + + const testList = renderCheckboxFilterList({ + container: testListDiv, + filterInput: testFilterInput, + allItems: () => allTests, + selected: selectedTests, + dataAttr: 'data-test', + emptyHint: 'No tests found', + onChange: () => updatePreview(), + }); + cleanupFns.push(testList.destroy); + + async function refreshTests(): Promise<void> { + allTests = []; + testListDiv.replaceChildren(); + if (!selectedMetric || selectedMachines.size === 0) { + selectedTests.clear(); + testListDiv.append(el('span', { class: 'test-list-hint' }, + 'Select metric and machines first')); + updatePreview(); + return; + } + if (refreshAbort) refreshAbort.abort(); + refreshAbort = new AbortController(); + const fetchSignal = refreshAbort.signal; + + testListDiv.replaceChildren(el('span', { class: 'test-list-hint' }, 'Loading tests...')); + try { + const results = await Promise.all( + [...selectedMachines].map(machine => + getTests(ts, { machine, metric: selectedMetric, limit: 500 }, fetchSignal) + .then(r => r.items.map(t => t.name)) + .catch((e: unknown) => { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + return [] as string[]; + }), + ), + ); + allTests = [...new Set(results.flat())].sort(); + // Prune selections no longer available + for (const t of selectedTests) { + if (!allTests.includes(t)) selectedTests.delete(t); + } + testList.rerender(); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') return; + testListDiv.replaceChildren(el('span', { class: 'error-banner' }, `Failed: ${e}`)); + } + } + + addPanelDiv.append(selectorsDiv); + + const previewDiv = el('div', { class: 'add-indicator-preview' }); + const previewSpan = el('span', {}, 'Select metric, machine, and tests to add indicators'); + previewDiv.append(previewSpan); + addPanelDiv.append(previewDiv); + + const addActionsDiv = el('div', { class: 'add-indicator-actions' }); + const addBtn = el('button', { class: 'compare-btn', disabled: '' }, 'Add') as HTMLButtonElement; + addActionsDiv.append(addBtn); + addPanelDiv.append(addActionsDiv); + + function updatePreview(): void { + const count = selectedMachines.size * selectedTests.size; + if (!selectedMetric || selectedMachines.size === 0 || selectedTests.size === 0) { + previewSpan.textContent = 'Select metric, machines, and tests to add indicators'; + addBtn.disabled = true; + } else { + previewSpan.textContent = `This will add ${count} indicator${count !== 1 ? 's' : ''}`; + addBtn.disabled = false; + } + } + + addBtn.addEventListener('click', async () => { + if (!selectedMetric || selectedMachines.size === 0 || selectedTests.size === 0) return; + addBtn.disabled = true; + addErrorDiv.replaceChildren(); + + const indicators = [...selectedMachines].flatMap(machine => + [...selectedTests].map(test => ({ + machine, + test, + metric: selectedMetric, + })), + ); + + try { + const updated = await addRegressionIndicators(ts, uuid, indicators, signal); + regression = updated; + renderIndicators(); + // Reset panel to clean state + selectedMachines.clear(); + machineList.rerender(); + selectedMetric = ''; + const metricSelect = metricContainer.querySelector('select') as HTMLSelectElement | null; + if (metricSelect) metricSelect.value = ''; + refreshTests(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + addErrorDiv.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + } finally { + addBtn.disabled = false; + } + }); + } + + // --------------------------------------------------------------- + // Delete regression section + // --------------------------------------------------------------- + + function renderDeleteSection(): void { + renderDeleteConfirm(deleteDiv, { + label: 'Delete Regression', + prompt: `Type "${uuid.slice(0, 8)}" to confirm deletion. This will delete the regression and all its indicators.`, + confirmValue: uuid.slice(0, 8), + placeholder: 'Regression UUID prefix', + onDelete: () => deleteRegression(ts, uuid, signal), + onSuccess: () => { + window.location.assign( + agnosticUrl(`/test-suites?suite=${encodeURIComponent(ts)}&tab=regressions`)); + }, + }); + } + }, + + unmount(): void { + controller?.abort(); + controller = null; + if (commitSearchCleanup) { commitSearchCleanup(); commitSearchCleanup = null; } + if (checkboxRangeCleanup) { checkboxRangeCleanup(); checkboxRangeCleanup = null; } + if (filterDebounceTimer) { clearTimeout(filterDebounceTimer); filterDebounceTimer = null; } + if (refreshAbort) { refreshAbort.abort(); refreshAbort = null; } + cleanupFns.forEach(fn => fn()); + cleanupFns = []; + }, +}; diff --git a/lnt/server/ui/v5/frontend/src/pages/regression-list.ts b/lnt/server/ui/v5/frontend/src/pages/regression-list.ts new file mode 100644 index 000000000..005d1c182 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/regression-list.ts @@ -0,0 +1,422 @@ +// pages/regression-list.ts — Regression tab renderer with filters, sortable +// table, cursor pagination, create form, and per-row delete. +// Called by test-suites.ts to render the Regressions tab content. + +import type { RegressionListItem, RegressionState } from '../types'; +import { + getRegressions, createRegression, deleteRegression, getFields, + getToken, authErrorMessage, getTestSuiteInfoCached, +} from '../api'; +import type { CursorPageResult } from '../api'; +import { el, truncate, debounce, ensureProtocol, resolveDisplayMap, matchesFilter, updateFilterValidation } from '../utils'; +import { renderDataTable, type Column } from '../components/data-table'; +import { renderPagination } from '../components/pagination'; +import { renderMachineCombobox } from '../components/machine-combobox'; +import { renderMetricSelector, filterMetricFields } from '../components/metric-selector'; +import { renderCommitSearch } from '../components/commit-search'; +import { ALL_STATES, STATE_META, renderStateBadge } from '../regression-utils'; + +const PAGE_SIZE = 25; + +export interface RegressionTabOptions { + container: HTMLElement; + testsuite: string; + signal: AbortSignal; + trackCleanup: (fn: () => void) => void; + /** Build a link to a suite-scoped detail page. */ + detailLink: (text: string, path: string) => HTMLAnchorElement; + /** Navigate to a regression detail page by UUID. */ + navigateToDetail: (uuid: string) => void; +} + +export function renderRegressionTab(opts: RegressionTabOptions): void { + const { container, signal } = opts; + const ts = opts.testsuite; + const hasToken = !!getToken(); + const commitFields: Array<{ name: string; display?: boolean }> = []; + + // Pre-fetch schema for commit display resolution (non-blocking) + getTestSuiteInfoCached(ts, signal).then(info => { + commitFields.push(...info.schema.commit_fields); + }).catch(() => {}); + + // --- Filter panel --- + const filtersDiv = el('div', { class: 'regression-filters' }); + const stateChipsDiv = el('div', { class: 'state-chips' }); + const filterRow1 = el('div', { class: 'filter-row' }); + const filterRow2 = el('div', { class: 'filter-row' }); + filtersDiv.append(stateChipsDiv, filterRow1, filterRow2); + + // --- Actions bar --- + const actionsDiv = el('div', { class: 'regression-actions' }); + + // --- Create form (initially hidden) --- + const createFormContainer = el('div', { class: 'create-form-container' }); + createFormContainer.style.display = 'none'; + + // --- Error area --- + const errorDiv = el('div', { class: 'regression-list-error' }); + + // --- Table and pagination --- + const tableContainer = el('div', { class: 'regression-table-container' }); + const paginationDiv = el('div', { class: 'regression-pagination' }); + + container.append( + filtersDiv, actionsDiv, createFormContainer, + errorDiv, tableContainer, paginationDiv, + ); + + // --- Filter state --- + const activeStates = new Set<RegressionState>(); + let machineFilter = ''; + let metricFilter = ''; + let hasCommitFilter: boolean | undefined; + let titleSearch = ''; + const cursorStack: string[] = []; + let currentCursor: string | undefined; + + // --- State chips --- + function renderStateChips(): void { + stateChipsDiv.replaceChildren(); + for (const state of ALL_STATES) { + const meta = STATE_META[state]; + const active = activeStates.has(state); + const chip = el('button', { + class: `state-chip${active ? ' state-chip-active' : ''}`, + 'data-state': state, + }, meta.label); + chip.addEventListener('click', () => { + if (activeStates.has(state)) { + activeStates.delete(state); + } else { + activeStates.add(state); + } + resetAndLoad(); + renderStateChips(); + }); + stateChipsDiv.append(chip); + } + } + renderStateChips(); + + // --- Machine filter --- + const machineGroup = el('div', { class: 'control-group' }); + machineGroup.append(el('label', {}, 'Machine')); + const machineInputContainer = el('div', {}); + machineGroup.append(machineInputContainer); + const machineHandle = renderMachineCombobox(machineInputContainer, { + testsuite: ts, + onSelect: (name) => { + machineFilter = name; + resetAndLoad(); + }, + onClear: () => { + machineFilter = ''; + resetAndLoad(); + }, + }); + opts.trackCleanup(machineHandle.destroy); + filterRow1.append(machineGroup); + + // --- Metric filter --- + const metricGroup = el('div', {}); + filterRow1.append(metricGroup); + + getFields(ts, signal).then(fields => { + renderMetricSelector(metricGroup, filterMetricFields(fields), (m) => { + metricFilter = m; + resetAndLoad(); + }, undefined, { placeholder: true }); + }).catch(() => { + metricGroup.append(el('span', { class: 'progress-label' }, 'Failed to load metrics')); + }); + + // --- Has commit checkbox --- + const hasCommitLabel = el('label', { class: 'control-group control-group-checkbox' }); + const hasCommitCb = el('input', { type: 'checkbox' }) as HTMLInputElement; + hasCommitLabel.append(hasCommitCb, ' Has commit'); + hasCommitCb.addEventListener('change', () => { + hasCommitFilter = hasCommitCb.checked ? true : undefined; + resetAndLoad(); + }); + filterRow1.append(hasCommitLabel); + + // --- Title search (client-side) --- + const titleInput = el('input', { + type: 'text', + class: 'title-search-input admin-input', + placeholder: 'Search title...', + }) as HTMLInputElement; + const doTitleFilter = debounce(() => { + titleSearch = titleInput.value; + renderTable(lastResult, lastDisplayMap); + }, 300); + titleInput.addEventListener('input', () => { + updateFilterValidation(titleInput); + doTitleFilter(); + }); + filterRow2.append(titleInput); + + // --- "New Regression" button (auth-gated) --- + if (hasToken) { + const newBtn = el('button', { class: 'compare-btn' }, 'New Regression'); + newBtn.addEventListener('click', () => { + createFormContainer.style.display = + createFormContainer.style.display === 'none' ? '' : 'none'; + }); + actionsDiv.append(newBtn); + + // --- Create form --- + renderCreateForm(createFormContainer, ts, signal, (fn) => opts.trackCleanup(fn), opts.navigateToDetail, commitFields); + } + + // --- Load page --- + let lastResult: CursorPageResult<RegressionListItem> = { items: [], nextCursor: null }; + let lastDisplayMap = new Map<string, string>(); + + function resetAndLoad(): void { + cursorStack.length = 0; + currentCursor = undefined; + loadPage(); + } + + async function loadPage(): Promise<void> { + tableContainer.replaceChildren( + el('p', { class: 'progress-label' }, 'Loading regressions...'), + ); + paginationDiv.replaceChildren(); + errorDiv.replaceChildren(); + + try { + const result = await getRegressions(ts, { + state: activeStates.size > 0 ? [...activeStates] : undefined, + machine: machineFilter || undefined, + metric: metricFilter || undefined, + has_commit: hasCommitFilter, + limit: PAGE_SIZE, + cursor: currentCursor, + }, signal); + + lastResult = result; + + // Resolve commit display values before rendering + const commitStrings = result.items + .map(r => r.commit).filter((c): c is string => c !== null); + const displayMap = await resolveDisplayMap(ts, commitStrings, signal); + lastDisplayMap = displayMap; + + renderTable(result, displayMap); + + renderPagination(paginationDiv, { + hasPrevious: cursorStack.length > 0, + hasNext: result.nextCursor !== null, + onPrevious: () => { currentCursor = cursorStack.pop(); loadPage(); }, + onNext: () => { + cursorStack.push(currentCursor || ''); + currentCursor = result.nextCursor!; + loadPage(); + }, + }); + } catch (e: unknown) { + if (e instanceof DOMException && e.name === 'AbortError') return; + tableContainer.replaceChildren(); + errorDiv.replaceChildren( + el('p', { class: 'error-banner' }, `Failed to load regressions: ${e}`), + ); + } + } + + function renderTable(result: CursorPageResult<RegressionListItem>, displayMap: Map<string, string>): void { + let rows = result.items; + if (titleSearch) { + rows = rows.filter(r => + matchesFilter(r.title || '', titleSearch)); + } + + tableContainer.replaceChildren(); + + const columns: Column<RegressionListItem>[] = [ + { + key: 'title', + label: 'Title', + render: (r) => opts.detailLink( + truncate(r.title || '(untitled)', 60), + `/regressions/${encodeURIComponent(r.uuid)}`, + ), + sortValue: (r) => r.title || '', + }, + { + key: 'state', + label: 'State', + render: (r) => renderStateBadge(r.state), + sortValue: (r) => ALL_STATES.indexOf(r.state), + }, + { + key: 'commit', + label: 'Commit', + render: (r) => r.commit + ? opts.detailLink(truncate(displayMap.get(r.commit) ?? r.commit, 12), `/commits/${encodeURIComponent(r.commit)}`) + : '\u2014', + sortValue: (r) => r.commit || '', + }, + { + key: 'machine_count', + label: 'Machines', + cellClass: 'col-num', + sortValue: (r) => r.machine_count, + }, + { + key: 'test_count', + label: 'Tests', + cellClass: 'col-num', + sortValue: (r) => r.test_count, + }, + { + key: 'bug', + label: 'Bug', + render: (r) => r.bug + ? el('a', { href: ensureProtocol(r.bug), target: '_blank', rel: 'noopener' }, 'Link') + : '\u2014', + sortable: false, + }, + ]; + + // Add delete column if auth'd + if (hasToken) { + columns.push({ + key: 'actions', + label: '', + sortable: false, + render: (r) => { + const btn = el('button', { + class: 'row-delete-btn', + title: 'Delete regression', + }, '\u00d7'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + confirmAndDelete(r); + }); + return btn; + }, + }); + } + + renderDataTable(tableContainer, { + columns, + rows, + onRowClick: (r) => opts.navigateToDetail(r.uuid), + emptyMessage: 'No regressions found.', + }); + } + + async function confirmAndDelete(r: RegressionListItem): Promise<void> { + const label = r.title || r.uuid.slice(0, 8); + if (!window.confirm(`Delete regression "${label}"? This cannot be undone.`)) return; + try { + await deleteRegression(ts, r.uuid, signal); + loadPage(); + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + errorDiv.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + } + } + + // Start loading + loadPage(); +} + +// --------------------------------------------------------------------------- +// Create Regression Form +// --------------------------------------------------------------------------- + +function renderCreateForm( + container: HTMLElement, + ts: string, + signal: AbortSignal, + trackCleanup: (fn: () => void) => void, + navigateToDetail: (uuid: string) => void, + commitFields: Array<{ name: string; display?: boolean }>, +): void { + const titleInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: 'Regression title', + }) as HTMLInputElement; + + const stateSelect = el('select', { class: 'metric-select' }) as HTMLSelectElement; + for (const state of ALL_STATES) { + const opt = el('option', { value: state }, STATE_META[state].label); + if (state === 'detected') (opt as HTMLOptionElement).selected = true; + stateSelect.append(opt); + } + + const bugInput = el('input', { + type: 'text', + class: 'admin-input', + placeholder: 'Bug URL (optional)', + }) as HTMLInputElement; + + let selectedCommit = ''; + const commitGroup = el('div', { class: 'control-group' }); + commitGroup.append(el('label', {}, 'Commit')); + const commitContainer = el('div', {}); + commitGroup.append(commitContainer); + const commitHandle = renderCommitSearch(commitContainer, { + testsuite: ts, + placeholder: 'Search commit...', + commitFields, + onSelect: (value) => { selectedCommit = value; }, + }); + trackCleanup(commitHandle.destroy); + + const createBtn = el('button', { class: 'compare-btn' }, 'Create') as HTMLButtonElement; + const cancelBtn = el('button', { class: 'pagination-btn' }, 'Cancel'); + const errorArea = el('div', { class: 'create-form-error' }); + + cancelBtn.addEventListener('click', () => { + container.style.display = 'none'; + titleInput.value = ''; + bugInput.value = ''; + selectedCommit = ''; + commitHandle.clear(); + errorArea.replaceChildren(); + }); + + createBtn.addEventListener('click', async () => { + createBtn.disabled = true; + errorArea.replaceChildren(); + + const body: Record<string, unknown> = {}; + const title = titleInput.value.trim(); + if (title) body.title = title; + body.state = stateSelect.value; + const bug = bugInput.value.trim(); + if (bug) body.bug = bug; + if (selectedCommit) body.commit = selectedCommit; + + try { + const created = await createRegression(ts, body, signal); + navigateToDetail(created.uuid); + } catch (err: unknown) { + createBtn.disabled = false; + if (err instanceof DOMException && err.name === 'AbortError') return; + errorArea.replaceChildren( + el('p', { class: 'error-banner' }, authErrorMessage(err)), + ); + } + }); + + container.append( + el('div', { class: 'admin-form-row' }, + el('label', {}, 'Title:'), titleInput), + el('div', { class: 'admin-form-row' }, + el('label', {}, 'State:'), stateSelect), + el('div', { class: 'admin-form-row' }, + el('label', {}, 'Bug:'), bugInput), + commitGroup, + el('div', { class: 'admin-form-row' }, createBtn, cancelBtn), + errorArea, + ); +} diff --git a/lnt/server/ui/v5/frontend/src/pages/run-detail.ts b/lnt/server/ui/v5/frontend/src/pages/run-detail.ts new file mode 100644 index 000000000..11d3e4d1e --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/run-detail.ts @@ -0,0 +1,222 @@ +// pages/run-detail.ts — Single run metadata, samples table with progressive +// loading, test filter, metric selector, and run deletion. + +import type { PageModule, RouteParams } from '../router'; +import type { SampleInfo, ProfileListItem } from '../types'; +import { getRun, getFields, getProfilesForRun, deleteRun, fetchOneCursorPage, apiUrl, getTestSuiteInfoCached, getCommit } from '../api'; +import { el, spaLink, agnosticLink, formatValue, formatTime, debounce, commitDisplayValue, matchesFilter, updateFilterValidation } from '../utils'; +import { navigate } from '../router'; +import { renderDataTable } from '../components/data-table'; +import { renderMetricSelector, filterMetricFields } from '../components/metric-selector'; +import { renderDeleteConfirm } from '../components/delete-confirm'; + +/** Instance-scoped abort controller for the current mount's sample fetches. */ +let activeFetchController: AbortController | null = null; + +export const runDetailPage: PageModule = { + mount(container: HTMLElement, params: RouteParams): void { + // Abort any in-flight fetches from a previous mount before creating a new controller. + if (activeFetchController) activeFetchController.abort(); + activeFetchController = new AbortController(); + + const ts = params.testsuite; + const uuid = params.uuid; + + container.append(el('h2', { class: 'page-header' }, `Run: ${uuid.slice(0, 8)}\u2026`)); + + const metaContainer = el('div', {}); + const actionsContainer = el('div', { class: 'action-links' }); + const deleteConfirmDiv = el('div', {}); + const controlsContainer = el('div', { class: 'global-controls' }); + const filterContainer = el('div', { class: 'table-controls' }); + const summaryContainer = el('div', {}); + const tableContainer = el('div', {}); + container.append( + metaContainer, actionsContainer, deleteConfirmDiv, + controlsContainer, filterContainer, summaryContainer, tableContainer, + ); + + const loading = el('p', { class: 'progress-label' }, 'Loading run data...'); + container.append(loading); + + let allSamples: SampleInfo[] = []; + let currentMetric = ''; + let testFilter = ''; + let machineName = ''; + let profileTestSet = new Set<string>(); + + Promise.all([ + getRun(ts, uuid), + getFields(ts), + getTestSuiteInfoCached(ts).catch(() => null), + getProfilesForRun(ts, uuid, activeFetchController!.signal).catch(() => [] as ProfileListItem[]), + ]).then(async ([run, fields, suiteInfo, profiles]) => { + loading.remove(); + machineName = run.machine; + profileTestSet = new Set(profiles.map(p => p.test)); + + // Resolve commit display value (fetch CommitDetail for fields) + let commitDisplay = run.commit; + if (suiteInfo) { + try { + const commitDetail = await getCommit(ts, run.commit); + commitDisplay = commitDisplayValue(commitDetail, suiteInfo.schema.commit_fields); + } catch { /* graceful degradation */ } + } + + // Metadata + const dl = el('dl', { class: 'metadata-dl' }); + dl.append(el('dt', {}, 'UUID'), el('dd', {}, run.uuid)); + + const machineDd = el('dd', {}); + machineDd.append(spaLink(run.machine, `/machines/${encodeURIComponent(run.machine)}`)); + dl.append(el('dt', {}, 'Machine'), machineDd); + + const commitDd = el('dd', {}); + commitDd.append(spaLink(commitDisplay, `/commits/${encodeURIComponent(run.commit)}`)); + dl.append(el('dt', {}, 'Commit'), commitDd); + + dl.append(el('dt', {}, 'Submitted'), el('dd', {}, formatTime(run.submitted_at))); + + for (const [k, v] of Object.entries(run.run_parameters || {})) { + dl.append(el('dt', {}, k), el('dd', {}, v)); + } + metaContainer.append(dl); + + // Actions + const compareLink = agnosticLink( + 'Compare with\u2026', + `/compare?suite_a=${encodeURIComponent(ts)}&machine_a=${encodeURIComponent(run.machine)}&commit_a=${encodeURIComponent(run.commit)}&runs_a=${encodeURIComponent(uuid)}`, + ); + compareLink.classList.add('action-link'); + actionsContainer.append(compareLink); + + // Delete (button in actions row, confirmation below) + const shortUuid = uuid.slice(0, 8); + renderDeleteConfirm(actionsContainer, { + label: 'Delete Run', + prompt: `Type "${shortUuid}" to confirm deletion. This will delete the run and all its samples.`, + confirmValue: shortUuid, + placeholder: 'Run UUID prefix', + onDelete: () => deleteRun(ts, uuid), + onSuccess: () => navigate(`/machines/${encodeURIComponent(machineName)}`), + confirmContainer: deleteConfirmDiv, + }); + + // Metric selector + currentMetric = renderMetricSelector(controlsContainer, filterMetricFields(fields), (metric) => { + currentMetric = metric; + renderSamplesTable(); + }); + + // Test filter + const filterInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter tests...', + }) as HTMLInputElement; + const doFilter = debounce(() => { + testFilter = filterInput.value; + renderSamplesTable(); + }, 200); + filterInput.addEventListener('input', () => { + updateFilterValidation(filterInput); + doFilter(); + }); + filterContainer.append(filterInput); + + // Progressive sample loading + loadSamplesProgressively(ts, uuid); + }).catch(e => { + loading.remove(); + container.append(el('p', { class: 'error-banner' }, `Failed to load run: ${e}`)); + }); + + async function loadSamplesProgressively(tsName: string, runUuid: string): Promise<void> { + if (!activeFetchController) return; + const { signal } = activeFetchController; + const progressEl = el('p', { class: 'progress-label' }, 'Loading samples...'); + summaryContainer.append(progressEl); + + let cursor: string | null = null; + try { + do { + const params: Record<string, string> = { limit: '2000' }; + if (cursor) params.cursor = cursor; + const page = await fetchOneCursorPage<SampleInfo>( + apiUrl(tsName, `runs/${encodeURIComponent(runUuid)}/samples`), + params, + signal, + ); + allSamples.push(...page.items); + cursor = page.nextCursor; + progressEl.textContent = `Loading samples: ${allSamples.length}${cursor ? '...' : ''}`; + renderSamplesTable(); + } while (cursor); + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') return; + summaryContainer.replaceChildren( + el('p', { class: 'error-banner' }, `Failed to load samples: ${err}`), + ); + return; + } + progressEl.remove(); + } + + let filterMessage: HTMLElement | null = null; + + function filteredSamples(): SampleInfo[] { + if (!testFilter) return allSamples; + return allSamples.filter(s => matchesFilter(s.test, testFilter)); + } + + function renderSamplesTable(): void { + const visible = filteredSamples(); + + if (filterMessage) { filterMessage.remove(); filterMessage = null; } + if (testFilter && visible.length !== allSamples.length) { + filterMessage = el('p', { class: 'table-message' }, + `${visible.length} of ${allSamples.length} samples matching`); + summaryContainer.prepend(filterMessage); + } + + tableContainer.replaceChildren(); + const columns = [ + { key: 'test', label: 'Test', cellClass: 'col-test', + render: (s: SampleInfo) => s.test }, + { key: 'value', label: 'Value', cellClass: 'col-num', + render: (s: SampleInfo) => formatValue(s.metrics[currentMetric] !== undefined ? s.metrics[currentMetric] : null), + sortValue: (s: SampleInfo) => s.metrics[currentMetric] ?? null }, + ]; + if (profileTestSet.size > 0) { + columns.push({ + key: 'profile', + label: 'Profile', + cellClass: 'col-profile', + sortable: false, + render: (s: SampleInfo) => { + if (!profileTestSet.has(s.test)) return ''; + const params = new URLSearchParams({ + suite_a: ts, run_a: uuid, test_a: s.test, + }); + return agnosticLink('View', `/profiles?${params.toString()}`); + }, + } as typeof columns[0]); + } + renderDataTable(tableContainer, { + columns, + rows: visible, + sortKey: 'test', + sortDir: 'asc', + emptyMessage: testFilter ? 'No samples matching filter.' : 'No samples found.', + }); + } + }, + + unmount(): void { + if (activeFetchController) { + activeFetchController.abort(); + activeFetchController = null; + } + }, +}; diff --git a/lnt/server/ui/v5/frontend/src/pages/test-suites.ts b/lnt/server/ui/v5/frontend/src/pages/test-suites.ts new file mode 100644 index 000000000..ac6e061df --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/pages/test-suites.ts @@ -0,0 +1,535 @@ +// pages/test-suites.ts — Test Suites page with suite picker and browsing tabs. +// Suite-agnostic — served at /v5/test-suites. + +import type { PageModule, RouteParams } from '../router'; +import type { MachineInfo, RunInfo, CommitSummary } from '../types'; +import type { CursorPageResult } from '../api'; +import { getTestsuites } from '../router'; +import { getMachines, getRunsPage, getCommitsPage, getTestSuiteInfoCached } from '../api'; +import type { Column } from '../components/data-table'; +import { el, formatTime, truncate, debounce, commitDisplayValue, resolveDisplayMap } from '../utils'; +import { renderDataTable } from '../components/data-table'; +import { renderPagination } from '../components/pagination'; +import { renderRegressionTab } from './regression-list'; + +const PAGE_SIZE = 25; + +type TabId = 'recent' | 'machines' | 'runs' | 'commits' | 'regressions'; + +let tabController: AbortController | null = null; +let tabCleanupFns: (() => void)[] = []; + +/** Build a full href to a suite-scoped detail page (full page navigation). */ +function suiteHref(suite: string, path: string): string { + // lnt_url_base is set as a global by the HTML template + const base = typeof (globalThis as Record<string, unknown>).lnt_url_base === 'string' + ? (globalThis as Record<string, unknown>).lnt_url_base as string + : ''; + return `${base}/v5/${encodeURIComponent(suite)}${path}`; +} + +/** Create a plain <a> link for full page navigation (not SPA). */ +function detailLink(text: string, suite: string, path: string): HTMLAnchorElement { + return el('a', { href: suiteHref(suite, path) }, text) as HTMLAnchorElement; +} + +export const testSuitesPage: PageModule = { + mount(container: HTMLElement, _params: RouteParams): void { + // Abort any previous tab load + if (tabController) tabController.abort(); + + const suites = getTestsuites(); + + // Read initial state from URL query params + const urlParams = new URLSearchParams(window.location.search); + let selectedSuite = urlParams.get('suite') || ''; + let activeTab: TabId = (urlParams.get('tab') as TabId) || 'recent'; + let currentSearch = urlParams.get('search') || ''; + let commitFields: Array<{ name: string; display?: boolean }> = []; + + container.append(el('h2', { class: 'page-header' }, 'Test Suites')); + + // --- Suite picker --- + const picker = el('div', { class: 'suite-picker' }); + const cardMap = new Map<string, HTMLElement>(); + + for (const name of suites) { + const card = el('button', { class: 'suite-card' }, name); + if (name === selectedSuite) card.classList.add('suite-card-active'); + card.addEventListener('click', () => { + if (selectedSuite === name) return; + selectSuite(name); + }); + cardMap.set(name, card); + picker.append(card); + } + + if (suites.length === 0) { + picker.append(el('p', {}, 'No test suites available.')); + } + container.append(picker); + + // --- Tab bar (hidden until suite selected) --- + const tabBar = el('div', { class: 'v5-tab-bar', style: selectedSuite ? '' : 'display:none' }); + const tabDefs: Array<{ id: TabId; label: string }> = [ + { id: 'recent', label: 'Recent Activity' }, + { id: 'machines', label: 'Machines' }, + { id: 'runs', label: 'Runs' }, + { id: 'commits', label: 'Commits' }, + { id: 'regressions', label: 'Regressions' }, + ]; + const tabButtons: HTMLElement[] = []; + for (const tab of tabDefs) { + const btn = el('button', { + class: `v5-tab${tab.id === activeTab ? ' v5-tab-active' : ''}`, + 'data-tab': tab.id, + }, tab.label); + btn.addEventListener('click', () => { + if (activeTab === tab.id) return; + activeTab = tab.id; + currentSearch = ''; + activateTab(tab.id); + syncUrl(); + loadTabContent(); + }); + tabButtons.push(btn); + tabBar.append(btn); + } + container.append(tabBar); + + // --- Tab content area --- + const tabContent = el('div', { class: 'v5-tab-content' }); + container.append(tabContent); + + function activateTab(tabId: TabId): void { + for (const btn of tabButtons) { + btn.classList.toggle('v5-tab-active', btn.getAttribute('data-tab') === tabId); + } + } + + function selectSuite(name: string): void { + for (const [n, card] of cardMap) { + card.classList.toggle('suite-card-active', n === name); + } + selectedSuite = name; + currentSearch = ''; + activeTab = 'recent'; + activateTab('recent'); + tabBar.style.display = ''; + // Pre-fetch schema for commit display resolution (cached after first call) + getTestSuiteInfoCached(name).then(info => { + commitFields = info.schema.commit_fields; + }).catch(() => { /* graceful degradation: commitFields stays [] */ }); + syncUrl(); + loadTabContent(); + } + + function syncUrl(): void { + const params = new URLSearchParams(); + if (selectedSuite) params.set('suite', selectedSuite); + if (activeTab && activeTab !== 'recent') params.set('tab', activeTab); + if (currentSearch) params.set('search', currentSearch); + const qs = params.toString(); + window.history.replaceState(null, '', window.location.pathname + (qs ? '?' + qs : '')); + } + + function loadTabContent(): void { + // Clean up any resources from the previous tab + tabCleanupFns.forEach(fn => fn()); + tabCleanupFns = []; + // Abort any previous tab load to prevent race conditions + if (tabController) tabController.abort(); + tabController = new AbortController(); + const signal = tabController.signal; + + tabContent.replaceChildren(); + if (!selectedSuite) return; + + switch (activeTab) { + case 'recent': + renderRecentActivityTab(tabContent, selectedSuite, signal); + break; + case 'machines': + renderMachinesTab(tabContent, selectedSuite, currentSearch, signal, + (search: string) => { currentSearch = search; syncUrl(); }); + break; + case 'runs': { + const runsDisplayMap = new Map<string, string>(); + renderCursorPaginatedTab(tabContent, selectedSuite, currentSearch, signal, + 'Filter by machine name...', 'Loading runs...', 'No runs found.', + 'Failed to load runs', + (s, opts, sig) => getRunsPage(s, { + machine: opts.search || undefined, + sort: '-submitted_at', + limit: opts.limit, + cursor: opts.cursor, + }, sig), + runsColumns(selectedSuite, runsDisplayMap), + (search: string) => { currentSearch = search; syncUrl(); }, + async (items: RunInfo[], sig: AbortSignal) => { + const commits = [...new Set(items.map(r => r.commit))]; + const resolved = await resolveDisplayMap(selectedSuite, commits, sig); + for (const [k, v] of resolved) runsDisplayMap.set(k, v); + }); + break; + } + case 'commits': + renderCursorPaginatedTab(tabContent, selectedSuite, currentSearch, signal, + 'Search commits...', 'Loading commits...', 'No commits found.', + 'Failed to load commits', + (s, opts, sig) => getCommitsPage(s, { + search: opts.search || undefined, + limit: opts.limit, + cursor: opts.cursor, + }, sig), + commitsColumns(selectedSuite, commitFields), + (search: string) => { currentSearch = search; syncUrl(); }); + break; + case 'regressions': + renderRegressionTab({ + container: tabContent, + testsuite: selectedSuite, + signal, + trackCleanup: (fn) => tabCleanupFns.push(fn), + detailLink: (text, path) => detailLink(text, selectedSuite, path), + navigateToDetail: (uuid) => { + window.location.href = suiteHref(selectedSuite, + `/regressions/${encodeURIComponent(uuid)}`); + }, + }); + break; + } + } + + // Load initial content if suite was pre-selected from URL + if (selectedSuite) { + getTestSuiteInfoCached(selectedSuite).then(info => { + commitFields = info.schema.commit_fields; + }).catch(() => {}); + loadTabContent(); + } + }, + + unmount(): void { + tabCleanupFns.forEach(fn => fn()); + tabCleanupFns = []; + if (tabController) { tabController.abort(); tabController = null; } + }, +}; + +// --------------------------------------------------------------------------- +// Recent Activity Tab +// --------------------------------------------------------------------------- + +function renderRecentActivityTab( + container: HTMLElement, + suite: string, + signal: AbortSignal, +): void { + container.append(el('p', { class: 'progress-label' }, 'Loading recent activity...')); + + // Accumulate all loaded runs for re-rendering via renderDataTable + const allRuns: RunInfo[] = []; + let nextCursor: string | null = null; + const displayMap = new Map<string, string>(); + + const tableContainer = el('div', {}); + const loadMoreContainer = el('div', {}); + + async function loadPage(): Promise<void> { + try { + const result = await getRunsPage(suite, { + sort: '-submitted_at', + limit: PAGE_SIZE, + cursor: nextCursor || undefined, + }, signal); + + allRuns.push(...result.items); + + // Resolve display values only for NEW commits not already in the map + const newCommits = [...new Set(result.items.map(r => r.commit))] + .filter(c => !displayMap.has(c)); + if (newCommits.length > 0) { + const resolved = await resolveDisplayMap(suite, newCommits, signal); + for (const [k, v] of resolved) displayMap.set(k, v); + } + + // First load: replace loading message with table + load-more area + if (container.querySelector('.progress-label')) { + container.replaceChildren(tableContainer, loadMoreContainer); + } + + if (allRuns.length === 0) { + tableContainer.replaceChildren(el('p', { class: 'no-results' }, 'No recent activity.')); + return; + } + + tableContainer.replaceChildren(); + renderDataTable(tableContainer, { + columns: recentActivityColumns(suite, displayMap), + rows: allRuns, + emptyMessage: 'No recent activity.', + }); + + nextCursor = result.nextCursor; + loadMoreContainer.replaceChildren(); + if (nextCursor) { + const loadMoreBtn = el('button', { class: 'pagination-btn load-more-btn' }, 'Load more'); + loadMoreBtn.addEventListener('click', () => { + loadMoreBtn.textContent = 'Loading...'; + loadMoreBtn.setAttribute('disabled', ''); + loadPage(); + }); + loadMoreContainer.append(loadMoreBtn); + } + } catch (e: unknown) { + if (allRuns.length === 0) container.replaceChildren(); + container.append(el('p', { class: 'error-banner' }, `Failed to load recent activity: ${e}`)); + } + } + + loadPage(); +} + +function recentActivityColumns(suite: string, displayMap: Map<string, string>): Column<RunInfo>[] { + return [ + { key: 'machine', label: 'Machine', + render: (r: RunInfo) => + detailLink(r.machine, suite, `/machines/${encodeURIComponent(r.machine)}`) }, + { key: 'commit', label: 'Commit', + render: (r: RunInfo) => + detailLink(displayMap.get(r.commit) ?? r.commit, suite, `/commits/${encodeURIComponent(r.commit)}`) }, + { key: 'submitted_at', label: 'Submitted', + render: (r: RunInfo) => formatTime(r.submitted_at) }, + { key: 'uuid', label: 'Run', + render: (r: RunInfo) => + detailLink(truncate(r.uuid, 8), suite, `/runs/${encodeURIComponent(r.uuid)}`) }, + ]; +} + +// --------------------------------------------------------------------------- +// Machines Tab +// --------------------------------------------------------------------------- + +function renderMachinesTab( + container: HTMLElement, + suite: string, + initialSearch: string, + signal: AbortSignal, + onSearchChange: (search: string) => void, +): void { + const searchRow = el('div', { class: 'table-controls' }); + const searchInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter by name...', + }) as HTMLInputElement; + searchInput.value = initialSearch; + searchRow.append(searchInput); + container.append(searchRow); + + const tableContainer = el('div', {}); + const paginationContainer = el('div', {}); + container.append(tableContainer, paginationContainer); + + let currentOffset = 0; + let currentSearch = initialSearch; + + async function loadPage(): Promise<void> { + tableContainer.replaceChildren(); + paginationContainer.replaceChildren(); + tableContainer.append(el('p', { class: 'progress-label' }, 'Loading machines...')); + + try { + const result = await getMachines(suite, { + search: currentSearch || undefined, + limit: PAGE_SIZE, + offset: currentOffset, + }, signal); + + tableContainer.replaceChildren(); + + renderDataTable(tableContainer, { + columns: [ + { key: 'name', label: 'Name', + render: (m: MachineInfo) => + detailLink(m.name, suite, `/machines/${encodeURIComponent(m.name)}`) }, + { key: 'info', label: 'Info', sortable: false, + render: (m: MachineInfo) => formatMachineInfo(m) }, + ], + rows: result.items, + emptyMessage: 'No machines found.', + }); + + const start = currentOffset + 1; + const end = currentOffset + result.items.length; + if (result.total > 0) { + renderPagination(paginationContainer, { + hasPrevious: currentOffset > 0, + hasNext: end < result.total, + rangeText: `${start}\u2013${end} of ${result.total}`, + onPrevious: () => { currentOffset = Math.max(0, currentOffset - PAGE_SIZE); loadPage(); }, + onNext: () => { currentOffset += PAGE_SIZE; loadPage(); }, + }); + } + } catch (e: unknown) { + tableContainer.replaceChildren(); + tableContainer.append(el('p', { class: 'error-banner' }, `Failed to load machines: ${e}`)); + } + } + + const onInput = debounce(() => { + currentSearch = searchInput.value.trim(); + currentOffset = 0; + onSearchChange(currentSearch); + loadPage(); + }, 300); + + searchInput.addEventListener('input', onInput as EventListener); + loadPage(); +} + +function formatMachineInfo(m: MachineInfo): string { + const entries = Object.entries(m.info || {}); + if (entries.length === 0) return ''; + return entries.slice(0, 3).map(([k, v]) => `${k}: ${v}`).join(', '); +} + +// --------------------------------------------------------------------------- +// Cursor-paginated tab (shared by Runs and Commits) +// --------------------------------------------------------------------------- + +interface CursorFetchOpts { + search: string | undefined; + limit: number; + cursor: string | undefined; +} + +/** + * Generic cursor-paginated tab with search input, data table, and Previous/Next. + * Used by the Runs and Commits tabs. + */ +function renderCursorPaginatedTab<T>( + container: HTMLElement, + suite: string, + initialSearch: string, + signal: AbortSignal, + placeholder: string, + loadingMsg: string, + emptyMsg: string, + errorPrefix: string, + fetchPage: (suite: string, opts: CursorFetchOpts, signal: AbortSignal) => Promise<CursorPageResult<T>>, + columns: Column<T>[], + onSearchChange?: (search: string) => void, + onPageLoaded?: (items: T[], signal: AbortSignal) => Promise<void>, +): void { + let currentSearch = initialSearch; + const cursorStack: string[] = []; + let currentCursor: string | undefined; + + if (placeholder && onSearchChange) { + const searchRow = el('div', { class: 'table-controls' }); + const searchInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder, + }) as HTMLInputElement; + searchInput.value = initialSearch; + searchRow.append(searchInput); + container.append(searchRow); + + const onInput = debounce(() => { + currentSearch = searchInput.value.trim(); + cursorStack.length = 0; + currentCursor = undefined; + onSearchChange(currentSearch); + loadPage(); + }, 300); + + searchInput.addEventListener('input', onInput as EventListener); + } + + const tableContainer = el('div', {}); + const paginationContainer = el('div', {}); + container.append(tableContainer, paginationContainer); + + async function loadPage(): Promise<void> { + tableContainer.replaceChildren(); + paginationContainer.replaceChildren(); + tableContainer.append(el('p', { class: 'progress-label' }, loadingMsg)); + + try { + const result = await fetchPage(suite, { + search: currentSearch || undefined, + limit: PAGE_SIZE, + cursor: currentCursor, + }, signal); + + if (onPageLoaded) await onPageLoaded(result.items, signal); + + tableContainer.replaceChildren(); + + renderDataTable(tableContainer, { + columns, + rows: result.items, + emptyMessage: emptyMsg, + }); + + if (cursorStack.length > 0 || result.nextCursor) { + renderPagination(paginationContainer, { + hasPrevious: cursorStack.length > 0, + hasNext: !!result.nextCursor, + onPrevious: () => { + currentCursor = cursorStack.pop(); + loadPage(); + }, + onNext: () => { + if (currentCursor !== undefined) cursorStack.push(currentCursor); + currentCursor = result.nextCursor!; + loadPage(); + }, + }); + } + } catch (e: unknown) { + tableContainer.replaceChildren(); + tableContainer.append(el('p', { class: 'error-banner' }, `${errorPrefix}: ${e}`)); + } + } + + loadPage(); +} + +function runsColumns(suite: string, displayMap: Map<string, string>): Column<RunInfo>[] { + return [ + { key: 'uuid', label: 'Run', + render: (r: RunInfo) => + detailLink(truncate(r.uuid, 8), suite, `/runs/${encodeURIComponent(r.uuid)}`) }, + { key: 'machine', label: 'Machine', + render: (r: RunInfo) => + detailLink(r.machine, suite, `/machines/${encodeURIComponent(r.machine)}`) }, + { key: 'commit', label: 'Commit', + render: (r: RunInfo) => + detailLink(truncate(displayMap.get(r.commit) ?? r.commit, 12), suite, + `/commits/${encodeURIComponent(r.commit)}`) }, + { key: 'submitted_at', label: 'Submitted', + render: (r: RunInfo) => formatTime(r.submitted_at) }, + ]; +} + +function commitsColumns( + suite: string, + commitFields: Array<{ name: string; display?: boolean }>, +): Column<CommitSummary>[] { + return [ + { key: 'commit', label: 'Commit', + render: (o: CommitSummary) => + detailLink( + commitDisplayValue(o, commitFields), + suite, `/commits/${encodeURIComponent(o.commit)}`) }, + { key: 'ordinal', label: 'Ordinal', + render: (o: CommitSummary) => o.ordinal != null ? String(o.ordinal) : '\u2014' }, + { key: 'tag', label: 'Tag', + render: (o: CommitSummary) => o.tag ?? '\u2014' }, + ]; +} + diff --git a/lnt/server/ui/v5/frontend/src/regression-utils.ts b/lnt/server/ui/v5/frontend/src/regression-utils.ts new file mode 100644 index 000000000..be0f5383c --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/regression-utils.ts @@ -0,0 +1,33 @@ +// regression-utils.ts — Shared constants and helpers for regression pages. + +import type { RegressionState } from './types'; +import { el } from './utils'; + +/** Display metadata for each regression state. */ +export const STATE_META: Record<RegressionState, { + label: string; + cssClass: string; +}> = { + detected: { label: 'Detected', cssClass: 'state-detected' }, + active: { label: 'Active', cssClass: 'state-active' }, + not_to_be_fixed: { label: 'Not To Be Fixed', cssClass: 'state-not-to-be-fixed' }, + fixed: { label: 'Fixed', cssClass: 'state-fixed' }, + false_positive: { label: 'False Positive', cssClass: 'state-false-positive' }, +}; + +/** All valid regression states in display order. */ +export const ALL_STATES: RegressionState[] = [ + 'detected', 'active', 'not_to_be_fixed', 'fixed', 'false_positive', +]; + +/** Non-resolved states (these are "open" / active). */ +export const UNRESOLVED_STATES: RegressionState[] = [ + 'detected', 'active', +]; + +/** Render a state badge span element. */ +export function renderStateBadge(state: RegressionState): HTMLElement { + const meta = STATE_META[state]; + return el('span', { class: `state-badge ${meta?.cssClass || ''}` }, + meta?.label || state); +} diff --git a/lnt/server/ui/v5/frontend/src/router.ts b/lnt/server/ui/v5/frontend/src/router.ts new file mode 100644 index 000000000..078f1ebcd --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/router.ts @@ -0,0 +1,197 @@ +// router.ts — Client-side URL routing using the History API. + +export interface PageModule { + /** Render the page into the container. Called on navigation. */ + mount(container: HTMLElement, params: RouteParams): void | Promise<void>; + /** Clean up when navigating away. Optional. */ + unmount?(): void; +} + +export interface RouteParams { + testsuite: string; + /** Named captures from the route pattern, e.g. { name: "machine-1" } */ + [key: string]: string; +} + +interface RouteEntry { + /** Regex compiled from the route pattern */ + regex: RegExp; + /** Named group keys in order */ + keys: string[]; + /** The page module to mount */ + module: PageModule; +} + +const routes: RouteEntry[] = []; +let currentModule: PageModule | null = null; +let appContainer: HTMLElement | null = null; +let basePath = ''; // e.g. "/v5/nts" +let urlBase = ''; // e.g. "" or "/lnt" — the LNT instance prefix +let onAfterResolve: ((routePath: string) => void) | null = null; +let routerTestsuite = ''; +let routerTestsuites: string[] = []; + +/** + * Return the list of available test suites. + * Populated from data-testsuites on the SPA shell. + */ +export function getTestsuites(): string[] { + return routerTestsuites; +} + +/** + * Return the current base path (e.g. "/v5/nts"). + * Used by spaLink to construct real href attributes for accessibility. + */ +export function getBasePath(): string { + return basePath; +} + +/** + * Return the URL base (e.g. "" or "/lnt"). + * This is the LNT instance prefix, without "/v5" or testsuite segments. + * Used by agnosticLink to construct cross-context hrefs. + */ +export function getUrlBase(): string { + return urlBase; +} + +/** + * Register a route. Pattern uses Express-style `:param` syntax. + * Example: "/machines/:name" matches "/machines/clang-x86" + */ +export function addRoute(pattern: string, module: PageModule): void { + const keys: string[] = []; + // Convert ":param" to named regex groups + const regexStr = pattern + .replace(/:([a-zA-Z_]+)/g, (_match, key) => { + keys.push(key); + return '([^/]+)'; + }); + routes.push({ + regex: new RegExp('^' + regexStr + '$'), + keys, + module, + }); +} + +/** + * Initialize the router. + * @param container The DOM element to render pages into + * @param tsBasePath The base path, e.g. "/v5/nts" + * @param afterResolve Optional callback after each route resolution (for nav highlighting) + * @param context Testsuite context from the SPA shell + */ +export function initRouter( + container: HTMLElement, + tsBasePath: string, + afterResolve?: (routePath: string) => void, + context?: { testsuite: string; testsuites: string[]; urlBase?: string }, +): void { + appContainer = container; + basePath = tsBasePath; + urlBase = context?.urlBase ?? ''; + onAfterResolve = afterResolve || null; + routerTestsuite = context?.testsuite ?? ''; + routerTestsuites = context?.testsuites ?? []; + + window.addEventListener('popstate', () => { + resolve(); + }); + + // Initial route resolution + resolve(); +} + +/** + * Navigate to a path (relative to the testsuite base). + * Example: navigate("/machines/clang-x86") + */ +export function navigate(path: string): void { + const fullPath = basePath + path; + window.history.pushState(null, '', fullPath); + resolve(); +} + +/** + * Navigate to a path with query string. + */ +export function navigateWithQuery(path: string, query: string): void { + const fullPath = basePath + path; + const qs = query ? '?' + query : ''; + window.history.pushState(null, '', fullPath + qs); + resolve(); +} + +/** + * Resolve the current URL to a route and mount the corresponding page. + */ +function resolve(): void { + if (!appContainer) return; + + const pathname = window.location.pathname; + // Strip basePath prefix to get the route portion + let routePath = pathname; + if (pathname.startsWith(basePath)) { + routePath = pathname.slice(basePath.length); + } + // Ensure it starts with / + if (!routePath.startsWith('/')) { + routePath = '/' + routePath; + } + // Normalize: strip trailing slash (except for root "/") + if (routePath.length > 1 && routePath.endsWith('/')) { + routePath = routePath.slice(0, -1); + } + + // Root path "/" maps to the dashboard + if (routePath === '' || routePath === '/') { + routePath = '/'; + } + + for (const route of routes) { + const match = routePath.match(route.regex); + if (match) { + const params: RouteParams = { + testsuite: routerTestsuite, + }; + route.keys.forEach((key, i) => { + params[key] = decodeURIComponent(match[i + 1]); + }); + + // Unmount previous page + if (currentModule?.unmount) { + currentModule.unmount(); + } + + // Clear container + appContainer.replaceChildren(); + + // Mount new page + currentModule = route.module; + currentModule.mount(appContainer, params); + + if (onAfterResolve) { + onAfterResolve(routePath); + } + return; + } + } + + // No route matched — show 404 + if (currentModule?.unmount) { + currentModule.unmount(); + } + currentModule = null; + appContainer.replaceChildren(); + const msg = document.createElement('div'); + msg.style.padding = '40px'; + msg.style.textAlign = 'center'; + msg.style.color = '#666'; + msg.innerHTML = '<h2>Page Not Found</h2><p>The URL does not match any v5 page.</p>'; + appContainer.appendChild(msg); + + if (onAfterResolve) { + onAfterResolve(''); + } +} diff --git a/lnt/server/ui/v5/frontend/src/selection.ts b/lnt/server/ui/v5/frontend/src/selection.ts new file mode 100644 index 000000000..6ab124e0c --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/selection.ts @@ -0,0 +1,611 @@ +import type { AggFn, FieldInfo, CommitSummary, SideSelection } from './types'; +import { SETTINGS_CHANGE, TEST_FILTER_CHANGE } from './events'; +import { getFields, getCommits, getRuns, getTestSuiteInfoCached } from './api'; +import { getBasePath } from './router'; +import { getState, setSideA, setSideB, setState, setNoiseConfig, swapSides } from './state'; +import { debounce, el, commitDisplayValue, updateFilterValidation } from './utils'; +import { createCommitPicker, type CommitPickerHandle } from './components/commit-combobox'; +import { renderMachineCombobox } from './components/machine-combobox'; +import { renderMetricSelector, renderEmptyMetricSelector, filterMetricFields } from './components/metric-selector'; + +// Per-side cached data +let cachedCommitsA: CommitSummary[] = []; +let cachedCommitsB: CommitSummary[] = []; +let cachedFieldsA: FieldInfo[] = []; +let cachedFieldsB: FieldInfo[] = []; +let testsuites: string[] = []; +let onCompare: (() => void) | null = null; +// Schema commit_fields per suite for display resolution +const commitFieldsCache = new Map<string, Array<{ name: string; display?: boolean }>>(); + +// Staleness counters for createRunsPanel — prevents earlier async calls +// from overwriting the DOM when a newer call has been issued. +let runsPanelVersionA = 0; +let runsPanelVersionB = 0; + +// Per-side suite data loading version counters — prevents stale fetches +// from overwriting data when the suite changes rapidly. +let suiteLoadVersionA = 0; +let suiteLoadVersionB = 0; + +// Per-side abort controllers for machine-filtered commit fetches +let commitFetchControllerA: AbortController | null = null; +let commitFetchControllerB: AbortController | null = null; + +/** Module-level reference to the metric selector container for re-rendering. */ +let metricContainerRef: HTMLElement | null = null; + +// Per-side commit picker references for enabling/disabling from machine combobox +let commitPickerA: CommitPickerHandle | null = null; +let commitPickerB: CommitPickerHandle | null = null; + +// Per-side machine combobox handles for cleanup on re-render +let machineComboA: { destroy: () => void } | null = null; +let machineComboB: { destroy: () => void } | null = null; + +/** Set the commit input to one of three states. */ +function setCommitInputState( + input: HTMLInputElement | null, + state: 'no-machine' | 'loading' | 'ready', + value?: string, +): void { + if (!input) return; + if (state === 'no-machine') { + input.disabled = true; + input.placeholder = 'Select a machine first'; + } else if (state === 'loading') { + input.disabled = true; + input.placeholder = 'Loading commits...'; + } else { + input.disabled = false; + input.placeholder = 'Type to search commits...'; + } + if (value !== undefined) input.value = value; +} + +/** + * Initialize the selection module. + * Replaces the old setCachedData() — no upfront data fetching. + */ +export function initSelection( + availableTestsuites: string[], + compareFn?: () => void, +): void { + testsuites = availableTestsuites; + if (compareFn) onCompare = compareFn; + cachedCommitsA = []; + cachedCommitsB = []; + cachedFieldsA = []; + cachedFieldsB = []; + if (commitFetchControllerA) { commitFetchControllerA.abort(); commitFetchControllerA = null; } + if (commitFetchControllerB) { commitFetchControllerB.abort(); commitFetchControllerB = null; } +} + +export function getMetricFields(): FieldInfo[] { + // Union of fields from both sides, deduplicated by name + const seen = new Set<string>(); + const merged: FieldInfo[] = []; + for (const f of [...cachedFieldsA, ...cachedFieldsB]) { + if (!seen.has(f.name)) { + seen.add(f.name); + merged.push(f); + } + } + return filterMetricFields(merged); +} + +function getSideState(side: 'a' | 'b') { + const state = getState(); + return { + selection: side === 'a' ? state.sideA : state.sideB, + setSide: side === 'a' ? setSideA : setSideB, + label: side === 'a' ? 'Side A (Baseline)' : 'Side B (New)', + }; +} + +function getCommitDataForSide(side: 'a' | 'b') { + const commits = side === 'a' ? cachedCommitsA : cachedCommitsB; + const cachedCommitValues = commits.map(c => c.commit); + const { selection } = getSideState(side); + const cf = selection.suite ? commitFieldsCache.get(selection.suite) : undefined; + let displayMap: Map<string, string> | undefined; + if (cf) { + displayMap = new Map<string, string>(); + for (const c of commits) { + const display = commitDisplayValue(c, cf); + if (display !== c.commit) displayMap.set(c.commit, display); + } + if (displayMap.size === 0) displayMap = undefined; + } + return { cachedCommitValues, displayMap }; +} + +/** + * Fetch commits filtered by machine for a side. + * Aborts any previous in-flight commit fetch for the same side. + */ +async function fetchCommitsForMachine(side: 'a' | 'b', machine: string): Promise<void> { + const prev = side === 'a' ? commitFetchControllerA : commitFetchControllerB; + if (prev) prev.abort(); + const ctrl = new AbortController(); + if (side === 'a') commitFetchControllerA = ctrl; + else commitFetchControllerB = ctrl; + + const { selection } = getSideState(side); + const suite = selection.suite; + if (!suite) return; + + try { + const commits = await getCommits(suite, { machine, signal: ctrl.signal }); + if (side === 'a') cachedCommitsA = commits; + else cachedCommitsB = commits; + } catch (err: unknown) { + if (err instanceof DOMException && err.name === 'AbortError') return; + if (side === 'a') cachedCommitsA = []; + else cachedCommitsB = []; + } +} + +/** Auto-trigger comparison when state is valid (both sides have runs + metric). */ +function tryAutoCompare(): void { + const state = getState(); + if (state.sideA.runs.length > 0 + && state.sideB.runs.length > 0 + && state.metric !== '' + && onCompare) { + onCompare(); + } +} + +/** + * Debounced version of tryAutoCompare, used only in checkbox change handlers. + * When the user rapidly toggles multiple run checkboxes, the calls coalesce + * into a single comparison after a short delay, avoiding redundant API calls. + */ +const debouncedTryAutoCompare = debounce(tryAutoCompare, 150); + +function createRunsPanel(side: 'a' | 'b', container: HTMLElement, setSide: (partial: Partial<SideSelection>) => void): void { + // Increment the version counter for this side so any in-flight request + // from a previous call knows it is stale and should not touch the DOM. + const version = side === 'a' ? ++runsPanelVersionA : ++runsPanelVersionB; + + const { selection: sideState } = getSideState(side); + + if (!sideState.suite || !sideState.commit || !sideState.machine) { + container.replaceChildren(el('span', { class: 'runs-hint' }, 'Select a commit first')); + return; + } + + container.replaceChildren(el('span', { class: 'runs-loading' }, 'Loading runs...')); + + getRuns(sideState.suite, { machine: sideState.machine, commit: sideState.commit }) + .then(runs => { + // A newer createRunsPanel call was made while we were waiting — + // discard this stale result to avoid overwriting fresh data. + const currentVersion = side === 'a' ? runsPanelVersionA : runsPanelVersionB; + if (version !== currentVersion) return; + container.replaceChildren(); + if (runs.length === 0) { + container.append(el('span', { class: 'runs-empty' }, 'No runs found')); + setSide({ runs: [] }); + return; + } + + // If the URL state has run UUIDs that match available runs, restore + // that selection (some runs may be unchecked). Otherwise select all. + const urlRuns = new Set(sideState.runs); + const hasUrlMatch = runs.some(r => urlRuns.has(r.uuid)); + + for (const run of runs) { + const id = `run-${side}-${run.uuid}`; + const cb = el('input', { type: 'checkbox', id, value: run.uuid }); + cb.checked = hasUrlMatch ? urlRuns.has(run.uuid) : true; + const label = el('label', { for: id }, + run.submitted_at ? new Date(run.submitted_at).toLocaleString() : '(no time)', + ); + const link = el('a', { + href: `${getBasePath()}/${encodeURIComponent(sideState.suite)}/runs/${encodeURIComponent(run.uuid)}`, + class: 'run-uuid', + }, `UUID ${run.uuid.slice(0, 8)}`); + container.append(el('div', { class: 'run-row' }, cb, label, link)); + + cb.addEventListener('change', () => { + const checked = container.querySelectorAll<HTMLInputElement>('input:checked'); + setSide({ runs: Array.from(checked).map(c => c.value) }); + updateRunAggState(side); + debouncedTryAutoCompare(); + }); + } + + const checked = container.querySelectorAll<HTMLInputElement>('input:checked'); + setSide({ runs: Array.from(checked).map(c => c.value) }); + updateRunAggState(side); + tryAutoCompare(); + }) + .catch(e => { + if (e instanceof DOMException && e.name === 'AbortError') return; + container.replaceChildren(el('span', { class: 'runs-error' }, 'Error loading runs')); + }); +} + +let runAggSelectA: HTMLSelectElement | null = null; +let runAggSelectB: HTMLSelectElement | null = null; + +function updateRunAggState(side: 'a' | 'b'): void { + const sel = side === 'a' ? runAggSelectA : runAggSelectB; + if (!sel) return; + const { selection } = getSideState(side); + sel.disabled = selection.runs.length <= 1; +} + +function createRunAggSelect( + side: 'a' | 'b', + setSide: (partial: Partial<SideSelection>) => void, +): HTMLSelectElement { + const { selection } = getSideState(side); + const select = el('select', { class: 'agg-select' }) as HTMLSelectElement; + for (const v of ['median', 'mean', 'min', 'max'] as AggFn[]) { + const opt = el('option', { value: v }, v); + if (v === selection.runAgg) (opt as HTMLOptionElement).selected = true; + select.append(opt); + } + select.disabled = true; + select.addEventListener('change', () => { + setSide({ runAgg: select.value as AggFn }); + tryAutoCompare(); + }); + if (side === 'a') runAggSelectA = select; + else runAggSelectB = select; + return select; +} + +function createSampleAggSelect(): HTMLSelectElement { + const state = getState(); + const select = el('select', { class: 'agg-select' }) as HTMLSelectElement; + for (const v of ['median', 'mean', 'min', 'max'] as AggFn[]) { + const opt = el('option', { value: v }, v); + if (v === state.sampleAgg) (opt as HTMLOptionElement).selected = true; + select.append(opt); + } + select.addEventListener('change', () => { + setState({ sampleAgg: select.value as AggFn }); + tryAutoCompare(); + }); + return select; +} + +/** + * Fetch fields and suite info for a side when its suite changes. + * Commits are NOT fetched here — they are fetched per-machine when a + * machine is selected (via fetchCommitsForMachine). + */ +export async function fetchSideData( + side: 'a' | 'b', + suite: string, +): Promise<void> { + const version = side === 'a' ? ++suiteLoadVersionA : ++suiteLoadVersionB; + + // Clear stale commits from a previous suite/machine selection + if (side === 'a') cachedCommitsA = []; + else cachedCommitsB = []; + + try { + const [fields, suiteInfo] = await Promise.all([ + getFields(suite), + getTestSuiteInfoCached(suite).catch(() => null), + ]); + + if (suiteInfo) { + commitFieldsCache.set(suite, suiteInfo.schema.commit_fields); + } + + // Check for staleness + const currentVersion = side === 'a' ? suiteLoadVersionA : suiteLoadVersionB; + if (version !== currentVersion) return; + + const picker = side === 'a' ? commitPickerA : commitPickerB; + const { selection } = getSideState(side); + if (picker && selection.commit) picker.setValue(selection.commit); + + if (side === 'a') { + cachedFieldsA = fields; + } else { + cachedFieldsB = fields; + } + + // Re-render metric selector — read metricContainerRef AFTER await + // so it targets the current DOM element (not one from before re-render). + const target = metricContainerRef; + if (target) { + target.replaceChildren(); + renderMetricSelector(target, getMetricFields(), (metric) => { + setState({ metric }); + tryAutoCompare(); + }, getState().metric, { placeholder: true }); + } + } catch { + // Silently ignore fetch errors — controls stay disabled + } +} + +// Main render +export function renderSelectionPanel(root: HTMLElement): void { + root.replaceChildren(); + if (machineComboA) { machineComboA.destroy(); machineComboA = null; } + if (machineComboB) { machineComboB.destroy(); machineComboB = null; } + if (commitPickerA) { commitPickerA.destroy(); commitPickerA = null; } + if (commitPickerB) { commitPickerB.destroy(); commitPickerB = null; } + + const panel = el('div', { class: 'controls-panel' }); + + // Side A and B + const sidesRow = el('div', { class: 'sides-row' }); + const runsContainers: Record<string, HTMLElement> = {}; + const sideDivs: HTMLElement[] = []; + + // Global controls (created early so fetchSideData can update metric selector) + const globalRow = el('div', { class: 'global-controls' }); + const metricContainer = el('div', {}); + metricContainerRef = metricContainer; + const metricFields = getMetricFields(); + if (metricFields.length > 0) { + renderMetricSelector(metricContainer, metricFields, (metric) => { + setState({ metric }); + tryAutoCompare(); + }, getState().metric, { placeholder: true }); + } else { + renderEmptyMetricSelector(metricContainer); + } + globalRow.append(metricContainer); + + for (const side of ['a', 'b'] as const) { + const { setSide, label } = getSideState(side); + + const sideDiv = el('div', { class: `side side-${side}` }); + sideDiv.append(el('h3', {}, label)); + + // Suite selector + sideDiv.append(el('label', {}, 'Suite')); + const suiteSelect = el('select', { class: 'suite-select' }) as HTMLSelectElement; + const emptyOpt = el('option', { value: '' }, '-- Select suite --'); + suiteSelect.append(emptyOpt); + const { selection: sideState } = getSideState(side); + for (const name of testsuites) { + const opt = el('option', { value: name }, name); + if (name === sideState.suite) (opt as HTMLOptionElement).selected = true; + suiteSelect.append(opt); + } + suiteSelect.addEventListener('change', () => { + const newSuite = suiteSelect.value; + setSide({ suite: newSuite, machine: '', commit: '', runs: [] }); + if (newSuite) { + fetchSideData(side, newSuite); + } else { + // Clear cached data for this side so metrics/commits don't linger + if (side === 'a') { cachedFieldsA = []; cachedCommitsA = []; } + else { cachedFieldsB = []; cachedCommitsB = []; } + } + // Re-render the panel to update comboboxes with new suite context + renderSelectionPanel(root); + }); + sideDiv.append(suiteSelect); + + const runsContainer = el('div', { class: 'runs-container' }); + runsContainers[side] = runsContainer; + + const refreshRuns = () => createRunsPanel(side, runsContainer, setSide); + + // Machine combobox + sideDiv.append(el('label', {}, 'Machine')); + const machineContainer = el('div', {}); + const suiteName = sideState.suite; + + async function onMachineSelect(name: string): Promise<void> { + setSide({ machine: name }); + + const picker = side === 'a' ? commitPickerA : commitPickerB; + setCommitInputState(picker?.input ?? null, 'loading'); + + await fetchCommitsForMachine(side, name); + + // Clear commit if it's no longer valid for this machine + const { cachedCommitValues } = getCommitDataForSide(side); + const { selection: current } = getSideState(side); + if (current.commit && !cachedCommitValues.includes(current.commit)) { + setSide({ commit: '' }); + } + const { selection: updated } = getSideState(side); + setCommitInputState(picker?.input ?? null, 'ready'); + if (updated.commit) picker?.setValue(updated.commit); + else if (picker) picker.input.value = ''; + refreshRuns(); + } + + const machineHandle = renderMachineCombobox(machineContainer, { + testsuite: suiteName, + initialValue: sideState.machine, + onSelect(name: string) { + onMachineSelect(name); + }, + onClear() { + setSide({ machine: '', commit: '', runs: [] }); + const picker = side === 'a' ? commitPickerA : commitPickerB; + setCommitInputState(picker?.input ?? null, 'no-machine', ''); + refreshRuns(); + }, + }); + if (side === 'a') machineComboA = machineHandle; + else machineComboB = machineHandle; + sideDiv.append(machineContainer); + + // Pre-fetch commits for URL-restored machine + if (sideState.machine && suiteName) { + fetchCommitsForMachine(side, sideState.machine) + .then(() => { + const picker = side === 'a' ? commitPickerA : commitPickerB; + const { selection: updated } = getSideState(side); + setCommitInputState(picker?.input ?? null, 'ready'); + if (updated.commit) picker?.setValue(updated.commit); + }) + .catch(() => {}); + } + + // Commit combobox + sideDiv.append(el('label', {}, 'Commit')); + const picker = createCommitPicker({ + id: `commit-${side}`, + getCommitData: () => { + const { cachedCommitValues, displayMap } = getCommitDataForSide(side); + return { values: cachedCommitValues, displayMap }; + }, + initialValue: sideState.commit, + placeholder: 'Type to search commits...', + onSelect: (value) => { + setSide(value ? { commit: value } : { commit: '', runs: [] }); + refreshRuns(); + }, + }); + if (side === 'a') commitPickerA = picker; + else commitPickerB = picker; + setCommitInputState(picker.input, sideState.machine ? 'loading' : 'no-machine'); + sideDiv.append(picker.element); + + // Runs + sideDiv.append(el('label', {}, 'Runs')); + sideDiv.append(runsContainer); + + // Run aggregation + const aggRow = el('div', { class: 'agg-row' }); + aggRow.append(el('label', {}, 'Run aggregation:')); + aggRow.append(createRunAggSelect(side, setSide)); + sideDiv.append(aggRow); + + sideDivs.push(sideDiv); + } + + // Swap button between the two sides + const swapBtn = el('button', { + class: 'swap-sides-btn', + title: 'Swap A and B sides', + 'aria-label': 'Swap A and B sides', + }, '⇄'); + swapBtn.addEventListener('click', () => { + swapSides(); + // Also swap per-side caches; abort in-flight commit fetches + // (they would write to the wrong logical side after the swap). + const tmpCommits = cachedCommitsA; + cachedCommitsA = cachedCommitsB; + cachedCommitsB = tmpCommits; + const tmpFields = cachedFieldsA; + cachedFieldsA = cachedFieldsB; + cachedFieldsB = tmpFields; + if (commitFetchControllerA) { commitFetchControllerA.abort(); commitFetchControllerA = null; } + if (commitFetchControllerB) { commitFetchControllerB.abort(); commitFetchControllerB = null; } + renderSelectionPanel(root); + tryAutoCompare(); + }); + + sidesRow.append(sideDivs[0], swapBtn, sideDivs[1]); + panel.append(sidesRow); + + // Continue global controls + const sampleAggGroup = el('div', { class: 'control-group' }); + sampleAggGroup.append(el('label', {}, 'Sample aggregation')); + sampleAggGroup.append(createSampleAggSelect()); + globalRow.append(sampleAggGroup); + + // Hide noise checkbox (outside collapsible, always visible) + const hideNoiseGroup = el('div', { class: 'control-group control-group-checkbox' }); + const hideNoiseCb = el('input', { type: 'checkbox', id: 'hide-noise' }) as HTMLInputElement; + hideNoiseCb.checked = getState().hideNoise; + hideNoiseCb.addEventListener('change', () => { + setState({ hideNoise: hideNoiseCb.checked }); + document.dispatchEvent(new CustomEvent(SETTINGS_CHANGE)); + }); + hideNoiseGroup.append(hideNoiseCb); + hideNoiseGroup.append(el('label', { for: 'hide-noise' }, 'Hide noise')); + globalRow.append(hideNoiseGroup); + + // Collapsible noise filtering section + const noisePanel = el('details', { class: 'noise-filtering-panel' }); + noisePanel.append(el('summary', {}, 'Noise filtering')); + const noiseBody = el('div', { class: 'noise-filtering-body' }); + + const nc = getState().noiseConfig; + + // Helper to build a knob row + function buildKnobRow( + knobKey: 'pct' | 'pval' | 'floor', + label: string, + tooltip: string, + inputAttrs: Record<string, string>, + validate: (v: number) => boolean, + ): HTMLElement { + const knob = nc[knobKey]; + const row = el('div', { class: 'noise-knob-row' }); + + const cb = el('input', { type: 'checkbox' }) as HTMLInputElement; + cb.checked = knob.enabled; + + const valInput = el('input', { + type: 'number', + value: String(knob.value), + ...inputAttrs, + }) as HTMLInputElement; + valInput.disabled = !knob.enabled; + + cb.addEventListener('change', () => { + setNoiseConfig(knobKey, { enabled: cb.checked }); + valInput.disabled = !cb.checked; + document.dispatchEvent(new CustomEvent(SETTINGS_CHANGE)); + }); + + valInput.addEventListener('change', () => { + const v = parseFloat(valInput.value); + if (Number.isFinite(v) && validate(v)) { + setNoiseConfig(knobKey, { value: v }); + document.dispatchEvent(new CustomEvent(SETTINGS_CHANGE)); + } + }); + + row.append(cb); + row.append(el('label', { title: tooltip }, label)); + row.append(valInput); + return row; + } + + noiseBody.append(buildKnobRow('pct', 'Delta % below', 'Tests where the absolute percentage change is within this threshold are considered noise.', { min: '0', step: '0.1' }, v => v >= 0)); + noiseBody.append(buildKnobRow('pval', 'P-value above', 'Welch’s t-test on raw samples from both sides. Tests with p-value above the threshold are considered noise (the difference is not statistically significant). Requires at least 2 samples per side.', { min: '0', max: '1', step: '0.01' }, v => v >= 0 && v <= 1)); + noiseBody.append(buildKnobRow('floor', 'Absolute below', 'Tests where both sides’ aggregated values are below this floor are considered noise. Useful for filtering out measurements too small to be meaningful.', { min: '0', step: 'any' }, v => v >= 0)); + + noisePanel.append(noiseBody); + globalRow.append(noisePanel); + + // Test filter + const filterGroup = el('div', { class: 'control-group' }); + filterGroup.append(el('label', {}, 'Filter tests')); + const filterInput = el('input', { + type: 'text', + class: 'test-filter-input', + placeholder: 'Filter tests...', + value: getState().testFilter, + }); + const doFilter = debounce(() => { + setState({ testFilter: filterInput.value }); + document.dispatchEvent(new CustomEvent(TEST_FILTER_CHANGE)); + }, 200); + filterInput.addEventListener('input', () => { + updateFilterValidation(filterInput); + doFilter(); + }); + filterGroup.append(filterInput); + globalRow.append(filterGroup); + + panel.append(globalRow); + + root.append(panel); + + // Populate runs section (shows appropriate hint or loads runs) + createRunsPanel('a', runsContainers['a'], setSideA); + createRunsPanel('b', runsContainers['b'], setSideB); +} diff --git a/lnt/server/ui/v5/frontend/src/state.ts b/lnt/server/ui/v5/frontend/src/state.ts new file mode 100644 index 000000000..f2e1a88b0 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/state.ts @@ -0,0 +1,260 @@ +import type { AggFn, AppState, NoiseConfig, NoiseKnob, ShadowConfig, SideSelection, SortCol, SortDir } from './types'; + +const NOISE_DEFAULTS: NoiseConfig = { + pct: { enabled: false, value: 1 }, + pval: { enabled: false, value: 0.05 }, + floor: { enabled: false, value: 0 }, +}; + +const DEFAULTS: AppState = { + sideA: { suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }, + sideB: { suite: '', commit: '', machine: '', runs: [], runAgg: 'median' }, + metric: '', + sampleAgg: 'median', + noiseConfig: structuredClone(NOISE_DEFAULTS), + sort: 'delta_pct', + sortDir: 'desc', + testFilter: '', + hideNoise: false, + shadow: null, +}; + +let state: AppState = structuredClone(DEFAULTS); + +export function getState(): AppState { + return state; +} + +export function setState(partial: Partial<AppState>): void { + if (partial.sideA) state.shadow = null; + Object.assign(state, partial); + replaceUrl(); +} + +export function setNoiseConfig(knob: keyof NoiseConfig, partial: Partial<NoiseKnob>): void { + state.noiseConfig = { + ...state.noiseConfig, + [knob]: { ...state.noiseConfig[knob], ...partial }, + }; + replaceUrl(); +} + +export function setSideA(partial: Partial<AppState['sideA']>): void { + state.shadow = null; + Object.assign(state.sideA, partial); + replaceUrl(); +} + +export function setSideB(partial: Partial<AppState['sideB']>): void { + Object.assign(state.sideB, partial); + replaceUrl(); +} + +export function swapSides(): void { + const tmp = state.sideA; + state.sideA = state.sideB; + state.sideB = tmp; + state.shadow = null; + replaceUrl(); +} + +export function setShadow(config: ShadowConfig): void { + state.shadow = config; + replaceUrl(); +} + +export function clearShadow(): void { + state.shadow = null; + replaceUrl(); +} + +const VALID_AGG: AggFn[] = ['median', 'mean', 'min', 'max']; +const VALID_SORT: SortCol[] = ['test', 'value_a', 'value_b', 'delta', 'delta_pct', 'ratio', 'status']; +const VALID_DIR: SortDir[] = ['asc', 'desc']; + +function parseAgg(v: string | null): AggFn | undefined { + return v && VALID_AGG.includes(v as AggFn) ? v as AggFn : undefined; +} + +function decodeSide(p: URLSearchParams, suffix: string): SideSelection | undefined { + const suite = p.get(`suite_${suffix}`); + const commit = p.get(`commit_${suffix}`); + const machine = p.get(`machine_${suffix}`); + const runs = p.get(`runs_${suffix}`); + const runAgg = parseAgg(p.get(`run_agg_${suffix}`)); + if (suite || commit || machine || runs || runAgg) { + return { + suite: suite || '', + commit: commit || '', + machine: machine || '', + runs: runs ? runs.split(',').filter(Boolean) : [], + runAgg: runAgg || 'median', + }; + } + return undefined; +} + +function encodeSide(p: URLSearchParams, side: SideSelection, suffix: string): void { + if (side.suite) p.set(`suite_${suffix}`, side.suite); + if (side.commit) p.set(`commit_${suffix}`, side.commit); + if (side.machine) p.set(`machine_${suffix}`, side.machine); + if (side.runs.length) p.set(`runs_${suffix}`, side.runs.join(',')); + if (side.runAgg !== 'median') p.set(`run_agg_${suffix}`, side.runAgg); +} + +function decodeNoiseConfig(p: URLSearchParams): Partial<NoiseConfig> | undefined { + const result: Partial<NoiseConfig> = {}; + let hasAny = false; + + // Delta % knob + const pctVal = p.get('noise_pct'); + const pctOn = p.get('noise_pct_on'); + if (pctVal !== null || pctOn !== null) { + hasAny = true; + const knob: NoiseKnob = { ...NOISE_DEFAULTS.pct }; + if (pctVal !== null) { + const n = parseFloat(pctVal); + if (Number.isFinite(n) && n >= 0) knob.value = n; + } + if (pctOn === '0') knob.enabled = false; + else if (pctOn === '1') knob.enabled = true; + result.pct = knob; + } + + // P-value knob + const pvalVal = p.get('noise_pval'); + const pvalOn = p.get('noise_pval_on'); + if (pvalVal !== null || pvalOn !== null) { + hasAny = true; + const knob: NoiseKnob = { ...NOISE_DEFAULTS.pval }; + if (pvalVal !== null) { + const n = parseFloat(pvalVal); + if (Number.isFinite(n) && n >= 0 && n <= 1) knob.value = n; + } + if (pvalOn === '0') knob.enabled = false; + else if (pvalOn === '1') knob.enabled = true; + result.pval = knob; + } + + // Floor knob + const floorVal = p.get('noise_floor'); + const floorOn = p.get('noise_floor_on'); + if (floorVal !== null || floorOn !== null) { + hasAny = true; + const knob: NoiseKnob = { ...NOISE_DEFAULTS.floor }; + if (floorVal !== null) { + const n = parseFloat(floorVal); + if (Number.isFinite(n) && n >= 0) knob.value = n; + } + if (floorOn === '0') knob.enabled = false; + else if (floorOn === '1') knob.enabled = true; + result.floor = knob; + } + + // Legacy migration: ?noise=X → pct.value + if (!hasAny) { + const legacyNoise = p.get('noise'); + if (legacyNoise !== null) { + const n = parseFloat(legacyNoise); + if (Number.isFinite(n) && n >= 0) { + return { pct: { enabled: true, value: n } }; + } + } + } + + return hasAny ? result : undefined; +} + +function encodeNoiseConfig(p: URLSearchParams, nc: NoiseConfig): void { + // pct: defaults are enabled=true, value=1 + if (nc.pct.value !== NOISE_DEFAULTS.pct.value) p.set('noise_pct', String(nc.pct.value)); + if (nc.pct.enabled !== NOISE_DEFAULTS.pct.enabled) p.set('noise_pct_on', nc.pct.enabled ? '1' : '0'); + + // pval: defaults are enabled=false, value=0.05 + if (nc.pval.value !== NOISE_DEFAULTS.pval.value) p.set('noise_pval', String(nc.pval.value)); + if (nc.pval.enabled !== NOISE_DEFAULTS.pval.enabled) p.set('noise_pval_on', nc.pval.enabled ? '1' : '0'); + + // floor: defaults are enabled=false, value=0 + if (nc.floor.value !== NOISE_DEFAULTS.floor.value) p.set('noise_floor', String(nc.floor.value)); + if (nc.floor.enabled !== NOISE_DEFAULTS.floor.enabled) p.set('noise_floor_on', nc.floor.enabled ? '1' : '0'); +} + +export function decodeFromUrl(search: string): Partial<AppState> { + const p = new URLSearchParams(search); + const result: Partial<AppState> = {}; + + const sideA = decodeSide(p, 'a'); + if (sideA) result.sideA = sideA; + + const sideB = decodeSide(p, 'b'); + if (sideB) result.sideB = sideB; + + const shadowB = decodeSide(p, 'shadow_b'); + if (shadowB) result.shadow = { sideB: shadowB }; + + const metric = p.get('metric'); + if (metric) result.metric = metric; + + const sampleAgg = parseAgg(p.get('sample_agg')); + if (sampleAgg) result.sampleAgg = sampleAgg; + + const noiseConfig = decodeNoiseConfig(p); + if (noiseConfig) result.noiseConfig = { ...structuredClone(NOISE_DEFAULTS), ...noiseConfig }; + + const sort = p.get('sort'); + if (sort && VALID_SORT.includes(sort as SortCol)) result.sort = sort as SortCol; + + const sortDir = p.get('sort_dir'); + if (sortDir && VALID_DIR.includes(sortDir as SortDir)) result.sortDir = sortDir as SortDir; + + const testFilter = p.get('test_filter'); + if (testFilter) result.testFilter = testFilter; + + const hideNoise = p.get('hide_noise'); + if (hideNoise === '1') result.hideNoise = true; + else if (hideNoise === '0') result.hideNoise = false; + + return result; +} + +export function encodeToUrl(s: AppState): string { + const p = new URLSearchParams(); + + encodeSide(p, s.sideA, 'a'); + encodeSide(p, s.sideB, 'b'); + if (s.shadow) encodeSide(p, s.shadow.sideB, 'shadow_b'); + + if (s.metric) p.set('metric', s.metric); + if (s.sampleAgg !== 'median') p.set('sample_agg', s.sampleAgg); + encodeNoiseConfig(p, s.noiseConfig); + if (s.sort !== 'delta_pct') p.set('sort', s.sort); + if (s.sortDir !== 'desc') p.set('sort_dir', s.sortDir); + if (s.testFilter) p.set('test_filter', s.testFilter); + if (s.hideNoise) p.set('hide_noise', '1'); + + const qs = p.toString(); + return qs ? `?${qs}` : ''; +} + +/** Decode URL params and apply onto a fresh default state. */ +export function applyUrlState(search: string): void { + const decoded = decodeFromUrl(search); + // Reset to defaults first, then apply decoded URL params + state = structuredClone(DEFAULTS); + if (decoded.sideA) state.sideA = { ...state.sideA, ...decoded.sideA }; + if (decoded.sideB) state.sideB = { ...state.sideB, ...decoded.sideB }; + if (decoded.metric !== undefined) state.metric = decoded.metric; + if (decoded.sampleAgg !== undefined) state.sampleAgg = decoded.sampleAgg; + if (decoded.noiseConfig !== undefined) state.noiseConfig = decoded.noiseConfig; + if (decoded.sort !== undefined) state.sort = decoded.sort; + if (decoded.sortDir !== undefined) state.sortDir = decoded.sortDir; + if (decoded.testFilter !== undefined) state.testFilter = decoded.testFilter; + if (decoded.hideNoise !== undefined) state.hideNoise = decoded.hideNoise; + if (decoded.shadow !== undefined) state.shadow = decoded.shadow; +} + +export function replaceUrl(): void { + const qs = encodeToUrl(state); + const url = window.location.pathname + qs; + window.history.replaceState(null, '', url); +} diff --git a/lnt/server/ui/v5/frontend/src/style.css b/lnt/server/ui/v5/frontend/src/style.css new file mode 100644 index 000000000..1a8ed80ad --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/style.css @@ -0,0 +1,1965 @@ +/* ============================================================ + v5 SPA — Base / Foundation Styles + ============================================================ + The SPA shell (v5_app.html) is standalone — it does not extend + layout.html and inherits no CSS framework. These rules provide + the typographic baseline and element resets that every page needs. + ============================================================ */ + +*, *::before, *::after { + box-sizing: border-box; +} + +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + font-size: 14px; + line-height: 1.5; + color: #333; + background: #fff; +} + +#v5-app { + max-width: 1200px; + margin: 0 auto; + padding: 12px 20px; +} + +/* Links */ +a { + color: #0d6efd; + text-decoration: none; +} + +a:hover { + text-decoration: underline; +} + +/* Headings */ +h1, h2, h3, h4 { + margin-top: 0; + color: #333; +} + +h2 { font-size: 20px; font-weight: 600; } +h3 { font-size: 15px; font-weight: 600; } + +/* Form elements — match the Compare page's control styling */ +input[type="text"], +input[type="password"], +input[type="search"], +select { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-family: inherit; + font-size: 13px; + color: #333; + background: #fff; +} + +input[type="text"]:focus, +input[type="password"]:focus, +input[type="search"]:focus, +select:focus { + outline: none; + border-color: #0d6efd; + box-shadow: 0 0 0 2px rgba(13, 110, 253, 0.15); +} + +button { + font-family: inherit; + font-size: 13px; + cursor: pointer; +} + +/* Tables — base reset (component classes add the rest) */ +table { + border-collapse: collapse; + width: 100%; +} + +/* Utility: definition list (used for metadata on detail pages) */ +dl { + margin: 0; +} + +/* ============================================================ + Shared Controls Panel (used by Compare, Graph, etc.) + ============================================================ */ + +.controls-panel { + padding: 15px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + margin-bottom: 20px; +} + +.sides-row { + display: flex; + gap: 20px; + align-items: flex-start; + margin-bottom: 15px; +} + +.swap-sides-btn { + flex-shrink: 0; + align-self: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid #ccc; + border-radius: 50%; + background: #fff; + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #666; +} + +.swap-sides-btn:hover { + background: #e9ecef; + color: #333; + border-color: #adb5bd; +} + +.side { + flex: 1; + background: #fff; + padding: 12px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.side h3 { + margin: 0 0 10px 0; + font-size: 14px; + font-weight: 600; + color: #333; +} + +.side-header-row { + display: flex; + justify-content: space-between; + align-items: center; +} + +.shadow-pin-btn { + padding: 2px 10px; + font-size: 11px; +} + +.side label { + display: block; + font-size: 12px; + font-weight: 600; + color: #666; + margin: 8px 0 3px 0; +} + +/* Combobox */ +.combobox { + position: relative; +} + +.combobox-input { + width: 100%; + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + box-sizing: border-box; +} + +.combobox-input.combobox-invalid { + border-color: #d62728 !important; + box-shadow: 0 0 0 2px rgba(214, 39, 40, 0.15) !important; +} + +.combobox-status { + color: #999; + pointer-events: none; +} + +.combobox-status-error { + color: #c00; + pointer-events: none; +} + +.add-existing-tab .combobox { + margin-bottom: 8px; +} + +.filter-invalid { + border-color: #d62728 !important; + box-shadow: 0 0 0 2px rgba(214, 39, 40, 0.15) !important; +} + +.filter-input-wrapper { + position: relative; + display: inline-block; +} + +.filter-row > .filter-input-wrapper { + flex: 1; + min-width: 200px; +} + +.filter-regex-badge { + position: absolute; + right: 6px; + top: 50%; + transform: translateY(-50%); + font-size: 10px; + padding: 1px 5px; + border-radius: 3px; + background: #e8f0fe; + color: #1a73e8; + font-weight: 600; + pointer-events: none; +} + +.filter-regex-badge-invalid { + background: #fce8e8; + color: #d62728; +} + +input.filter-has-badge { + padding-right: 50px; +} + +.combobox-dropdown { + position: absolute; + z-index: 1000; + width: 100%; + max-height: 200px; + overflow-y: auto; + background: #fff; + border: 1px solid #ccc; + border-top: none; + border-radius: 0 0 3px 3px; + list-style: none; + margin: 0; + padding: 0; + display: none; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.combobox-dropdown.open { + display: block; +} + +.combobox-item { + padding: 4px 8px; + cursor: pointer; + font-size: 13px; +} + +.combobox-item:hover, +.combobox-item:focus { + background: #e9ecef; + outline: none; +} + +/* Runs */ +.runs-container { + max-height: 120px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 3px; + padding: 4px; + background: #fff; +} + +.runs-hint, .runs-loading, .runs-empty, .runs-error { + font-size: 12px; + color: #999; + padding: 4px; + user-select: none; +} + +.runs-error { + color: #d62728; +} + +.run-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + font-size: 12px; +} + +.run-row input[type="checkbox"] { + margin: 0; +} + +.run-uuid { + color: #888; + font-size: 11px; +} + +/* Aggregation */ +.agg-row { + display: flex; + align-items: center; + gap: 8px; + margin-top: 8px; +} + +.agg-row label { + margin: 0; + display: inline; +} + +.agg-select { + padding: 4px 6px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; +} + +.agg-select:disabled { + opacity: 0.5; +} + +/* Global Controls */ +.global-controls { + display: flex; + gap: 15px; + align-items: flex-end; + margin-bottom: 12px; + flex-wrap: wrap; +} + +.control-group { + display: flex; + flex-direction: column; + gap: 3px; +} + +.control-group label { + font-size: 12px; + font-weight: 600; + color: #666; +} + +.control-group-checkbox { + flex-direction: row; + align-items: center; + gap: 6px; +} + +.control-group-checkbox label { + margin: 0; +} + +.metric-select { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + min-width: 200px; +} + +/* Noise filtering panel */ +.noise-filtering-panel { + margin: 0; + position: relative; +} +.noise-filtering-panel summary { + cursor: pointer; + font-weight: 500; + font-size: 13px; +} +.noise-filtering-body { + position: absolute; + top: 100%; + left: 0; + z-index: 10; + background: #fff; + border: 1px solid #ddd; + border-radius: 4px; + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.5rem; +} +.noise-knob-row { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 13px; +} +.noise-knob-row input[type="number"] { + width: 5rem; + padding: 2px 4px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; +} +.noise-knob-row input[type="number"]:disabled { + opacity: 0.5; +} +.noise-knob-row label { + cursor: help; +} + +/* Action Row */ +.action-row { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +.shadow-toolbar { + min-height: 32px; + gap: 8px; +} + +.compare-btn { + padding: 6px 20px; + background: #0d6efd; + color: #fff; + border: none; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; +} + +.compare-btn:hover:not(:disabled) { + background: #0b5ed7; +} + +.compare-btn:disabled { + background: #6c757d; + cursor: not-allowed; +} + +.settings-toggle { + padding: 4px 12px; + background: none; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + cursor: pointer; + color: #666; +} + +.settings-panel { + background: #fff; + border: 1px solid #dee2e6; + border-radius: 4px; + padding: 10px; + margin-bottom: 10px; +} + +.settings-panel label { + display: block; + font-size: 12px; + font-weight: 600; + color: #666; + margin-bottom: 4px; +} + +.token-input { + width: 100%; + max-width: 400px; + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + box-sizing: border-box; +} + +/* Progress */ +.progress-row { + display: flex; + align-items: center; + gap: 10px; +} + +.progress-label { + font-size: 12px; + color: #666; +} + +/* Error Banner */ +.error-banner { + background: #f8d7da; + color: #842029; + padding: 8px 12px; + border: 1px solid #f5c2c7; + border-radius: 4px; + font-size: 13px; + margin-top: 8px; +} + +/* Results Area */ +#results-root { + margin-top: 15px; +} + +#chart-container { + margin-bottom: 20px; +} + +.no-chart-data { + color: #666; + font-style: italic; + padding: 20px; + text-align: center; +} + +/* Comparison Summary Bar */ +.comparison-summary-container { + margin: 8px 0 12px; +} + +.comparison-summary { + display: flex; + flex-wrap: wrap; + gap: 16px; + padding: 8px 12px; + background: #f8f9fa; + border: 1px solid #dee2e6; + border-radius: 4px; + font-size: 13px; +} + +.summary-item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.summary-item-zero { + opacity: 0.5; +} + +.summary-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; +} + +.summary-label { + font-weight: 600; + color: #495057; +} + +.summary-count { + color: #666; + font-variant-numeric: tabular-nums; +} + +/* Table Controls */ +.table-controls { + display: flex; + gap: 10px; + align-items: center; + margin-bottom: 10px; +} + +.test-filter-input { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; + width: 250px; +} + +.reset-filters-btn { + padding: 4px 12px; + background: none; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 12px; + cursor: pointer; + color: #666; +} + +.reset-filters-btn:hover { + background: #f0f0f0; +} + +/* Noise Summary */ +.noise-summary { + padding: 6px 10px; + background: #fff3cd; + border: 1px solid #ffecb5; + border-radius: 4px; + font-size: 13px; + color: #664d03; + margin-bottom: 10px; +} + +.table-message { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + font-size: 12px; + color: #666; + margin-bottom: 4px; +} + +.noise-summary a { + color: #0d6efd; + text-decoration: underline; +} + +/* Comparison Table */ +.comparison-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.comparison-table th { + border-bottom: 2px solid #dee2e6; + padding: 6px 8px; + text-align: left; + font-weight: 600; + font-size: 12px; + color: #495057; + white-space: nowrap; +} + +.comparison-table th.sortable { + cursor: pointer; + user-select: none; +} + +.comparison-table th.sortable:hover { + background: #f0f0f0; +} + +.comparison-table td { + border-bottom: 1px solid #eee; + padding: 4px 8px; +} + +.col-test { + max-width: 400px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.col-num { + text-align: right; + font-family: monospace; + white-space: nowrap; +} + +.col-status { + font-weight: 600; + font-size: 12px; + text-transform: uppercase; +} + +/* Status colors */ +.status-improved { + color: #2ca02c; +} + +.status-regressed { + color: #d62728; +} + +.status-noise, .status-unchanged { + color: #999; +} + +.status-missing { + color: #888; + font-style: italic; +} + +.status-na { + color: #888; +} + +/* Row states */ +.row-hidden { + opacity: 0.3; +} + +.row-missing { + color: #888; + font-style: italic; +} + +.row-na { + opacity: 0.7; +} + +.row-highlighted { + background: #fff3cd !important; +} + +.comparison-table tbody tr[data-test] { + cursor: pointer; +} + +.comparison-table tbody tr:hover { + background: #f5f5f5; +} + +/* Missing table */ +.missing-header { + margin: 20px 0 8px 0; + font-size: 14px; + color: #666; +} + +.missing-table { + opacity: 0.7; +} + +.no-results { + color: #666; + font-style: italic; + padding: 10px; +} + +/* ============================================================ + v5 SPA Navigation Bar + ============================================================ */ + +.v5-nav { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 15px; + background: #343a40; + border-radius: 4px; + margin-bottom: 15px; + flex-wrap: wrap; +} + +.v5-nav-brand { + color: #fff; + font-weight: 700; + font-size: 16px; + text-decoration: none; + margin-right: 8px; +} + +.v5-nav-brand:hover { + color: #ddd; +} + +.v5-nav-links { + display: flex; + gap: 4px; + flex: 1; +} + +.v5-nav-link { + color: #adb5bd; + text-decoration: none; + font-size: 13px; + padding: 4px 10px; + border-radius: 3px; +} + +.v5-nav-link:hover { + color: #fff; + background: #495057; +} + +.v5-nav-link-active { + color: #fff; + background: #0d6efd; +} + +.v5-nav-right { + display: flex; + gap: 4px; + margin-left: auto; +} + +/* Page container */ +#v5-page { + min-height: 300px; +} + +.page-placeholder { + padding: 40px; + text-align: center; + color: #666; +} + +.page-placeholder h2 { + margin-bottom: 10px; +} + +/* ============================================================ + Phase 2: Core Browsing Pages + ============================================================ */ + +/* Pagination controls */ +.pagination-controls { + display: flex; + gap: 10px; + align-items: center; + margin: 10px 0; +} + +.pagination-btn { + padding: 4px 12px; + border: 1px solid #ccc; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 13px; +} + +.pagination-btn:hover:not(:disabled) { + background: #f0f0f0; +} + +.pagination-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.pagination-range { + font-size: 13px; + color: #666; +} + +/* Dashboard */ +.dashboard-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 16px; +} + +.dashboard-header .page-header { + margin-bottom: 0; +} + +.dashboard-range-group { + display: flex; + gap: 4px; +} + +.dashboard-range-btn { + padding: 4px 12px; + border: 1px solid #ccc; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 13px; +} + +.dashboard-range-btn:hover { + background: #f0f0f0; +} + +.dashboard-range-btn-active { + background: #0d6efd; + color: #fff; + border-color: #0d6efd; +} + +.suite-section { + margin-bottom: 24px; +} + +.sparkline-grid { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.sparkline-card { + width: 300px; + border: 1px solid #dee2e6; + border-radius: 6px; + padding: 8px; + cursor: pointer; + transition: border-color 0.15s; +} + +.sparkline-card:hover { + border-color: #0d6efd; +} + +.sparkline-title { + font-size: 13px; + font-weight: 600; + margin: 0 0 4px 0; + color: #333; +} + +.sparkline-chart { + height: 130px; +} + +.sparkline-loading, .sparkline-error { + height: 130px; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + color: #999; +} + +.sparkline-error { + color: #d62728; +} + +/* Page headers */ +.page-header { + margin: 0 0 15px 0; + font-size: 20px; + color: #333; +} + +/* Metadata definition list */ +.metadata-dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 12px; + margin-bottom: 15px; + font-size: 13px; +} + +.metadata-dl dt { + font-weight: 600; + color: #666; +} + +.metadata-dl dd { + margin: 0; + word-break: break-all; +} + +/* Action links */ +.action-links { + display: flex; + gap: 10px; + margin: 10px 0; +} + +.action-link { + padding: 4px 12px; + border: 1px solid #0d6efd; + color: #0d6efd; + border-radius: 3px; + text-decoration: none; + font-size: 13px; +} + +.action-link:hover { + background: #0d6efd; + color: #fff; +} + +/* Commit search */ +.commit-search { + position: relative; + display: inline-block; +} + +.commit-search-input { + width: 280px; +} + +.commit-search-dropdown { + position: absolute; + z-index: 1000; + width: 100%; + max-height: 200px; + overflow-y: auto; + background: #fff; + border: 1px solid #ccc; + border-top: none; + border-radius: 0 0 3px 3px; + list-style: none; + margin: 0; + padding: 0; + display: none; + box-shadow: 0 2px 8px rgba(0,0,0,0.15); +} + +.commit-search-dropdown.open { + display: block; +} + +.commit-search-field { + color: #0d6efd; + font-size: 12px; + margin-left: 4px; +} + +.commit-search-invalid { + border-color: #d62728 !important; + box-shadow: 0 0 0 2px rgba(214, 39, 40, 0.15) !important; +} + +/* Commit detail */ +.commit-fields { + margin-bottom: 10px; +} + +.commit-nav { + display: flex; + gap: 10px; + margin: 10px 0; +} + +.editable-field { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 10px; + font-size: 13px; +} + +.ordinal-edit-input { + padding: 4px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-size: 13px; +} + +/* Data table tweaks */ +.data-table { + margin-bottom: 10px; +} + +/* ============================================================ + Phase 3: Graph Page + ============================================================ */ + +.controls-row { + display: flex; + gap: 15px; + align-items: flex-end; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.controls-row-top { + align-items: flex-start; + background: #fff; + padding: 12px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.graph-chart { + min-height: 400px; + margin: 10px 0; +} + +/* Machine chip input and chips */ +.machine-chips { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + align-items: flex-start; +} + +.baseline-chips { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + align-items: flex-start; +} + +.machine-chip, .baseline-chip { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 2px 8px; + background: #e9ecef; + border: 1px solid #dee2e6; + border-radius: 12px; + font-size: 12px; + color: #333; +} + +.chip-symbol { + font-size: 14px; + line-height: 1; +} + +.chip-remove { + background: none; + border: none; + padding: 0 2px; + font-size: 14px; + cursor: pointer; + color: #666; + line-height: 1; +} + +.chip-remove:hover { + color: #d62728; +} + +/* Baseline panel */ +.baseline-add-btn { + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + align-self: flex-start; +} + +.baseline-form { + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; + margin-top: 8px; +} + +/* Test selection table container */ +.test-table-container { + margin-top: 10px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.error-text { + color: #d62728; + font-size: 12px; +} + +.test-selection-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; +} + +.test-selection-table td { + padding: 4px 8px; + border-bottom: 1px solid #eee; + cursor: pointer; + user-select: none; +} + +.test-selection-table tbody tr:hover { + background: #f5f5f5; +} + +.sel-checkbox-cell { + width: 1%; + white-space: nowrap; +} + +.sel-checkbox-cell input[type="checkbox"] { + cursor: pointer; +} + +.sel-symbol-cell { + white-space: nowrap; + width: 1%; +} + +.legend-symbol { + font-size: 16px; + vertical-align: middle; + line-height: 1; +} + +.sel-test-name { + /* Left-justified (default) */ +} + +.row-selected { + background: #e7f1ff; +} + +.row-loading { + opacity: 0.6; +} + +@keyframes loading-pulse { + 0%, 100% { opacity: 0.6; } + 50% { opacity: 0.3; } +} + +.row-loading .sel-checkbox-cell { + animation: loading-pulse 1.5s ease-in-out infinite; +} + +.test-selection-message { + padding: 6px 8px; + font-size: 12px; + color: #666; + border-bottom: 1px solid #eee; +} + +.test-selection-table .row-highlighted { + background: #fff3cd !important; +} + +/* Delete section */ +.delete-section { + margin-top: 16px; + margin-bottom: 16px; +} + +.delete-machine-confirm { + margin-top: 8px; +} + +.delete-machine-confirm p { + margin: 0 0 8px 0; + font-size: 13px; + color: #842029; +} + +.delete-machine-confirm .admin-input { + display: block; + margin-bottom: 8px; +} + +/* Reusable tab bar (used by Admin page, Test Suites page) */ + +.v5-tab-bar { + display: flex; + gap: 0; + border-bottom: 2px solid #ddd; + margin-bottom: 16px; +} + +.v5-tab { + padding: 8px 20px; + border: none; + background: none; + cursor: pointer; + font-size: 14px; + color: #555; + border-bottom: 2px solid transparent; + margin-bottom: -2px; +} + +.v5-tab:hover { + color: #333; +} + +.v5-tab-active { + color: #1f77b4; + border-bottom-color: #1f77b4; + font-weight: 600; +} + +/* Suite picker cards */ + +.suite-picker { + display: flex; + gap: 10px; + flex-wrap: wrap; + margin-bottom: 20px; +} + +.suite-card { + padding: 12px 24px; + border: 2px solid #dee2e6; + border-radius: 6px; + background: #f8f9fa; + cursor: pointer; + font-size: 15px; + font-weight: 600; + color: #333; + transition: border-color 0.15s, background 0.15s; +} + +.suite-card:hover { + border-color: #0d6efd; + background: #e7f1ff; +} + +.suite-card-active { + border-color: #0d6efd; + background: #0d6efd; + color: #fff; +} + +/* Admin page */ + +.admin-create-form { + margin-bottom: 16px; +} + +.admin-create-form label { + display: block; + font-weight: 600; + margin-bottom: 6px; +} + +.admin-form-row { + display: flex; + gap: 8px; + align-items: center; +} + +.admin-input { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; + min-width: 200px; +} + +.admin-select { + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 14px; +} + +.admin-btn { + padding: 6px 14px; + background: #1f77b4; + color: #fff; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.admin-btn:hover { + background: #1a6da0; +} + +.admin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-btn-danger { + background: #d62728; +} + +.admin-btn-danger:hover { + background: #b71c1c; +} + +.admin-key-created { + margin-top: 8px; + padding: 10px; + background: #d4edda; + border: 1px solid #c3e6cb; + border-radius: 4px; +} + +.admin-raw-token { + display: flex; + align-items: center; + gap: 8px; + margin-top: 6px; + padding: 12px 16px; + background: #f0f0f0; + border-radius: 6px; + font-family: monospace; + font-size: 15px; + word-break: break-all; +} + +.admin-raw-token span { + flex: 1; +} + +.admin-token-row { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; +} + +.admin-copy-btn { + padding: 0; + border: none; + background: none; + cursor: pointer; + font-size: 16px; + color: #4a90d9; + flex-shrink: 0; +} + +.admin-copy-btn:hover { + color: #1f77b4; +} + +.admin-textarea { + width: 100%; + max-width: 600px; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 4px; + font-family: monospace; + font-size: 13px; + margin-top: 8px; + resize: vertical; +} + +.admin-delete-section { + margin-top: 24px; + padding-top: 16px; + border-top: 1px solid #eee; +} + +.admin-delete-confirm { + margin-top: 12px; +} + +.admin-delete-warning { + color: #d62728; + font-weight: 500; + margin-bottom: 8px; +} + +/* ============================================================ + Regression Pages + ============================================================ */ + +/* State badges — used on regression list and detail pages */ +.state-badge { + display: inline-block; + padding: 2px 8px; + border-radius: 3px; + font-size: 12px; + font-weight: 600; + text-transform: capitalize; +} + +.state-detected { + background: #fff3cd; + color: #856404; +} + +.state-active { + background: #f8d7da; + color: #721c24; +} + +.state-not-to-be-fixed { + background: #e2e3e5; + color: #383d41; +} + +.state-fixed { + background: #d4edda; + color: #155724; +} + +.state-false-positive { + background: #d1ecf1; + color: #0c5460; +} + +/* State filter chips — regression list page */ +.state-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.state-chip { + padding: 4px 12px; + border: 1px solid #ccc; + border-radius: 16px; + background: #fff; + cursor: pointer; + font-size: 12px; + font-weight: 500; + transition: background 0.15s, border-color 0.15s; +} + +.state-chip:hover { + background: #f0f0f0; +} + +.state-chip-active { + border-color: #0d6efd; + background: #e7f1ff; + color: #0d6efd; +} + +/* Regression filter panel */ +.regression-filters { + margin-bottom: 15px; + padding: 12px; + border: 1px solid #dee2e6; + border-radius: 4px; + background: #f8f9fa; +} + +.filter-row { + display: flex; + gap: 12px; + align-items: center; + margin-bottom: 8px; + flex-wrap: wrap; +} + +.filter-row:last-child { + margin-bottom: 0; +} + +.title-search-input { + flex: 1; + min-width: 200px; +} + +/* Regression actions bar */ +.regression-actions { + margin-bottom: 12px; +} + +/* Create form */ +.create-form-container { + margin-bottom: 15px; + padding: 12px; + border: 1px solid #dee2e6; + border-radius: 4px; + background: #fff; +} + +.create-form-container .admin-input { + width: 100%; + margin-bottom: 8px; +} + +.create-form-error { + margin-top: 8px; +} + +/* Row delete button (inline in table row) */ +.row-delete-btn { + background: none; + border: none; + color: #999; + cursor: pointer; + font-size: 16px; + padding: 2px 6px; + border-radius: 3px; + line-height: 1; +} + +.row-delete-btn:hover { + color: #d62728; + background: #fff5f5; +} + +/* Regression detail — header fields */ +.regression-header { + margin-bottom: 20px; +} + +.field-row { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 8px; + font-size: 13px; +} + +.field-row label { + font-weight: 600; + color: #666; + min-width: 70px; +} + +.editable-value { + font-size: 14px; +} + +.edit-btn { + background: none; + border: 1px solid #ccc; + border-radius: 3px; + padding: 2px 8px; + cursor: pointer; + font-size: 12px; + color: #666; +} + +.edit-btn:hover { + background: #f0f0f0; + color: #333; +} + +/* Notes section */ +.regression-notes { + align-items: flex-start; +} + +.notes-display { + white-space: pre-wrap; +} + +/* Notes textarea */ +.regression-notes-input { + width: 100%; + min-height: 60px; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 3px; + font-family: inherit; + font-size: 13px; + resize: vertical; +} + +/* Indicator actions (batch remove) */ +.indicator-actions { + margin-bottom: 8px; +} + +.indicator-filter { + margin-bottom: 8px; +} + +/* Add indicators panel */ +.add-indicators-panel { + margin-top: 15px; + padding: 12px; + border: 1px solid #dee2e6; + border-radius: 4px; + background: #f8f9fa; +} + +.add-indicator-selectors { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 10px; +} + +.add-indicator-selectors .control-group { + min-width: 180px; +} + +.add-indicator-preview { + margin-bottom: 8px; + font-size: 13px; + color: #666; +} + +.add-indicator-actions { + margin-top: 8px; +} + +/* Cross-page regression sections */ +.machine-regressions-section, +.run-regressions-section, +.commit-regressions-section { + margin-top: 20px; + margin-bottom: 20px; +} + +/* Compare page — Add to Regression panel */ +.add-to-regression-panel { + margin-top: 20px; + border: 1px solid #dee2e6; + border-radius: 4px; +} + +.add-to-regression-panel > summary { + padding: 10px 12px; + cursor: pointer; + font-weight: 600; + font-size: 14px; + background: #f8f9fa; + border-radius: 4px; +} + +.add-to-regression-panel[open] > summary { + border-bottom: 1px solid #dee2e6; + border-radius: 4px 4px 0 0; +} + +.add-to-regression-content { + padding: 12px; +} + +.regression-mode-tabs { + display: flex; + gap: 4px; + margin-bottom: 12px; +} + +.tab-btn { + padding: 4px 12px; + border: 1px solid #ccc; + border-radius: 3px; + background: #fff; + cursor: pointer; + font-size: 13px; +} + +.tab-btn:hover { + background: #f0f0f0; +} + +.tab-btn-active { + background: #0d6efd; + color: #fff; + border-color: #0d6efd; +} + +.regression-label-muted { + font-size: 12px; + color: #666; + padding: 4px 8px; +} + +.regression-feedback-ok { + color: #155724; + font-size: 13px; +} + +.test-list-container { + border: 1px solid #eee; + border-radius: 3px; + padding: 4px; + margin-top: 4px; +} + +.checkbox-list-container { + max-height: 200px; + overflow-y: auto; + border: 1px solid #eee; + border-radius: 3px; + padding: 4px; + margin-top: 4px; +} + +.test-list-hint { + font-size: 12px; + color: #999; +} + +.test-list-row { + display: flex; + align-items: center; + gap: 6px; + padding: 2px 0; + font-size: 12px; +} + +/* ============================================================ + Profiles Page + ============================================================ */ + +.profile-suite-row { + margin-bottom: 12px; +} + +.profile-picker { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-bottom: 16px; +} + +.profile-side { + border: 1px solid #e0e0e0; + border-radius: 6px; + padding: 12px; +} + +.profile-side h3 { + margin: 0 0 8px; + font-size: 14px; + color: #666; +} + +.profile-cascade-row { + margin-bottom: 8px; +} + +.profile-cascade-row label { + display: block; + font-weight: 500; + margin-bottom: 2px; + font-size: 12px; + color: #666; +} + +.profile-stats-container { + margin-bottom: 12px; +} + +/* Stats bar table */ +.profile-stats { + width: 100%; + border-collapse: collapse; + margin-bottom: 12px; +} + +.profile-stats th, +.profile-stats td { + padding: 4px 8px; + text-align: left; + border-bottom: 1px solid #eee; +} + +.profile-stats th { + font-weight: 600; + font-size: 12px; + color: #666; + background: #f8f8f8; +} + +.profile-stats-value { + font-variant-numeric: tabular-nums; + text-align: right; +} + +.profile-stats-delta { + position: relative; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.profile-stats-improved { + color: #2e7d32; +} + +.profile-stats-regressed { + color: #c62828; +} + +.profile-stats-bar { + position: absolute; + bottom: 0; + left: 0; + height: 3px; + transition: width 0.2s; +} + +.profile-stats-improved .profile-stats-bar { + background: #66bb6a; +} + +.profile-stats-regressed .profile-stats-bar { + background: #ef5350; +} + +/* Global controls (counter + display mode) */ +.profile-viewer-controls { + display: flex; + gap: 16px; + margin-bottom: 12px; + align-items: flex-end; +} + +/* Columns for A/B disassembly */ +.profile-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 16px; +} + +.profile-column { + min-width: 0; +} + +/* Function selector */ +.profile-fn-selector { + margin-bottom: 8px; +} + +.profile-fn-combobox { + position: relative; +} + +.profile-fn-badge { + display: inline-block; + padding: 1px 4px; + border-radius: 3px; + font-size: 11px; + font-weight: 600; + margin-right: 4px; + color: #333; +} + +/* Disassembly table */ +.profile-disasm { + width: 100%; + border-collapse: collapse; + font-family: "SF Mono", "Menlo", "Monaco", "Consolas", monospace; + font-size: 12px; + table-layout: fixed; +} + +.profile-disasm th, +.profile-disasm td { + padding: 1px 6px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.profile-disasm th { + font-weight: 600; + background: #f8f8f8; + text-align: left; + position: sticky; + top: 0; + z-index: 1; +} + +.profile-disasm-heat { + width: 70px; + text-align: right; + font-variant-numeric: tabular-nums; +} + +.profile-disasm-addr { + width: 100px; + color: #888; +} + +.profile-disasm-text { + /* takes remaining space */ +} + +.profile-row-cap { + padding: 8px; + text-align: center; + color: #666; + font-size: 13px; +} + +.profile-loading { + display: inline-block; + padding: 8px; + color: #666; + font-style: italic; +} + +/* Viewer container */ +.profile-viewer-container { + max-height: 600px; + overflow-y: auto; + border: 1px solid #e0e0e0; + border-radius: 4px; +} + +/* Profile column in comparison and samples tables */ +.col-profile { + text-align: center; + width: 60px; +} diff --git a/lnt/server/ui/v5/frontend/src/table.ts b/lnt/server/ui/v5/frontend/src/table.ts new file mode 100644 index 000000000..3b84ce035 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/table.ts @@ -0,0 +1,407 @@ +import type { ComparisonRow, SortCol, SortDir } from './types'; +import { TABLE_HOVER } from './events'; +import { getState, setState } from './state'; +import { formatValue, formatPercent, formatRatio, el, spaLink, matchesFilter } from './utils'; +import { computeGeomean } from './comparison'; + +function sampleTooltip(samples: number | undefined, runs: number | undefined): string | undefined { + if (samples === undefined || runs === undefined) return undefined; + const s = samples === 1 ? 'sample' : 'samples'; + const r = runs === 1 ? 'run' : 'runs'; + return `${samples} ${s} across ${runs} ${r}`; +} + +export interface TableOptions { + /** Test names that are manually hidden (grayed out in table). Noise-hidden + * rows are filtered upstream before reaching renderTable. */ + hiddenTests?: Set<string>; + /** Called when a row is single-clicked (toggle visibility). */ + onToggle?: (test: string) => void; + /** Called when a row is double-clicked (isolate — hide all others). */ + onIsolate?: (test: string) => void; + /** Map from test name to profile page URL path. When set, a Profile column appears. */ + profileLinks?: Map<string, string>; +} + +let tableContainer: HTMLElement | null = null; +let allRows: ComparisonRow[] = []; +let filteredTests: Set<string> | null = null; // null = show all +let currentOptions: TableOptions = {}; +let renderedRows: Map<string, HTMLTableRowElement> = new Map(); +let renderedMissingRows: Map<string, HTMLTableRowElement> = new Map(); +let rowIndex: Map<string, ComparisonRow> = new Map(); +let geomeanTr: HTMLTableRowElement | null = null; +let summaryTextSpan: HTMLElement | null = null; +let missingHeaderEl: HTMLElement | null = null; + +export function renderTable(container: HTMLElement, rows: ComparisonRow[], options?: TableOptions): void { + tableContainer = container; + allRows = rows; + filteredTests = null; + currentOptions = options ?? {}; + redraw(); +} + +export function filterToTests(tests: Set<string> | null): void { + filteredTests = tests; + if (renderedRows.size > 0) { + applyTableFilters(); + } else { + redraw(); + } +} + +function redraw(): void { + if (!tableContainer) return; + tableContainer.replaceChildren(); + + // Rebuild lookup index + renderedRows.clear(); + renderedMissingRows.clear(); + rowIndex.clear(); + geomeanTr = null; + summaryTextSpan = null; + missingHeaderEl = null; + for (const r of allRows) rowIndex.set(r.test, r); + + const state = getState(); + const { sort, sortDir } = state; + const hiddenTests = currentOptions.hiddenTests ?? new Set<string>(); + + // All rows are built regardless of the active filter so the display:none + // fast path can widen results without a full rebuild. + const presentRows = allRows.filter(r => r.sidePresent === 'both'); + const missingRows = allRows.filter(r => r.sidePresent !== 'both'); + + const sorted = sortRows(presentRows, sort, sortDir); + + const totalPresent = presentRows.length; + if (totalPresent > 0) { + summaryTextSpan = el('span', {}, `${totalPresent} tests`); + const summaryMessage = el('div', { class: 'table-message' }, summaryTextSpan); + tableContainer.append(summaryMessage); + } + + // Main table + if (sorted.length > 0) { + tableContainer.append(buildTable(sorted, sort, sortDir, hiddenTests)); + } + + // Missing tests section + if (missingRows.length > 0) { + missingHeaderEl = el('h4', { class: 'missing-header' }, `Missing tests (${missingRows.length})`) as HTMLElement; + tableContainer.append(missingHeaderEl); + tableContainer.append(buildMissingTable(missingRows)); + } + + applyTableFilters(); +} + +function buildTable(rows: ComparisonRow[], sort: SortCol, sortDir: SortDir, hiddenTests: Set<string>): HTMLTableElement { + const table = el('table', { class: 'comparison-table' }) as HTMLTableElement; + + // Header + const thead = el('thead'); + const headerRow = el('tr'); + const cols: { key: SortCol; label: string }[] = [ + { key: 'test', label: 'Test' }, + { key: 'value_a', label: 'Value A' }, + { key: 'value_b', label: 'Value B' }, + { key: 'delta', label: 'Delta' }, + { key: 'delta_pct', label: 'Delta %' }, + { key: 'ratio', label: 'Ratio' }, + { key: 'status', label: 'Status' }, + ]; + + for (const col of cols) { + const isSorted = col.key === sort; + const ariaSortValue = isSorted ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'; + const th = el('th', { class: 'sortable', 'aria-sort': ariaSortValue }); + const indicator = isSorted ? (sortDir === 'asc' ? ' \u25B2' : ' \u25BC') : ''; + th.textContent = col.label + indicator; + th.addEventListener('click', () => { + if (sort === col.key) { + setState({ sortDir: sortDir === 'asc' ? 'desc' : 'asc' }); + } else { + setState({ sort: col.key, sortDir: 'desc' }); + } + redraw(); + }); + headerRow.append(th); + } + if (currentOptions.profileLinks) { + headerRow.append(el('th', {}, 'Profile')); + } + thead.append(headerRow); + table.append(thead); + + // Body + const tbody = el('tbody'); + + // Geomean summary row (first row) — only visible (non-hidden) rows + const visibleRows = rows.filter(r => !hiddenTests.has(r.test)); + const geomean = computeGeomean(visibleRows); + if (geomean !== null) { + const summaryRow = el('tr', { class: 'geomean-row' }); + summaryRow.append( + el('td', { class: 'col-test' }, el('strong', {}, 'Geomean')), + el('td', { class: 'col-num' }, formatValue(geomean.geomeanA)), + el('td', { class: 'col-num' }, formatValue(geomean.geomeanB)), + el('td', { class: 'col-num' }, formatValue(geomean.delta)), + el('td', { class: 'col-num' }, formatPercent(geomean.deltaPct)), + el('td', { class: 'col-num' }, formatRatio(geomean.ratioGeomean)), + el('td', { class: 'col-status' }, ''), + ); + if (currentOptions.profileLinks) { + summaryRow.append(el('td', { class: 'col-profile' }, '')); + } + tbody.append(summaryRow); + geomeanTr = summaryRow; + } + + for (const row of rows) { + const tr = el('tr', { 'data-test': row.test }); + + if (hiddenTests.has(row.test)) { + tr.classList.add('row-hidden'); + } else if (row.status === 'na') { + tr.classList.add('row-na'); + } + + const tipA = sampleTooltip(row.samplesA, row.runsA); + const tipB = sampleTooltip(row.samplesB, row.runsB); + const combinedTip = (tipA && tipB) ? `A: ${tipA}, B: ${tipB}` : undefined; + + tr.append(el('td', { class: 'col-test' }, row.test)); + tr.append(el('td', { class: 'col-num', ...(tipA ? { title: tipA } : {}) }, formatValue(row.valueA))); + tr.append(el('td', { class: 'col-num', ...(tipB ? { title: tipB } : {}) }, formatValue(row.valueB))); + tr.append(el('td', { class: 'col-num', ...(combinedTip ? { title: combinedTip } : {}) }, formatValue(row.delta))); + tr.append(el('td', { class: 'col-num', ...(combinedTip ? { title: combinedTip } : {}) }, formatPercent(row.deltaPct))); + tr.append(el('td', { class: 'col-num', ...(combinedTip ? { title: combinedTip } : {}) }, formatRatio(row.ratio))); + const statusAttrs: Record<string, string> = { + class: `col-status status-${row.status}`, + }; + if (row.status === 'noise' && row.noiseReasons.length > 0) { + statusAttrs.title = row.noiseReasons.map(r => r.message).join('\n'); + } + tr.append(el('td', statusAttrs, row.status)); + + if (currentOptions.profileLinks) { + const url = currentOptions.profileLinks.get(row.test); + if (url) { + tr.append(el('td', { class: 'col-profile' }, spaLink('View', url))); + } else { + tr.append(el('td', { class: 'col-profile' }, '')); + } + } + + renderedRows.set(row.test, tr); + tbody.append(tr); + } + + // Event delegation for hover sync (1 listener instead of 2N) + tbody.addEventListener('mouseenter', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (tr) { + document.dispatchEvent(new CustomEvent(TABLE_HOVER, { detail: tr.getAttribute('data-test') })); + } + }, true); + tbody.addEventListener('mouseleave', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (tr) { + document.dispatchEvent(new CustomEvent(TABLE_HOVER, { detail: null })); + } + }, true); + + // Click/dblclick for toggle/isolate (200ms delay to distinguish, same as test-selection-table) + if (currentOptions.onToggle || currentOptions.onIsolate) { + let clickTimer: ReturnType<typeof setTimeout> | null = null; + + tbody.addEventListener('click', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (!tr) return; + const test = tr.getAttribute('data-test'); + if (!test) return; + + if (clickTimer !== null) return; // dblclick pending + clickTimer = setTimeout(() => { + clickTimer = null; + if (currentOptions.onToggle) currentOptions.onToggle(test); + }, 200); + }); + + tbody.addEventListener('dblclick', (e) => { + const tr = (e.target as HTMLElement).closest('tr[data-test]'); + if (!tr) return; + const test = tr.getAttribute('data-test'); + if (!test) return; + + if (clickTimer !== null) { + clearTimeout(clickTimer); + clickTimer = null; + } + if (currentOptions.onIsolate) currentOptions.onIsolate(test); + }); + } + + table.append(tbody); + return table; +} + +function buildMissingTable(rows: ComparisonRow[]): HTMLTableElement { + const table = el('table', { class: 'comparison-table missing-table' }) as HTMLTableElement; + const thead = el('thead'); + const headerRow = el('tr'); + headerRow.append(el('th', {}, 'Test'), el('th', {}, 'Value A'), el('th', {}, 'Value B'), el('th', {}, 'Present In')); + if (currentOptions.profileLinks) { + headerRow.append(el('th', {}, 'Profile')); + } + thead.append(headerRow); + table.append(thead); + + const tbody = el('tbody'); + for (const row of rows) { + const tr = el('tr', { 'data-test': row.test, class: 'row-missing' }); + tr.append(el('td', {}, row.test)); + tr.append(el('td', { class: 'col-num' }, formatValue(row.valueA))); + tr.append(el('td', { class: 'col-num' }, formatValue(row.valueB))); + tr.append(el('td', {}, row.sidePresent === 'a_only' ? 'Side A only' : 'Side B only')); + if (currentOptions.profileLinks) { + tr.append(el('td', { class: 'col-profile' }, '')); + } + renderedMissingRows.set(row.test, tr); + tbody.append(tr); + } + table.append(tbody); + return table; +} + +function updateGeomeanCells(tr: HTMLTableRowElement, geomean: ReturnType<typeof computeGeomean>): void { + if (!geomean) { + tr.style.display = 'none'; + return; + } + tr.style.display = ''; + const cells = tr.querySelectorAll('td'); + if (cells[1]) cells[1].textContent = formatValue(geomean.geomeanA); + if (cells[2]) cells[2].textContent = formatValue(geomean.geomeanB); + if (cells[3]) cells[3].textContent = formatValue(geomean.delta); + if (cells[4]) cells[4].textContent = formatPercent(geomean.deltaPct); + if (cells[5]) cells[5].textContent = formatRatio(geomean.ratioGeomean); +} + +/** Fast path: toggle display:none on existing rows. Returns the set of matching test names. */ +export function applyTableFilters(): Set<string> | null { + if (!tableContainer || renderedRows.size === 0) return null; + + const state = getState(); + const { testFilter } = state; + const hiddenTests = currentOptions.hiddenTests ?? new Set<string>(); + + const visibleRows: ComparisonRow[] = []; + const matchingTests = new Set<string>(); + let totalPresent = 0; + + for (const [test, tr] of renderedRows) { + totalPresent++; + + const matchesText = !testFilter || matchesFilter(test, testFilter); + const matchesZoom = !filteredTests || filteredTests.has(test); + const visible = matchesText && matchesZoom; + + tr.style.display = visible ? '' : 'none'; + if (matchesText) matchingTests.add(test); + if (visible && !hiddenTests.has(test)) { + const row = rowIndex.get(test); + if (row) visibleRows.push(row); + } + } + + let visibleMissing = 0; + for (const [test, tr] of renderedMissingRows) { + const matchesText = !testFilter || matchesFilter(test, testFilter); + const matchesZoom = !filteredTests || filteredTests.has(test); + const visible = matchesText && matchesZoom; + tr.style.display = visible ? '' : 'none'; + if (visible) visibleMissing++; + } + + if (missingHeaderEl) { + const totalMissing = renderedMissingRows.size; + if (testFilter || filteredTests) { + missingHeaderEl.textContent = `Missing tests (${visibleMissing} of ${totalMissing} matching)`; + } else { + missingHeaderEl.textContent = `Missing tests (${totalMissing})`; + } + } + + if (geomeanTr) { + const geomean = computeGeomean(visibleRows); + updateGeomeanCells(geomeanTr, geomean); + } + + if (summaryTextSpan) { + const visibleCount = visibleRows.length; + if (testFilter || filteredTests) { + summaryTextSpan.textContent = `${visibleCount} of ${totalPresent} tests matching`; + } else if (visibleCount < totalPresent) { + summaryTextSpan.textContent = `${visibleCount} of ${totalPresent} tests visible`; + } else { + summaryTextSpan.textContent = `${totalPresent} tests`; + } + } + + return testFilter ? matchingTests : null; +} + +export function sortRows(rows: ComparisonRow[], col: SortCol, dir: SortDir): ComparisonRow[] { + const mult = dir === 'asc' ? 1 : -1; + return [...rows].sort((a, b) => { + let av: number | string | null; + let bv: number | string | null; + switch (col) { + case 'test': av = a.test; bv = b.test; break; + case 'value_a': av = a.valueA; bv = b.valueA; break; + case 'value_b': av = a.valueB; bv = b.valueB; break; + case 'delta': av = a.delta; bv = b.delta; break; + case 'delta_pct': av = a.deltaPct; bv = b.deltaPct; break; + case 'ratio': av = a.ratio; bv = b.ratio; break; + case 'status': av = a.status; bv = b.status; break; + } + if (av === null && bv === null) return 0; + if (av === null) return 1; + if (bv === null) return -1; + if (typeof av === 'string' && typeof bv === 'string') { + return av.localeCompare(bv) * mult; + } + return ((av as number) - (bv as number)) * mult; + }); +} + +// External: highlight a row by test name +export function highlightRow(testName: string | null): void { + if (!tableContainer) return; + // Remove previous highlights + for (const el of tableContainer.querySelectorAll('.row-highlighted')) { + el.classList.remove('row-highlighted'); + } + if (!testName) return; + const tr = tableContainer.querySelector(`tr[data-test="${CSS.escape(testName)}"]`); + if (tr) { + tr.classList.add('row-highlighted'); + } +} + +/** Reset module-level state. Call from page unmount. */ +export function resetTable(): void { + tableContainer = null; + allRows = []; + filteredTests = null; + currentOptions = {}; + renderedRows.clear(); + renderedMissingRows.clear(); + rowIndex.clear(); + geomeanTr = null; + summaryTextSpan = null; + missingHeaderEl = null; +} diff --git a/lnt/server/ui/v5/frontend/src/types.ts b/lnt/server/ui/v5/frontend/src/types.ts new file mode 100644 index 000000000..77c64afea --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/types.ts @@ -0,0 +1,276 @@ +// v5 API response types + +export interface FieldInfo { + name: string; + type: string; + display_name: string | null; + unit: string | null; + unit_abbrev: string | null; + bigger_is_better: boolean | null; +} + +export interface CommitSummary { + commit: string; + ordinal: number | null; + tag: string | null; + fields: Record<string, string>; +} + +export interface CommitDetail { + commit: string; + ordinal: number | null; + tag: string | null; + fields: Record<string, string>; + previous_commit: CommitNeighbor | null; + next_commit: CommitNeighbor | null; +} + +export interface CommitNeighbor { + commit: string; + ordinal: number | null; + tag: string | null; + link: string; +} + +export interface CommitResolveResponse { + results: Record<string, CommitSummary>; + not_found: string[]; +} + +export interface MachineInfo { + name: string; + info: Record<string, string>; +} + +export interface RunInfo { + uuid: string; + machine: string; + commit: string; + submitted_at: string | null; + run_parameters?: Record<string, string>; +} + +/** Run as returned by GET /machines/{name}/runs. */ +export interface MachineRunInfo { + uuid: string; + commit: string; + submitted_at: string | null; +} + +export interface RunDetail { + uuid: string; + machine: string; + commit: string; + submitted_at: string | null; + run_parameters: Record<string, string>; +} + +export interface SampleInfo { + test: string; + metrics: Record<string, number | null>; +} + +// Profile types + +export interface ProfileListItem { + test: string; + uuid: string; +} + +export interface ProfileMetadata { + uuid: string; + test: string; + run_uuid: string; + counters: Record<string, number>; + disassembly_format: string; +} + +export interface ProfileFunctionInfo { + name: string; + counters: Record<string, number>; + length: number; +} + +export interface ProfileInstruction { + address: number; + counters: Record<string, number>; + text: string; +} + +export interface ProfileFunctionDetail { + name: string; + counters: Record<string, number>; + disassembly_format: string; + instructions: ProfileInstruction[]; +} + +export interface FieldChangeInfo { + uuid: string; + test: string | null; + machine: string | null; + metric: string | null; + old_value: number; + new_value: number; + start_commit: string | null; + end_commit: string | null; +} + +export interface QueryDataPoint { + test: string; + machine: string; + metric: string; + value: number; + commit: string; + ordinal: number | null; + tag: string | null; + run_uuid: string; + submitted_at: string | null; +} + +export interface CursorPaginated<T> { + items: T[]; + cursor: { + next: string | null; + previous: string | null; + }; +} + +export interface OffsetPaginated<T> { + items: T[]; + total: number; + cursor: { + next: string | null; + previous: string | null; + }; +} + +// App state + +export type AggFn = 'median' | 'mean' | 'min' | 'max'; +export type SortDir = 'asc' | 'desc'; +export type SortCol = 'test' | 'value_a' | 'value_b' | 'delta' | 'delta_pct' | 'ratio' | 'status'; + +export interface SideSelection { + suite: string; + commit: string; + machine: string; + runs: string[]; // UUIDs + runAgg: AggFn; +} + +export interface NoiseKnob { + enabled: boolean; + value: number; +} + +export interface NoiseConfig { + pct: NoiseKnob; // Delta % below threshold + pval: NoiseKnob; // P-value above threshold + floor: NoiseKnob; // Absolute value below floor +} + +export interface NoiseReason { + knob: 'pct' | 'pval' | 'floor'; + message: string; +} + +export interface ShadowConfig { + sideB: SideSelection; +} + +export interface AppState { + sideA: SideSelection; + sideB: SideSelection; + metric: string; + sampleAgg: AggFn; + noiseConfig: NoiseConfig; + sort: SortCol; + sortDir: SortDir; + testFilter: string; + hideNoise: boolean; + shadow: ShadowConfig | null; +} + +// Comparison results + +export type RowStatus = 'improved' | 'regressed' | 'unchanged' | 'noise' | 'missing' | 'na'; + +export interface ComparisonRow { + test: string; + valueA: number | null; + valueB: number | null; + delta: number | null; + deltaPct: number | null; + ratio: number | null; + status: RowStatus; + sidePresent: 'both' | 'a_only' | 'b_only'; + noiseReasons: NoiseReason[]; + samplesA?: number; + samplesB?: number; + runsA?: number; + runsB?: number; +} + +// Admin types + +export interface APIKeyItem { + prefix: string; + name: string; + scope: string; + created_at: string; + last_used_at: string | null; + is_active: boolean; +} + +export interface APIKeyCreateResponse { + key: string; + prefix: string; + scope: string; +} + +export interface TestSuiteInfo { + name: string; + schema: { + metrics: FieldInfo[]; + commit_fields: Array<{ name: string; type: string; display?: boolean }>; + machine_fields: Array<{ name: string; type: string }>; + }; +} + +// Regression types + +export type RegressionState = + | 'detected' + | 'active' + | 'not_to_be_fixed' + | 'fixed' + | 'false_positive'; + +export interface RegressionIndicator { + uuid: string; + machine: string | null; + test: string | null; + metric: string; +} + +/** Regression as returned by GET /regressions (list endpoint). */ +export interface RegressionListItem { + uuid: string; + title: string | null; + bug: string | null; + state: RegressionState; + commit: string | null; + machine_count: number; + test_count: number; +} + +/** Regression as returned by GET /regressions/{uuid} (detail endpoint). */ +export interface RegressionDetail { + uuid: string; + title: string | null; + bug: string | null; + notes: string | null; + state: RegressionState; + commit: string | null; + indicators: RegressionIndicator[]; +} diff --git a/lnt/server/ui/v5/frontend/src/utils.ts b/lnt/server/ui/v5/frontend/src/utils.ts new file mode 100644 index 000000000..07c756671 --- /dev/null +++ b/lnt/server/ui/v5/frontend/src/utils.ts @@ -0,0 +1,330 @@ +import type { AggFn } from './types'; +import { getTestSuiteInfoCached, resolveCommits } from './api'; +import { navigate, getBasePath, getUrlBase } from './router'; + +/** + * Separator between test name and machine name in trace names. + * Uses middle-dot (U+00B7) to avoid ambiguity when machine names contain ' - '. + */ +export const TRACE_SEP = ' \u00b7 '; + +/** Delay (ms) to distinguish single-click from double-click. */ +export const DOUBLE_CLICK_DELAY_MS = 200; + +// Aggregation functions + +/** Default Plotly color palette, shared across Graph and Dashboard sparklines. */ +const PLOTLY_COLORS = [ + '#1f77b4', '#ff7f0e', '#2ca02c', '#d62728', '#9467bd', + '#8c564b', '#e377c2', '#7f7f7f', '#bcbd22', '#17becf', +]; + +/** Return a color from the shared palette by index (wraps around). */ +export function machineColor(index: number): string { + return PLOTLY_COLORS[index % PLOTLY_COLORS.length]; +} + +/** Colors for comparison row statuses, shared across chart and summary bar. */ +export const STATUS_COLORS: Record<string, string> = { + improved: '#2ca02c', + regressed: '#d62728', + noise: '#999999', + unchanged: '#999999', +}; + +export function median(values: number[]): number { + if (values.length === 0) return 0; + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 !== 0 + ? sorted[mid] + : (sorted[mid - 1] + sorted[mid]) / 2; +} + +export function mean(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((s, v) => s + v, 0) / values.length; +} + +export function safeMin(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => Math.min(a, b)); +} + +export function safeMax(values: number[]): number { + if (values.length === 0) return 0; + return values.reduce((a, b) => Math.max(a, b)); +} + +/** Geometric mean of positive values. Returns null if no valid (> 0) values. */ +export function geomean(values: number[]): number | null { + const valid = values.filter(v => v > 0); + if (valid.length === 0) return null; + const sumLog = valid.reduce((s, v) => s + Math.log(v), 0); + return Math.exp(sumLog / valid.length); +} + +export function getAggFn(name: AggFn): (values: number[]) => number { + switch (name) { + case 'median': return median; + case 'mean': return mean; + case 'min': return safeMin; + case 'max': return safeMax; + } +} + +// Formatting + +export function formatValue(v: number | null): string { + if (v === null) return 'N/A'; + if (Math.abs(v) >= 1000) return v.toFixed(1); + if (Math.abs(v) >= 1) return v.toPrecision(4); + if (v === 0) return '0'; + return v.toPrecision(3); +} + +export function formatPercent(v: number | null): string { + if (v === null) return 'N/A'; + const sign = v > 0 ? '+' : ''; + return `${sign}${v.toFixed(2)}%`; +} + +export function formatRatio(v: number | null): string { + if (v === null) return 'N/A'; + return v.toFixed(4); +} + +export function formatTime(iso: string | null, fallback = '\u2014'): string { + if (!iso) return fallback; + try { return new Date(iso).toLocaleString(); } catch { return iso; } +} + +export function truncate(s: string, max: number): string { + return s.length > max ? s.slice(0, max) + '\u2026' : s; +} + +/** Ensure a URL has a protocol; prepend https:// if missing. */ +export function ensureProtocol(url: string): string { + return url.startsWith('http://') || url.startsWith('https://') ? url : `https://${url}`; +} + + +/** + * Return the display value for a commit. If the schema defines a commit_field + * with display=true and the commit's fields dict has a non-null value for it, + * return that value. Otherwise, return the raw commit string. + * + * If the commit has a tag, it is appended in parentheses. + */ +export function commitDisplayValue( + entry: { commit: string; fields: Record<string, string>; tag?: string | null }, + commitFields?: Array<{ name: string; display?: boolean }>, +): string { + let base = entry.commit; + if (commitFields) { + const displayField = commitFields.find(f => f.display); + if (displayField && entry.fields[displayField.name]) { + base = entry.fields[displayField.name]; + } + } + if (entry.tag) { + return `${base} (${entry.tag})`; + } + return base; +} + + +/** + * Resolve display values for a batch of commits via the API. + * + * Fetches the test suite schema (cached) and calls POST /commits/resolve, + * then builds a Map from raw commit string to display value using + * ``commitDisplayValue()``. + * + * On any failure (network, missing schema, etc.) returns an empty map + * so callers can always fall back to raw commit strings without try/catch. + */ +export async function resolveDisplayMap( + suite: string, + commits: string[], + signal?: AbortSignal, +): Promise<Map<string, string>> { + if (commits.length === 0) return new Map(); + try { + const [suiteInfo, resolved] = await Promise.all([ + getTestSuiteInfoCached(suite, signal), + resolveCommits(suite, commits, signal), + ]); + const commitFields = suiteInfo.schema.commit_fields; + const map = new Map<string, string>(); + for (const [key, summary] of Object.entries(resolved.results)) { + map.set(key, commitDisplayValue(summary, commitFields)); + } + return map; + } catch (e) { + if (e instanceof DOMException && e.name === 'AbortError') throw e; + return new Map(); + } +} + + +// Text filtering + +let _cachedFilterKey = ''; +let _cachedRegex: RegExp | null = null; +let _cachedRegexValid = true; +let _cachedPlainFilter = ''; +let _cachedPlainLower = ''; + +function _getRegex(pattern: string): RegExp | null { + const key = 're:' + pattern; + if (_cachedFilterKey !== key) { + _cachedFilterKey = key; + try { + _cachedRegex = new RegExp(pattern, 'i'); + _cachedRegexValid = true; + } catch { + _cachedRegex = null; + _cachedRegexValid = false; + } + } + return _cachedRegex; +} + +export function matchesFilter(text: string, filter: string): boolean { + if (!filter) return true; + if (filter.startsWith('re:')) { + const regex = _getRegex(filter.slice(3)); + return regex ? regex.test(text) : false; + } + if (_cachedPlainFilter !== filter) { + _cachedPlainFilter = filter; + _cachedPlainLower = filter.toLowerCase(); + } + return text.toLowerCase().includes(_cachedPlainLower); +} + +export function isFilterValid(filter: string): boolean { + if (!filter || !filter.startsWith('re:')) return true; + _getRegex(filter.slice(3)); + return _cachedRegexValid; +} + +export function updateFilterValidation(input: HTMLInputElement): void { + const isRegex = input.value.startsWith('re:'); + const valid = isFilterValid(input.value); + + input.classList.toggle('filter-invalid', !valid); + + let container = input.parentElement; + const needsWrap = container + && !container.classList.contains('combobox') + && !container.classList.contains('commit-search') + && !container.classList.contains('filter-input-wrapper'); + + if (isRegex && needsWrap) { + const wrapper = el('div', { class: 'filter-input-wrapper' }); + container!.insertBefore(wrapper, input); + wrapper.appendChild(input); + container = wrapper; + } else if (needsWrap) { + // Input has never entered regex mode, so no wrapper or chip exists yet. + return; + } else { + container = input.parentElement; + } + + let badge = container?.querySelector('.filter-regex-badge') as HTMLElement | null; + + if (isRegex) { + if (!badge) { + badge = el('span', { class: 'filter-regex-badge', 'aria-hidden': 'true' }, 'regex'); + input.after(badge); + } + badge.classList.toggle('filter-regex-badge-invalid', !valid); + input.classList.add('filter-has-badge'); + } else if (badge) { + badge.remove(); + input.classList.remove('filter-has-badge'); + } +} + + +// DOM helpers + +export function debounce<T extends (...args: unknown[]) => void>(fn: T, ms: number): T { + let timer: ReturnType<typeof setTimeout>; + return ((...args: unknown[]) => { + clearTimeout(timer); + timer = setTimeout(() => fn(...args), ms); + }) as T; +} + +export function el<K extends keyof HTMLElementTagNameMap>( + tag: K, + attrs?: Record<string, string | boolean>, + ...children: (Node | string)[] +): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (attrs) { + for (const [k, v] of Object.entries(attrs)) { + if (v === true) { + e.setAttribute(k, ''); + } else if (v === false) { + // Boolean false: omit the attribute entirely + } else { + e.setAttribute(k, v); + } + } + } + for (const child of children) { + e.append(child); + } + return e; +} + +/** Return true when the click should be handled by the browser (new tab, etc.). */ +export function isModifiedClick(e: MouseEvent): boolean { + return e.metaKey || e.ctrlKey || e.shiftKey || e.altKey || e.button !== 0; +} + +/** + * Create an anchor element that navigates via the SPA router. + * All internal links across all pages should use this helper. + * + * The href is set to the real full path so that right-click "Open in new tab", + * Cmd+Click / Ctrl+Click (open in new tab), middle-click, browser status bar, + * and screen readers all work correctly. Modified clicks (Cmd, Ctrl, Shift, + * middle-click) bypass the SPA router and let the browser handle them natively. + */ +export function spaLink(text: string, path: string): HTMLAnchorElement { + const a = el('a', { href: getBasePath() + path, class: 'spa-link' }, text); + a.addEventListener('click', (e) => { + if (isModifiedClick(e)) return; + e.preventDefault(); + navigate(path); + }); + return a; +} + +/** + * Build a full URL for a suite-agnostic page. + * @param path Path relative to /v5, e.g. "/compare?suite_a=nts" + */ +export function agnosticUrl(path: string): string { + return getUrlBase() + '/v5' + path; +} + +/** + * Create an anchor element that links to a suite-agnostic page. + * + * Use this for cross-context links from suite-scoped pages to suite-agnostic + * pages (e.g. Graph, Compare). These links trigger a full page load since the + * SPA context changes (different route table, different basePath). + * + * @param text Link text + * @param path Path relative to /v5, e.g. "/compare?suite_a=nts&machine_a=..." + */ +export function agnosticLink(text: string, path: string): HTMLAnchorElement { + return el('a', { href: agnosticUrl(path), class: 'spa-link' }, text); +} diff --git a/lnt/server/ui/v5/frontend/tsconfig.json b/lnt/server/ui/v5/frontend/tsconfig.json new file mode 100644 index 000000000..6d657b2d3 --- /dev/null +++ b/lnt/server/ui/v5/frontend/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "isolatedModules": true, + "skipLibCheck": true + }, + "include": ["src"] +} diff --git a/lnt/server/ui/v5/frontend/vite.config.ts b/lnt/server/ui/v5/frontend/vite.config.ts new file mode 100644 index 000000000..380381fd5 --- /dev/null +++ b/lnt/server/ui/v5/frontend/vite.config.ts @@ -0,0 +1,25 @@ +import { defineConfig } from 'vite'; +import { resolve } from 'path'; + +export default defineConfig({ + build: { + outDir: resolve(__dirname, '../static/v5'), + emptyOutDir: true, + sourcemap: true, + lib: { + entry: resolve(__dirname, 'src/main.ts'), + formats: ['iife'], + name: 'LNTv5', + fileName: () => 'v5.js', + }, + rollupOptions: { + external: ['plotly.js-dist'], + output: { + globals: { + 'plotly.js-dist': 'Plotly', + }, + assetFileNames: 'v5[extname]', + }, + }, + }, +}); diff --git a/lnt/server/ui/v5/templates/v5_app.html b/lnt/server/ui/v5/templates/v5_app.html new file mode 100644 index 000000000..ef4841f42 --- /dev/null +++ b/lnt/server/ui/v5/templates/v5_app.html @@ -0,0 +1,19 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"/> + <meta name="viewport" content="width=device-width, initial-scale=1.0"/> + <title>{{ old_config.name }}{% if g.testsuite_name %} : {{ g.testsuite_name }}{% endif %} - v5 UI + + + + + + +
+
+ + + diff --git a/lnt/server/ui/v5/views.py b/lnt/server/ui/v5/views.py new file mode 100644 index 000000000..d1f635923 --- /dev/null +++ b/lnt/server/ui/v5/views.py @@ -0,0 +1,55 @@ +from flask import abort, render_template, request + +from . import v5_frontend, _setup_testsuite + + +def _v5_url_base(): + """Compute the LNT URL base for the v5 SPA.""" + return request.script_root + + +def _v5_render(**kwargs): + """Render the v5 SPA shell with common template variables.""" + return render_template("v5_app.html", + lnt_url_base=_v5_url_base(), + **kwargs) + + +@v5_frontend.route("/v5/", strict_slashes=False) +@v5_frontend.route("/v5/test-suites", strict_slashes=False) +@v5_frontend.route("/v5/admin", strict_slashes=False) +@v5_frontend.route("/v5/graph", strict_slashes=False) +@v5_frontend.route("/v5/compare", strict_slashes=False) +@v5_frontend.route("/v5/profiles", strict_slashes=False) +def v5_global(): + """Suite-agnostic pages (dashboard, test suites, admin, graph, compare, profiles). + + Serves the SPA shell with an empty testsuite. Each page manages + suite selection internally via its own UI controls. The list of + available test suites is provided via data-testsuites. + """ + _setup_testsuite('') + try: + db = request.get_db() + return _v5_render(testsuites=sorted(db.testsuite.keys())) + finally: + request.session.close() + + +@v5_frontend.route("/v5//") +@v5_frontend.route("/v5//") +def v5_app(testsuite_name, subpath=None): + """Catch-all route for the v5 SPA. + + All client-side routes (dashboard, machines, graph, compare, etc.) + hit this single endpoint, which serves the SPA shell. The TypeScript + router handles the rest. + """ + _setup_testsuite(testsuite_name) + try: + db = request.get_db() + if testsuite_name not in db.testsuite: + abort(404) + return _v5_render(testsuites=sorted(db.testsuite.keys())) + finally: + request.session.close() diff --git a/pyproject.toml b/pyproject.toml index cc6e4aa6f..03f7c7d0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -42,6 +42,8 @@ dependencies = [ "PyYAML>=6.0.0", "requests", "SQLAlchemy==1.3.24", + "flask-smorest>=0.44.0", + "flask-compress>=1.13", "typing", "Werkzeug>=3.1.5", "WTForms>=3.2.0", @@ -98,6 +100,13 @@ find = {namespaces = false} "templates/reporting/*.html", "templates/reporting/*.txt", ] +"lnt.server.ui.v5" = [ + "templates/*.html", + "static/v5/*.js", + "static/v5/*.css", + "static/v5/*.map", + "static/v5/*.ico", +] "lnt.server.db" = [ "migrations/*.py" ] @@ -114,7 +123,7 @@ count = true plugins = ["sqlmypy"] [tool.tox] -env_list = ["py3", "mypy", "flake8", "docs"] +env_list = ["py3", "mypy", "flake8", "js", "docker", "docs"] [tool.tox.env.py3] description = "Run the unit tests" @@ -131,6 +140,17 @@ deps = [".[dev]"] commands = [["flake8", "--statistics", "--exclude=./lnt/external/", "./lnt/", "./tests/"]] skip_install = true +[tool.tox.env.js] +description = "Typecheck, test, and build the v5 TypeScript frontend" +allowlist_externals = ["npm"] +commands = [ + ["npm", "--prefix", "lnt/server/ui/v5/frontend", "install"], + ["npm", "--prefix", "lnt/server/ui/v5/frontend", "run", "typecheck"], + ["npm", "--prefix", "lnt/server/ui/v5/frontend", "test"], + ["npm", "--prefix", "lnt/server/ui/v5/frontend", "run", "build"], +] +skip_install = true + [tool.tox.env.mypy] description = "Typecheck the codebase" # TODO: Make it possible to install [.dev] dependencies instead @@ -143,6 +163,12 @@ deps = [ commands = [["mypy", "--ignore-missing-imports", "lnt"]] skip_install = true +[tool.tox.env.docker] +description = "Run Docker integration tests (requires Docker)" +allowlist_externals = ["tests/docker/smoke-test.sh"] +commands = [["tests/docker/smoke-test.sh"]] +skip_install = true + [tool.tox.env.docs] description = "Build the documentation" deps = [ diff --git a/requirements.server.txt b/requirements.server.txt index bcd14ee9e..9158a8731 100644 --- a/requirements.server.txt +++ b/requirements.server.txt @@ -4,3 +4,4 @@ -r requirements.client.txt gunicorn==23.0.0 psycopg2==2.9.10 +flask-compress==1.17 diff --git a/tests/docker/smoke-test.sh b/tests/docker/smoke-test.sh new file mode 100755 index 000000000..c0a4f0275 --- /dev/null +++ b/tests/docker/smoke-test.sh @@ -0,0 +1,303 @@ +#!/usr/bin/env bash +# End-to-end smoke test for the Docker-based LNT deployment. +# +# Builds the Docker Compose stack from the currently checked-out source, +# creates a test suite via the API, exercises all key v5 endpoints, and +# tears down with volume removal on exit. +# +# Uses a separate compose project name and host port to avoid interfering +# with any local development stack. +# +# Usage: +# ./tests/docker/smoke-test.sh +# +# Requirements: docker, docker compose, curl, jq + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +COMPOSE_FILE="${REPO_ROOT}/docker/compose.yaml" +PROJECT_NAME="lnt-smoke-test-$$" +HOST_PORT=$(python3 -c "import socket; s=socket.socket(); s.bind(('',0)); print(s.getsockname()[1]); s.close()") +BASE_URL="http://localhost:${HOST_PORT}" +AUTH_TOKEN="smoke-test-token" +SUITE="smoketest" + +# Export secrets and port override for docker compose. +export LNT_DB_PASSWORD="smoke-test-password" +export LNT_AUTH_TOKEN="${AUTH_TOKEN}" +export LNT_HOST_PORT="${HOST_PORT}" + +# Common compose flags: use isolated project name. +COMPOSE_CMD=(docker compose -f "$COMPOSE_FILE" -p "$PROJECT_NAME") + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- +pass_count=0 +fail_count=0 + +pass() { echo " PASS $1"; pass_count=$((pass_count + 1)); } +fail() { echo " FAIL $1 -- $2"; fail_count=$((fail_count + 1)); } + +# check_endpoint LABEL URL EXPECTED_STATUS [METHOD [BODY [JQ_EXPR]]] +# +# Single curl call that checks the HTTP status code and optionally validates +# a jq expression on the response body. +check_endpoint() { + local label="$1" url="$2" expected="$3" method="${4:-GET}" body="${5:-}" jq_expr="${6:-}" + local args=(-s -w '\n%{http_code}' -L -X "$method") + if [ -n "$body" ]; then + args+=(-H 'Content-Type: application/json' -d "$body") + fi + args+=(-H "Authorization: Bearer ${AUTH_TOKEN}") + + local output status json_body + output=$(curl "${args[@]}" "$url") + status="${output##*$'\n'}" + json_body="${output%$'\n'*}" + + if [ "$status" = "$expected" ]; then + pass "$label (HTTP $status)" + else + fail "$label" "expected $expected, got $status" + return + fi + + if [ -n "$jq_expr" ]; then + local result + result=$(echo "$json_body" | jq -r "$jq_expr" 2>/dev/null) + if [ -n "$result" ] && [ "$result" != "null" ]; then + pass "$label: $jq_expr = $result" + else + fail "$label: $jq_expr" "returned empty/null" + fi + fi +} + +cleanup() { + echo "" + echo "Tearing down smoke-test Docker stack..." + "${COMPOSE_CMD[@]}" down -v --remove-orphans 2>/dev/null || true + docker rmi "${PROJECT_NAME}-webserver" 2>/dev/null || true +} +trap cleanup EXIT + +# --------------------------------------------------------------------------- +# Start +# --------------------------------------------------------------------------- +echo "=== LNT Docker Smoke Test ===" +echo " project: ${PROJECT_NAME}" +echo " port: ${HOST_PORT}" +echo "" + +echo "Building and starting Docker Compose stack..." +"${COMPOSE_CMD[@]}" up --build --detach + +# Wait for the container's webserver to be listening, using the API discovery +# endpoint (simpler than the SPA route which depends on templates). +echo "Waiting for server to be ready..." +timeout 120 bash -c " + until curl -sf http://localhost:${HOST_PORT}/api/v5/ > /dev/null 2>&1; do + sleep 2 + done +" || { + echo "Server did not become ready in 120s. Logs:" + "${COMPOSE_CMD[@]}" logs + exit 1 +} +echo "Server is ready." +echo "" + +# --------------------------------------------------------------------------- +# Test: SPA shell +# --------------------------------------------------------------------------- +echo "--- SPA Shell ---" +check_endpoint "GET /v5/" "${BASE_URL}/v5/" 200 + +# --------------------------------------------------------------------------- +# Test: Discovery +# --------------------------------------------------------------------------- +echo "--- Discovery ---" +check_endpoint "GET /api/v5/" "${BASE_URL}/api/v5/" 200 GET "" ".test_suites | type" + +# --------------------------------------------------------------------------- +# Test: Create test suite +# --------------------------------------------------------------------------- +echo "--- Test Suite CRUD ---" +SUITE_BODY=$(cat <<'ENDJSON' +{ + "name": "smoketest", + "metrics": [ + {"name": "execution_time", "type": "real", "unit": "seconds"}, + {"name": "compile_time", "type": "real", "unit": "seconds"} + ], + "commit_fields": [ + {"name": "git_sha", "searchable": true} + ], + "machine_fields": [ + {"name": "os", "searchable": true} + ] +} +ENDJSON +) +check_endpoint "POST /api/v5/test-suites (create)" \ + "${BASE_URL}/api/v5/test-suites" 201 POST "$SUITE_BODY" + +check_endpoint "GET /api/v5/test-suites/${SUITE}" \ + "${BASE_URL}/api/v5/test-suites/${SUITE}" 200 GET "" ".schema.name" + +# Verify the SPA shell reflects the newly created suite. With 8 gunicorn +# workers, the request may land on a worker that did not handle the POST; +# ensure_fresh must propagate the new suite to that worker's cache. +spa_html=$(curl -s -H "Authorization: Bearer ${AUTH_TOKEN}" "${BASE_URL}/v5/test-suites") +if echo "$spa_html" | grep -q "${SUITE}"; then + pass "SPA shell lists new suite '${SUITE}'" +else + fail "SPA shell lists new suite '${SUITE}'" "suite name not found in /v5/test-suites HTML" +fi + +# --------------------------------------------------------------------------- +# Test: Submit a run +# --------------------------------------------------------------------------- +echo "--- Run Submission ---" +RUN_BODY=$(cat <<'ENDJSON' +{ + "format_version": "5", + "machine": {"name": "smoke-machine", "os": "linux"}, + "commit": "abc123", + "commit_fields": {"git_sha": "abc123def456"}, + "tests": [ + {"name": "test.suite/bench1", "execution_time": 1.5, "compile_time": 0.3}, + {"name": "test.suite/bench2", "execution_time": 2.0, "compile_time": 0.5} + ] +} +ENDJSON +) +check_endpoint "POST /api/v5/${SUITE}/runs (submit)" \ + "${BASE_URL}/api/v5/${SUITE}/runs" 201 POST "$RUN_BODY" + +# --------------------------------------------------------------------------- +# Test: Concurrent submission (same machine, commit, and test names) +# --------------------------------------------------------------------------- +echo "--- Concurrent Submission ---" + +CONCURRENT=20 +tmpdir=$(mktemp -d) +pids=() +for i in $(seq 1 $CONCURRENT); do + curl -s -o /dev/null -w '%{http_code}' -X POST \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + -H "Content-Type: application/json" \ + -d "{ + \"format_version\": \"5\", + \"machine\": {\"name\": \"concurrent-machine\", \"os\": \"linux\"}, + \"commit\": \"concurrent-rev\", + \"commit_fields\": {\"git_sha\": \"concurrent-sha\"}, + \"tests\": [ + {\"name\": \"test.suite/concurrent-bench\", \"execution_time\": ${i}.0} + ] + }" \ + "${BASE_URL}/api/v5/${SUITE}/runs" \ + > "${tmpdir}/status-${i}" 2>/dev/null & + pids+=($!) +done + +concurrent_failures=0 +for i in $(seq 1 $CONCURRENT); do + wait "${pids[$((i-1))]}" || true + status=$(cat "${tmpdir}/status-${i}" 2>/dev/null || echo "000") + if [ "$status" != "201" ]; then + concurrent_failures=$((concurrent_failures + 1)) + fi +done +rm -rf "$tmpdir" + +if [ "$concurrent_failures" -eq 0 ]; then + pass "All $CONCURRENT concurrent submissions returned 201" +else + fail "Concurrent submission" "$concurrent_failures of $CONCURRENT requests failed" +fi + +# Verify exactly 1 machine, 1 commit, and N runs were created. +check_endpoint "Exactly 1 concurrent machine" \ + "${BASE_URL}/api/v5/${SUITE}/machines?search=concurrent-machine" 200 \ + GET "" '.items | length | if . == 1 then "1" else null end' + +check_endpoint "Exactly 1 concurrent commit" \ + "${BASE_URL}/api/v5/${SUITE}/commits?search=concurrent" 200 \ + GET "" '.items | length | if . == 1 then "1" else null end' + +check_endpoint "All $CONCURRENT concurrent runs exist" \ + "${BASE_URL}/api/v5/${SUITE}/runs?machine=concurrent-machine&limit=100" 200 \ + GET "" ".items | length | if . == $CONCURRENT then \"$CONCURRENT\" else null end" + +check_endpoint "Exactly 1 concurrent test" \ + "${BASE_URL}/api/v5/${SUITE}/tests?search=test.suite/concurrent-bench" 200 \ + GET "" '.items | length | if . == 1 then "1" else null end' + +# --------------------------------------------------------------------------- +# Test: Read endpoints +# --------------------------------------------------------------------------- +echo "--- Read Endpoints ---" +check_endpoint "GET /api/v5/${SUITE}/runs" \ + "${BASE_URL}/api/v5/${SUITE}/runs" 200 GET "" ".items | length" + +check_endpoint "GET /api/v5/${SUITE}/machines" \ + "${BASE_URL}/api/v5/${SUITE}/machines" 200 GET "" ".items | length" + +check_endpoint "GET /api/v5/${SUITE}/commits" \ + "${BASE_URL}/api/v5/${SUITE}/commits" 200 GET "" ".items | length" + +check_endpoint "GET /api/v5/${SUITE}/tests" \ + "${BASE_URL}/api/v5/${SUITE}/tests" 200 GET "" ".items | length" + +# --------------------------------------------------------------------------- +# Test: Response compression +# --------------------------------------------------------------------------- +echo "--- Response Compression ---" +resp_headers=$(mktemp) +resp_body=$(curl -s --compressed \ + -H "Authorization: Bearer ${AUTH_TOKEN}" \ + -D "$resp_headers" \ + "${BASE_URL}/api/v5/${SUITE}/runs") + +if grep -qi 'content-encoding:.*gzip' "$resp_headers"; then + pass "Response has Content-Encoding: gzip" +else + fail "Response compression" "missing Content-Encoding: gzip header" +fi + +items=$(echo "$resp_body" | jq -r '.items | length') +if [ -n "$items" ] && [ "$items" -gt 0 ]; then + pass "Compressed response is valid JSON ($items items)" +else + fail "Compressed response" "invalid JSON or no items" +fi +rm -f "$resp_headers" + +# --------------------------------------------------------------------------- +# Test: Time-series query +# --------------------------------------------------------------------------- +echo "--- Time-Series Query ---" +QUERY_BODY=$(cat <<'ENDJSON' +{ + "metric": "execution_time", + "machine": "smoke-machine" +} +ENDJSON +) +check_endpoint "POST /api/v5/${SUITE}/query" \ + "${BASE_URL}/api/v5/${SUITE}/query" 200 POST "$QUERY_BODY" ".items | length" + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "=== Results: ${pass_count} passed, ${fail_count} failed ===" +if [ "$fail_count" -gt 0 ]; then + echo "" + echo "Docker logs:" + "${COMPOSE_CMD[@]}" logs --tail=50 + exit 1 +fi diff --git a/tests/lit.cfg b/tests/lit.cfg index 29f10a89e..f6949a856 100644 --- a/tests/lit.cfg +++ b/tests/lit.cfg @@ -18,7 +18,7 @@ config.test_format = lit.formats.ShTest() config.suffixes = ['.py', '.shtest'] # excludes: A list of individual files to exclude. -config.excludes = ['__init__.py', 'Inputs', 'utils'] +config.excludes = ['__init__.py', 'Inputs', 'utils', 'v5_test_helpers.py'] # test_source_root: The root path where tests are located. config.test_source_root = os.path.dirname(os.path.abspath(__file__)) diff --git a/tests/lnttool/create.shtest b/tests/lnttool/create.shtest index 296b93952..05ce13703 100644 --- a/tests/lnttool/create.shtest +++ b/tests/lnttool/create.shtest @@ -85,3 +85,21 @@ lnt import "${TMPBASE}/instance" "${SHARED_INPUTS}/sample-a-small.plist" \ | filecheck --check-prefix CHECK-IMPORT "${THIS_FILE}" # CHECK-IMPORT: Import succeeded. + +############################################################################### +# v5 creation: --db-version 5.0 writes db_version into the config and +# initializes the v5 global tables. +############################################################################### + +# Create the database first (Postgres requires it to exist). +psql "${LNT_TEST_DB_URI}/postgres" -c "CREATE DATABASE lnt_test_v5" 2>/dev/null || true + +lnt create "${TMPBASE}/v5inst" \ + --db-dir "${LNT_TEST_DB_URI}" \ + --default-db "lnt_test_v5" \ + --db-version 5.0 \ + --api-auth-token test_token + +filecheck --check-prefix CHECK-V5 "${THIS_FILE}" < "${TMPBASE}/v5inst/lnt.cfg" + +# CHECK-V5: 'default' : { 'db_version': '5.0', 'path' : '{{.*}}lnt_test_v5' }, diff --git a/tests/server/__init__.py b/tests/server/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/api/__init__.py b/tests/server/api/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/api/v5/__init__.py b/tests/server/api/v5/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/api/v5/test_access_log.py b/tests/server/api/v5/test_access_log.py new file mode 100644 index 000000000..059097b76 --- /dev/null +++ b/tests/server/api/v5/test_access_log.py @@ -0,0 +1,209 @@ +# Tests for the v5 access log. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import logging +import re +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, +) + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + +# Apache combined log format regex. +# Fields: ip - user [timestamp] "method path protocol" status size "referer" "ua" +COMBINED_RE = re.compile( + r'^(?P\S+) - (?P\S+) ' + r'\[(?P[^\]]+)\] ' + r'"(?P\S+) (?P\S+) (?P[^"]+)" ' + r'(?P\d+) (?P\S+) ' + r'"(?P[^"]*)" "(?P[^"]*)"$' +) + + +class _LogCapture(logging.Handler): + """A logging handler that records log lines into a list.""" + + def __init__(self): + super().__init__() + self.lines = [] + + def emit(self, record): + self.lines.append(self.format(record)) + + def clear(self): + self.lines.clear() + + +class _AccessLogTestCase(unittest.TestCase): + """Base class for access log tests. + + Sets up a Flask app, test client, and log capture handler. + Cleans up the handler on teardown to prevent handler accumulation + on the singleton logger. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls.capture = _LogCapture() + cls.capture.setFormatter(logging.Formatter('%(message)s')) + logger = logging.getLogger('lnt.server.api.v5.access') + logger.addHandler(cls.capture) + + @classmethod + def tearDownClass(cls): + logger = logging.getLogger('lnt.server.api.v5.access') + logger.removeHandler(cls.capture) + super().tearDownClass() + + def setUp(self): + self.capture.clear() + + +class TestAccessLogFormat(_AccessLogTestCase): + """Verify the access log emits valid Apache combined format.""" + + def test_log_line_matches_combined_format(self): + self.client.get(PREFIX + '/commits') + self.assertEqual(len(self.capture.lines), 1) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertIsNotNone(m, f'Log line does not match combined format: ' + f'{self.capture.lines[0]!r}') + + def test_method_and_path(self): + self.client.get(PREFIX + '/commits') + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('method'), 'GET') + self.assertIn('/api/v5/nts/commits', m.group('path')) + + def test_status_code_200(self): + self.client.get(PREFIX + '/commits') + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('status'), '200') + + def test_post_status_code_201(self): + rev = f'log-{uuid.uuid4().hex[:8]}' + self.client.post(PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers()) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('status'), '201') + self.assertEqual(m.group('method'), 'POST') + + +class TestAccessLogUser(_AccessLogTestCase): + """Verify the user field reflects authentication state.""" + + def test_unauthenticated_request(self): + self.client.get(PREFIX + '/commits') + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('user'), '-') + + def test_bootstrap_token(self): + self.client.get(PREFIX + '/commits', headers=admin_headers()) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('user'), 'bootstrap') + + def test_named_api_key(self): + headers = make_scoped_headers(self.app, 'read') + self.capture.clear() # discard log from API key creation + self.client.get(PREFIX + '/commits', headers=headers) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('user'), 'test-read') + + +class TestAccessLogMiddleware404(_AccessLogTestCase): + """Verify logging for requests that fail at the middleware level.""" + + def test_nonexistent_testsuite(self): + resp = self.client.get('/api/v5/nonexistent/commits') + self.assertEqual(resp.status_code, 404) + self.assertEqual(len(self.capture.lines), 1) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('status'), '404') + self.assertEqual(m.group('user'), '-') + + +class TestAccessLogHeaders(_AccessLogTestCase): + """Verify Referer and User-Agent appear in the log.""" + + def test_referer_present(self): + self.client.get(PREFIX + '/commits', + headers={'Referer': 'http://example.com/page'}) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('referer'), 'http://example.com/page') + + def test_referer_absent(self): + self.client.get(PREFIX + '/commits') + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('referer'), '-') + + def test_user_agent_present(self): + self.client.get(PREFIX + '/commits', + headers={'User-Agent': 'TestBot/1.0'}) + m = COMBINED_RE.match(self.capture.lines[0]) + self.assertEqual(m.group('ua'), 'TestBot/1.0') + + def test_user_agent_absent(self): + # Werkzeug test client sends a default User-Agent; override to empty. + self.client.get(PREFIX + '/commits', + headers={'User-Agent': ''}) + m = COMBINED_RE.match(self.capture.lines[0]) + # Empty string or '-' are both acceptable for absent user agent. + self.assertIn(m.group('ua'), ('', '-')) + + +class TestAccessLogContentLength(_AccessLogTestCase): + """Verify the size field reflects response content length.""" + + def test_response_with_body_has_size(self): + self.client.get(PREFIX + '/commits') + m = COMBINED_RE.match(self.capture.lines[0]) + size = m.group('size') + # Should be a positive integer, not '-' + self.assertNotEqual(size, '-') + self.assertGreater(int(size), 0) + + +class TestResponseCompression(unittest.TestCase): + """Verify gzip compression is active when the client requests it.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + # flask-compress only compresses responses above COMPRESS_MIN_SIZE + # (default 500 bytes). Lower it so we can test with small responses. + cls.app.config['COMPRESS_MIN_SIZE'] = 0 + cls.client = create_client(cls.app) + + def test_gzip_when_accept_encoding_set(self): + resp = self.client.get( + PREFIX + '/commits', + headers={'Accept-Encoding': 'gzip'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.headers.get('Content-Encoding'), 'gzip') + + def test_no_encoding_when_not_requested(self): + resp = self.client.get(PREFIX + '/commits') + self.assertEqual(resp.status_code, 200) + self.assertIsNone(resp.headers.get('Content-Encoding')) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]]) diff --git a/tests/server/api/v5/test_admin.py b/tests/server/api/v5/test_admin.py new file mode 100644 index 000000000..e33556ebd --- /dev/null +++ b/tests/server/api/v5/test_admin.py @@ -0,0 +1,460 @@ +# Tests for the v5 Admin / API Key endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, +) + + +class TestListAPIKeysEmpty(unittest.TestCase): + """GET /api/v5/admin/api-keys with no keys in the database.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_list_keys_returns_200(self): + resp = self.client.get( + '/api/v5/admin/api-keys', headers=self._admin_headers) + self.assertEqual(resp.status_code, 200) + + def test_list_keys_empty_initially(self): + resp = self.client.get( + '/api/v5/admin/api-keys', headers=self._admin_headers) + data = resp.get_json() + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + # There may be keys from other tests that ran first; what matters is + # the structure is correct. + + +class TestCreateAPIKey(unittest.TestCase): + """POST /api/v5/admin/api-keys.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_create_key_valid_scope(self): + """Create a key with a valid scope and verify the response.""" + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'my-read-key', 'scope': 'read'}, + headers=self._admin_headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIn('key', data) + self.assertIn('prefix', data) + self.assertIn('scope', data) + self.assertEqual(data['scope'], 'read') + # Prefix is the first 8 chars of the token + self.assertEqual(data['prefix'], data['key'][:8]) + # Token should be reasonably long + self.assertGreaterEqual(len(data['key']), 32) + + def test_create_key_all_valid_scopes(self): + """All five scopes should be accepted.""" + for scope in ('read', 'submit', 'triage', 'manage', 'admin'): + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': f'key-{scope}', 'scope': scope}, + headers=self._admin_headers, + ) + self.assertEqual( + resp.status_code, 201, + f"Failed to create key with scope '{scope}': " + f"{resp.get_data(as_text=True)}") + + def test_create_key_invalid_scope(self): + """An invalid scope should return 422.""" + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'bad-key', 'scope': 'superadmin'}, + headers=self._admin_headers, + ) + self.assertIn(resp.status_code, (400, 422)) + + def test_create_key_missing_name(self): + """Missing 'name' field should return 422.""" + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'scope': 'read'}, + headers=self._admin_headers, + ) + self.assertIn(resp.status_code, (400, 422)) + + def test_create_key_missing_scope(self): + """Missing 'scope' field should return 422.""" + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'no-scope-key'}, + headers=self._admin_headers, + ) + self.assertIn(resp.status_code, (400, 422)) + + +class TestCreateAPIKeyAuth(unittest.TestCase): + """Auth enforcement on POST /api/v5/admin/api-keys.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_create_key_without_auth(self): + """No auth header should return 401.""" + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'unauth-key', 'scope': 'read'}, + ) + self.assertEqual(resp.status_code, 401) + + def test_create_key_with_read_scope(self): + """A read-scoped key should get 403 on admin endpoints.""" + read_headers = make_scoped_headers(self.app, 'read') + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'from-read', 'scope': 'read'}, + headers=read_headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_key_with_submit_scope(self): + """A submit-scoped key should get 403 on admin endpoints.""" + submit_headers = make_scoped_headers(self.app, 'submit') + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'from-submit', 'scope': 'read'}, + headers=submit_headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_key_with_triage_scope(self): + """A triage-scoped key should get 403 on admin endpoints.""" + triage_headers = make_scoped_headers(self.app, 'triage') + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'from-triage', 'scope': 'read'}, + headers=triage_headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_key_with_manage_scope(self): + """A manage-scoped key should get 403 on admin endpoints.""" + manage_headers = make_scoped_headers(self.app, 'manage') + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'from-manage', 'scope': 'read'}, + headers=manage_headers, + ) + self.assertEqual(resp.status_code, 403) + + +class TestListAPIKeysAuth(unittest.TestCase): + """Auth enforcement on GET /api/v5/admin/api-keys.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_keys_without_auth(self): + """No auth header should return 401.""" + resp = self.client.get('/api/v5/admin/api-keys') + self.assertEqual(resp.status_code, 401) + + def test_list_keys_with_manage_scope_403(self): + """A manage-scoped key (one below admin) should get 403.""" + manage_headers = make_scoped_headers(self.app, 'manage') + resp = self.client.get( + '/api/v5/admin/api-keys', headers=manage_headers) + self.assertEqual(resp.status_code, 403) + + def test_list_keys_with_admin_scope_200(self): + """An admin-scoped key (the required scope) succeeds.""" + resp = self.client.get( + '/api/v5/admin/api-keys', headers=admin_headers()) + self.assertEqual(resp.status_code, 200) + + +class TestListAPIKeysAfterCreate(unittest.TestCase): + """After creating keys, they appear in the list.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_created_key_appears_in_list(self): + # Create a key + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'list-test-key', 'scope': 'submit'}, + headers=self._admin_headers, + ) + self.assertEqual(create_resp.status_code, 201) + created = create_resp.get_json() + prefix = created['prefix'] + + # List keys and check the new key is there + list_resp = self.client.get( + '/api/v5/admin/api-keys', headers=self._admin_headers) + self.assertEqual(list_resp.status_code, 200) + items = list_resp.get_json()['items'] + prefixes = [item['prefix'] for item in items] + self.assertIn(prefix, prefixes) + + # Verify the list item has the correct fields and no hash/raw key + matching = [item for item in items if item['prefix'] == prefix][0] + self.assertEqual(matching['name'], 'list-test-key') + self.assertEqual(matching['scope'], 'submit') + self.assertTrue(matching['is_active']) + self.assertIn('created_at', matching) + # MUST NOT leak the hash or raw key + self.assertNotIn('key_hash', matching) + self.assertNotIn('key', matching) + + +class TestDeleteAPIKey(unittest.TestCase): + """DELETE /api/v5/admin/api-keys/{prefix}.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_delete_key_returns_204(self): + # Create a key first + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'delete-me', 'scope': 'read'}, + headers=self._admin_headers, + ) + self.assertEqual(create_resp.status_code, 201) + prefix = create_resp.get_json()['prefix'] + + # Delete it + delete_resp = self.client.delete( + f'/api/v5/admin/api-keys/{prefix}', + headers=self._admin_headers, + ) + self.assertEqual(delete_resp.status_code, 204) + + def test_delete_nonexistent_prefix(self): + """Deleting a prefix that does not exist should return 404.""" + resp = self.client.delete( + '/api/v5/admin/api-keys/zzzzzzzz', + headers=self._admin_headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_deleted_key_is_soft_deleted(self): + """After deletion the key is inactive but still appears in the list.""" + # Create + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'soft-del', 'scope': 'read'}, + headers=self._admin_headers, + ) + prefix = create_resp.get_json()['prefix'] + + # Delete + self.client.delete( + f'/api/v5/admin/api-keys/{prefix}', + headers=self._admin_headers, + ) + + # The key should still show in the list but with is_active=False + list_resp = self.client.get( + '/api/v5/admin/api-keys', headers=self._admin_headers) + items = list_resp.get_json()['items'] + matching = [i for i in items if i['prefix'] == prefix] + self.assertEqual(len(matching), 1) + self.assertFalse(matching[0]['is_active']) + + +class TestDeleteAPIKeyAuth(unittest.TestCase): + """Auth enforcement on DELETE /api/v5/admin/api-keys/{prefix}.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_key_without_auth(self): + resp = self.client.delete('/api/v5/admin/api-keys/abcd1234') + self.assertEqual(resp.status_code, 401) + + def test_delete_key_with_manage_scope_403(self): + """A manage-scoped key (one below admin) should get 403.""" + manage_headers = make_scoped_headers(self.app, 'manage') + resp = self.client.delete( + '/api/v5/admin/api-keys/abcd1234', headers=manage_headers) + self.assertEqual(resp.status_code, 403) + + def test_delete_key_with_admin_scope_204(self): + """An admin-scoped key (the required scope) succeeds.""" + # Create a key to delete + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'admin-del-test', 'scope': 'read'}, + headers=admin_headers(), + ) + self.assertEqual(create_resp.status_code, 201) + prefix = create_resp.get_json()['prefix'] + + resp = self.client.delete( + f'/api/v5/admin/api-keys/{prefix}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + +class TestCreatedKeyWorksForAuth(unittest.TestCase): + """Keys created via the admin endpoint actually authenticate requests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_created_read_key_can_access_discovery(self): + """A newly created read key should work on a GET endpoint.""" + # Create a read-scoped key + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'auth-test-read', 'scope': 'read'}, + headers=self._admin_headers, + ) + self.assertEqual(create_resp.status_code, 201) + raw_token = create_resp.get_json()['key'] + + # Use the raw token to access the discovery endpoint + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get('/api/v5/', headers=headers) + self.assertEqual(resp.status_code, 200) + + def test_created_read_key_can_access_fields(self): + """A newly created read key should access /nts/machines.""" + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'auth-test-fields', 'scope': 'read'}, + headers=self._admin_headers, + ) + raw_token = create_resp.get_json()['key'] + + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 200) + + def test_revoked_key_is_rejected(self): + """After revoking a key, it should no longer authenticate.""" + # Create a key + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'revoke-test', 'scope': 'admin'}, + headers=self._admin_headers, + ) + created = create_resp.get_json() + raw_token = created['key'] + prefix = created['prefix'] + + # Verify it works + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get( + '/api/v5/admin/api-keys', headers=headers) + self.assertEqual(resp.status_code, 200) + + # Revoke it + self.client.delete( + f'/api/v5/admin/api-keys/{prefix}', + headers=self._admin_headers, + ) + + # Now it should be rejected -- admin endpoints require admin scope, + # so a revoked key with no valid auth should get 401 + resp2 = self.client.get( + '/api/v5/admin/api-keys', headers=headers) + self.assertEqual(resp2.status_code, 401) + + def test_created_read_key_cannot_access_admin(self): + """A read-scoped key should be forbidden from admin endpoints.""" + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'read-no-admin', 'scope': 'read'}, + headers=self._admin_headers, + ) + raw_token = create_resp.get_json()['key'] + + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get( + '/api/v5/admin/api-keys', headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_created_admin_key_can_create_more_keys(self): + """An admin-scoped key should be able to create other keys.""" + # Create an admin key + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'admin-creator', 'scope': 'admin'}, + headers=self._admin_headers, + ) + raw_token = create_resp.get_json()['key'] + new_admin_headers = {'Authorization': f'Bearer {raw_token}'} + + # Use it to create another key + resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'child-key', 'scope': 'read'}, + headers=new_admin_headers, + ) + self.assertEqual(resp.status_code, 201) + + +class TestAdminUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_api_keys_list_unknown_param_returns_400(self): + resp = self.client.get( + '/api/v5/admin/api-keys?bogus=1', + headers=self._admin_headers) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_agents.py b/tests/server/api/v5/test_agents.py new file mode 100644 index 000000000..fb8d4e06a --- /dev/null +++ b/tests/server/api/v5/test_agents.py @@ -0,0 +1,60 @@ +# Tests for the /llms.txt endpoint (AI agent orientation). +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client + + +class TestLlmsTxt(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_returns_200(self): + resp = self.client.get('/llms.txt') + self.assertEqual(resp.status_code, 200) + + def test_content_type_is_text_plain(self): + resp = self.client.get('/llms.txt') + self.assertTrue(resp.content_type.startswith('text/plain')) + + def test_contains_lnt_description(self): + resp = self.client.get('/llms.txt') + text = resp.get_data(as_text=True) + self.assertIn('LNT', text) + self.assertIn('performance testing infrastructure', text) + + def test_contains_key_concepts(self): + resp = self.client.get('/llms.txt') + text = resp.get_data(as_text=True) + self.assertIn('Key Concepts', text) + self.assertIn('Test Suite', text) + self.assertIn('Machine', text) + self.assertIn('Regression', text) + + def test_contains_api_links(self): + resp = self.client.get('/llms.txt') + text = resp.get_data(as_text=True) + self.assertIn('/api/v5/openapi/swagger-ui', text) + + def test_contains_endpoint_listing(self): + resp = self.client.get('/llms.txt') + text = resp.get_data(as_text=True) + self.assertIn('/api/v5/{ts}/machines', text) + self.assertIn('/api/v5/{ts}/runs', text) + self.assertIn('/api/v5/{ts}/query', text) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_auth.py b/tests/server/api/v5/test_auth.py new file mode 100644 index 000000000..926e6310d --- /dev/null +++ b/tests/server/api/v5/test_auth.py @@ -0,0 +1,321 @@ +# Tests for the v5 API authentication system. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import datetime +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_api_key, make_scoped_headers, +) + +from lnt.server.api.v5.auth import ( + SCOPE_LEVELS, _get_scope_level, _hash_token, +) +from lnt.server.db.v5.models import APIKey, utcnow + + +class TestScopeHierarchy(unittest.TestCase): + def test_scope_levels_ordering(self): + self.assertLess(SCOPE_LEVELS['read'], SCOPE_LEVELS['submit']) + self.assertLess(SCOPE_LEVELS['submit'], SCOPE_LEVELS['triage']) + self.assertLess(SCOPE_LEVELS['triage'], SCOPE_LEVELS['manage']) + self.assertLess(SCOPE_LEVELS['manage'], SCOPE_LEVELS['admin']) + + def test_get_scope_level_valid(self): + self.assertEqual(_get_scope_level('read'), 0) + self.assertEqual(_get_scope_level('admin'), 4) + + def test_get_scope_level_invalid(self): + self.assertEqual(_get_scope_level('nonexistent'), -1) + + def test_all_scopes_present(self): + expected = {'read', 'submit', 'triage', 'manage', 'admin'} + self.assertEqual(set(SCOPE_LEVELS.keys()), expected) + + +class TestTokenHashing(unittest.TestCase): + def test_hash_deterministic(self): + token = 'test_token_abc123' + self.assertEqual(_hash_token(token), _hash_token(token)) + + def test_hash_different_for_different_tokens(self): + self.assertNotEqual(_hash_token('token_a'), _hash_token('token_b')) + + def test_hash_is_hex_string(self): + h = _hash_token('some_token') + self.assertEqual(len(h), 64) # SHA-256 hex + int(h, 16) # Should not raise + + +class TestBootstrapToken(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._admin_headers = admin_headers() + + def test_bootstrap_token_allows_read(self): + resp = self.client.get('/api/v5/', headers=self._admin_headers) + self.assertEqual(resp.status_code, 200) + + def test_bootstrap_token_allows_admin_paths(self): + """Admin token should not get 401 or 403 on discovery.""" + resp = self.client.get('/api/v5/', headers=self._admin_headers) + self.assertEqual(resp.status_code, 200) + + def test_bootstrap_token_grants_admin_scope(self): + """Bootstrap token should resolve to admin scope via constant-time + comparison (hmac.compare_digest).""" + with self.app.test_request_context( + headers=self._admin_headers): + from lnt.server.api.v5.auth import _resolve_bearer_token + scope, api_key = _resolve_bearer_token() + self.assertEqual(scope, 'admin') + self.assertIsNone(api_key) + + def test_wrong_bootstrap_token_returns_401(self): + """A token that does not match the bootstrap token (and has no + matching DB key) should abort with 401 on a read-scoped endpoint.""" + wrong_headers = {'Authorization': 'Bearer wrong_token_value'} + resp = self.client.get('/api/v5/nts/machines', headers=wrong_headers) + self.assertEqual(resp.status_code, 401) + + +class TestUnauthenticatedAccess(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_discovery_no_auth(self): + resp = self.client.get('/api/v5/') + self.assertEqual(resp.status_code, 200) + + def test_fields_no_auth(self): + resp = self.client.get('/api/v5/nts/machines') + self.assertEqual(resp.status_code, 200) + + def test_schema_no_auth(self): + resp = self.client.get('/api/v5/test-suites/nts') + self.assertEqual(resp.status_code, 200) + + +class TestInvalidToken(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_invalid_bearer_token_on_read_endpoint_returns_401(self): + """An invalid Bearer token must return 401, not silently succeed.""" + headers = {'Authorization': 'Bearer totally_invalid_token_xyz'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 401) + + def test_invalid_bearer_token_on_testsuite_read_returns_401(self): + """An invalid Bearer token on a testsuite read endpoint returns 401.""" + headers = {'Authorization': 'Bearer bogus_token_abc123'} + resp = self.client.get('/api/v5/nts/tests', headers=headers) + self.assertEqual(resp.status_code, 401) + + def test_empty_bearer_token_returns_401(self): + """'Bearer ' with no actual token value returns 401.""" + headers = {'Authorization': 'Bearer '} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 401) + + def test_invalid_token_on_unprotected_discovery_passes(self): + """Discovery endpoint has no auth decorator, so invalid tokens + do not cause 401 there — it is truly public.""" + headers = {'Authorization': 'Bearer totally_invalid_token_xyz'} + resp = self.client.get('/api/v5/', headers=headers) + self.assertEqual(resp.status_code, 200) + + def test_malformed_auth_header_allows_unauthenticated_read(self): + """Non-Bearer Authorization header is treated as no-auth (not 401), + preserving backward compatibility with proxies/middleware that may + inject other schemes.""" + headers = {'Authorization': 'NotBearer sometoken'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 200) + + +class TestScopedAPIKeys(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._read_headers = make_scoped_headers(cls.app, 'read') + + def test_read_key_can_access_discovery(self): + resp = self.client.get('/api/v5/', headers=self._read_headers) + self.assertEqual(resp.status_code, 200) + + def test_read_key_can_access_fields(self): + resp = self.client.get( + '/api/v5/nts/machines', headers=self._read_headers) + self.assertEqual(resp.status_code, 200) + + +class TestRequireAuthForReads(unittest.TestCase): + """Tests for the require_auth_for_reads config flag. + + When require_auth_for_reads is True, unauthenticated GET requests to + read-scoped endpoints must return 401, while authenticated requests + succeed. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + # Enable require_auth_for_reads on this app instance + cls.app.old_config.require_auth_for_reads = True + cls.client = create_client(cls.app) + cls._read_headers = make_scoped_headers(cls.app, 'read') + + @classmethod + def tearDownClass(cls): + # Restore default to avoid affecting other tests + cls.app.old_config.require_auth_for_reads = False + super().tearDownClass() + + def test_unauthenticated_read_returns_401(self): + """Unauthenticated GET to a read-scoped endpoint returns 401.""" + resp = self.client.get('/api/v5/nts/machines') + self.assertEqual(resp.status_code, 401) + + def test_unauthenticated_fields_returns_401(self): + """Unauthenticated GET to /tests returns 401.""" + resp = self.client.get('/api/v5/nts/tests') + self.assertEqual(resp.status_code, 401) + + def test_authenticated_read_returns_200(self): + """Authenticated GET with read scope returns 200.""" + resp = self.client.get( + '/api/v5/nts/machines', headers=self._read_headers) + self.assertEqual(resp.status_code, 200) + + def test_authenticated_fields_returns_200(self): + """Authenticated GET to /tests with read scope returns 200.""" + resp = self.client.get( + '/api/v5/nts/tests', headers=self._read_headers) + self.assertEqual(resp.status_code, 200) + + def test_admin_token_still_works(self): + """Admin bootstrap token still works when reads require auth.""" + resp = self.client.get( + '/api/v5/nts/machines', headers=admin_headers()) + self.assertEqual(resp.status_code, 200) + + +class TestLastUsedAtThrottling(unittest.TestCase): + """Tests for last_used_at update throttling. + + The auth system only updates last_used_at once per hour to avoid + dirtying the DB session (and triggering a COMMIT) on every read. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _get_api_key(self, token): + """Look up an APIKey row by raw token.""" + from lnt.server.api.v5.auth import _hash_token + db = self.app.instance.get_database("default") + session = db.make_session() + key_hash = _hash_token(token) + api_key = session.query(APIKey).filter( + APIKey.key_hash == key_hash).first() + session.close() + return api_key + + def _set_last_used_at(self, token, value): + """Set last_used_at to a specific value for an API key.""" + from lnt.server.api.v5.auth import _hash_token + db = self.app.instance.get_database("default") + session = db.make_session() + key_hash = _hash_token(token) + api_key = session.query(APIKey).filter( + APIKey.key_hash == key_hash).first() + api_key.last_used_at = value + session.commit() + session.close() + + def test_first_use_sets_last_used_at(self): + """When last_used_at is None, it should be set on first use.""" + raw_token = 'throttle_first_use_token_00001' + db = self.app.instance.get_database("default") + session = db.make_session() + make_api_key(session, 'throttle-first', 'read', raw_token) + + # Verify it starts as None + api_key = self._get_api_key(raw_token) + self.assertIsNone(api_key.last_used_at) + + # Make a request + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 200) + + # Verify last_used_at is now set + api_key = self._get_api_key(raw_token) + self.assertIsNotNone(api_key.last_used_at) + + def test_recent_use_does_not_update(self): + """When last_used_at is recent (< 1 hour), it should NOT be updated.""" + raw_token = 'throttle_recent_use_token_0002' + db = self.app.instance.get_database("default") + session = db.make_session() + make_api_key(session, 'throttle-recent', 'read', raw_token) + + # Set last_used_at to 30 minutes ago + thirty_min_ago = utcnow() - datetime.timedelta(minutes=30) + self._set_last_used_at(raw_token, thirty_min_ago) + + # Make a request + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 200) + + # Verify last_used_at was NOT updated (still ~30 min ago) + api_key = self._get_api_key(raw_token) + self.assertEqual(api_key.last_used_at, thirty_min_ago) + + def test_stale_use_does_update(self): + """When last_used_at is stale (> 1 hour), it should be updated.""" + raw_token = 'throttle_stale_use_token_00003' + db = self.app.instance.get_database("default") + session = db.make_session() + make_api_key(session, 'throttle-stale', 'read', raw_token) + + # Set last_used_at to 2 hours ago + two_hours_ago = utcnow() - datetime.timedelta(hours=2) + self._set_last_used_at(raw_token, two_hours_ago) + + # Make a request + headers = {'Authorization': f'Bearer {raw_token}'} + resp = self.client.get('/api/v5/nts/machines', headers=headers) + self.assertEqual(resp.status_code, 200) + + # Verify last_used_at was updated (no longer 2 hours ago) + api_key = self._get_api_key(raw_token) + self.assertNotEqual(api_key.last_used_at, two_hours_ago) + self.assertGreater(api_key.last_used_at, two_hours_ago) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_commits.py b/tests/server/api/v5/test_commits.py new file mode 100644 index 000000000..36af90892 --- /dev/null +++ b/tests/server/api/v5/test_commits.py @@ -0,0 +1,1368 @@ +# Tests for the v5 commit endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, + create_commit, create_machine, create_run, collect_all_pages, + submit_run, make_profile_base64, set_ordinal, +) + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +class TestCommitList(unittest.TestCase): + """Tests for GET /api/v5/{ts}/commits.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_returns_200(self): + """GET /commits returns 200.""" + resp = self.client.get(PREFIX + '/commits') + self.assertEqual(resp.status_code, 200) + + def test_list_has_pagination_envelope(self): + """Response includes cursor pagination envelope.""" + resp = self.client.get(PREFIX + '/commits') + data = resp.get_json() + self.assertIn('items', data) + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + def test_list_returns_commits(self): + """Create a commit via DB and verify it appears in the list.""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + rev = f'list-{uuid.uuid4().hex[:8]}' + create_commit(session, ts, commit=rev) + session.commit() + session.close() + + resp = self.client.get(PREFIX + '/commits') + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + self.assertIn('commit', item) + self.assertIn('ordinal', item) + self.assertIn('fields', item) + self.assertIsInstance(item['fields'], dict) + + def test_list_pagination(self): + """Verify cursor pagination works.""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i in range(3): + create_commit( + session, ts, + commit=f'page-{uuid.uuid4().hex[:6]}-{i}') + session.commit() + session.close() + + resp = self.client.get(PREFIX + '/commits?limit=1') + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + self.assertIsNotNone(data['cursor']['next']) + + cursor = data['cursor']['next'] + resp2 = self.client.get( + PREFIX + f'/commits?limit=1&cursor={cursor}') + self.assertEqual(resp2.status_code, 200) + data2 = resp2.get_json() + self.assertEqual(len(data2['items']), 1) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + resp = self.client.get( + PREFIX + '/commits?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + +class TestCommitSearch(unittest.TestCase): + """Tests for the search parameter on GET /commits.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_search_by_commit_substring(self): + """Search by commit string substring.""" + unique = uuid.uuid4().hex[:8] + middle = f'srch{unique}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + create_commit(session, ts, commit=f'aaa-{middle}-xxx') + create_commit(session, ts, commit=f'bbb-{middle}-yyy') + create_commit(session, ts, commit=f'other-{uuid.uuid4().hex[:8]}') + session.commit() + session.close() + + resp = self.client.get(PREFIX + f'/commits?search={middle}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 2) + for item in data['items']: + self.assertIn(middle, item['commit']) + + def test_search_case_insensitive(self): + """Search is case-insensitive.""" + unique = uuid.uuid4().hex[:8] + commit_val = f'CaSeCmT-{unique}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + create_commit(session, ts, commit=commit_val) + session.commit() + session.close() + + # Search with all-lowercase + resp = self.client.get( + PREFIX + f'/commits?search=casecmt-{unique}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + commits = [i['commit'] for i in data['items']] + self.assertIn(commit_val, commits) + + # Search with all-uppercase + resp = self.client.get( + PREFIX + f'/commits?search=CASECMT-{unique.upper()}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + commits = [i['commit'] for i in data['items']] + self.assertIn(commit_val, commits) + + def test_search_no_match(self): + """Search with no matches returns empty list.""" + resp = self.client.get( + PREFIX + '/commits?search=nonexistent-prefix-xyz') + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(resp.get_json()['items']), 0) + + +class TestCommitCreate(unittest.TestCase): + """Tests for POST /api/v5/{ts}/commits.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_create_commit(self): + """Create a commit and verify 201 response.""" + rev = f'create-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['commit'], rev) + self.assertIsNone(data['ordinal']) + + def test_create_commit_with_ordinal(self): + """Create a commit with an explicit ordinal.""" + rev = f'ordinal-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev, 'ordinal': 42}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['ordinal'], 42) + + def test_create_commit_with_fields(self): + """Create a commit with commit_fields metadata.""" + rev = f'fields-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev, + 'llvm_project_revision': 'abc123'}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['fields']['llvm_project_revision'], 'abc123') + + def test_create_commit_includes_prev_next(self): + """Created commit response includes prev/next references.""" + rev = f'prevnext-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIn('previous_commit', data) + self.assertIn('next_commit', data) + + def test_create_commit_appears_in_list(self): + """Newly created commit appears in the list.""" + rev = f'appear-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + '/commits') + data = resp.get_json() + commits = [item['commit'] for item in data['items']] + self.assertIn(rev, commits) + + def test_create_duplicate_409(self): + """Creating a commit with a duplicate string returns 409.""" + rev = f'dup-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 409) + + def test_create_missing_commit_422(self): + """Creating without required commit field returns 422.""" + resp = self.client.post( + PREFIX + '/commits', + json={}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_create_no_body_422(self): + """POST without body returns 422.""" + resp = self.client.post( + PREFIX + '/commits', + headers=admin_headers(), + content_type='application/json', + ) + self.assertEqual(resp.status_code, 422) + + def test_create_no_auth_401(self): + """Creating without auth should return 401.""" + resp = self.client.post( + PREFIX + '/commits', + json={'commit': 'no-auth'}, + ) + self.assertEqual(resp.status_code, 401) + + def test_create_read_scope_403(self): + """Creating with read scope should return 403.""" + headers = make_scoped_headers(self.app, 'read') + resp = self.client.post( + PREFIX + '/commits', + json={'commit': 'read-only'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_submit_scope_ok(self): + """Creating with submit scope should succeed.""" + headers = make_scoped_headers(self.app, 'submit') + rev = f'submit-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + + +class TestCommitDetail(unittest.TestCase): + """Tests for GET /api/v5/{ts}/commits/{value}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_get_detail(self): + """Get commit detail by commit string.""" + rev = f'detail-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + create_commit(session, ts, commit=rev) + session.commit() + session.close() + + resp = self.client.get(PREFIX + f'/commits/{rev}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['commit'], rev) + self.assertIn('ordinal', data) + self.assertIn('fields', data) + self.assertIn('previous_commit', data) + self.assertIn('next_commit', data) + + def test_get_nonexistent_404(self): + """Getting a nonexistent commit should return 404.""" + resp = self.client.get( + PREFIX + '/commits/nonexistent-commit-xyz') + self.assertEqual(resp.status_code, 404) + + def test_detail_with_neighbors(self): + """Verify prev/next references when neighbors exist.""" + rev1 = f'nbr1-{uuid.uuid4().hex[:8]}' + rev2 = f'nbr2-{uuid.uuid4().hex[:8]}' + rev3 = f'nbr3-{uuid.uuid4().hex[:8]}' + # Create commits with ordinals + self.client.post( + PREFIX + '/commits', + json={'commit': rev1, 'ordinal': 100}, + headers=admin_headers(), + ) + self.client.post( + PREFIX + '/commits', + json={'commit': rev2, 'ordinal': 200}, + headers=admin_headers(), + ) + self.client.post( + PREFIX + '/commits', + json={'commit': rev3, 'ordinal': 300}, + headers=admin_headers(), + ) + + # Middle commit should have both neighbors + resp = self.client.get(PREFIX + f'/commits/{rev2}') + data = resp.get_json() + self.assertIsNotNone(data['previous_commit']) + self.assertIsNotNone(data['next_commit']) + self.assertEqual(data['previous_commit']['commit'], rev1) + self.assertEqual(data['next_commit']['commit'], rev3) + + def test_detail_no_ordinal_no_neighbors(self): + """Commits without ordinal have null neighbors.""" + rev = f'no-ord-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/commits/{rev}') + data = resp.get_json() + self.assertIsNone(data['previous_commit']) + self.assertIsNone(data['next_commit']) + + +class TestCommitDetailETag(unittest.TestCase): + """ETag tests for GET /api/v5/{ts}/commits/{value}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_etag_present(self): + """Commit detail response should include an ETag header.""" + rev = f'etag-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/commits/{rev}') + self.assertEqual(resp.status_code, 200) + self.assertIsNotNone(resp.headers.get('ETag')) + self.assertTrue(resp.headers['ETag'].startswith('W/"')) + + def test_etag_304_on_match(self): + """Sending If-None-Match with the same ETag returns 304.""" + rev = f'etag304-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/commits/{rev}') + etag = resp.headers['ETag'] + + resp2 = self.client.get( + PREFIX + f'/commits/{rev}', + headers={'If-None-Match': etag}, + ) + self.assertEqual(resp2.status_code, 304) + + def test_etag_200_on_mismatch(self): + """Sending If-None-Match with a different ETag returns 200.""" + rev = f'etag200-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get( + PREFIX + f'/commits/{rev}', + headers={'If-None-Match': 'W/"stale"'}, + ) + self.assertEqual(resp.status_code, 200) + + +class TestCommitUpdate(unittest.TestCase): + """Tests for PATCH /api/v5/{ts}/commits/{value}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_set_ordinal(self): + """PATCH to set an ordinal on a commit.""" + rev = f'setord-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'ordinal': 9999}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()['ordinal'], 9999) + + def test_clear_ordinal(self): + """PATCH with ordinal=null clears the ordinal.""" + rev = f'clrord-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev, 'ordinal': 9998}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'ordinal': None}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 200) + self.assertIsNone(resp.get_json()['ordinal']) + + def test_update_commit_field(self): + """PATCH updates a commit field value.""" + rev = f'updfld-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'llvm_project_revision': 'updated123'}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual( + resp.get_json()['fields']['llvm_project_revision'], + 'updated123') + + def test_patch_nonexistent_404(self): + """PATCH nonexistent commit returns 404.""" + resp = self.client.patch( + PREFIX + '/commits/nonexistent-xyz', + json={'ordinal': 1}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 404) + + def test_patch_no_auth_401(self): + """PATCH without auth returns 401.""" + rev = f'noauth-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'ordinal': 1}, + ) + self.assertEqual(resp.status_code, 401) + + def test_patch_triage_scope_403(self): + """PATCH with triage scope (one below manage) returns 403.""" + rev = f'triagep-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'ordinal': 1}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_patch_manage_scope_200(self): + """PATCH with manage scope (the required scope) succeeds.""" + rev = f'mngp-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.patch( + PREFIX + f'/commits/{rev}', + json={'ordinal': 1}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + + +class TestCommitDelete(unittest.TestCase): + """Tests for DELETE /api/v5/{ts}/commits/{value}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_commit(self): + """Delete a commit and verify it's gone.""" + rev = f'delc-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.delete( + PREFIX + f'/commits/{rev}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + resp = self.client.get(PREFIX + f'/commits/{rev}') + self.assertEqual(resp.status_code, 404) + + def test_delete_nonexistent_404(self): + """Deleting a nonexistent commit should return 404.""" + resp = self.client.delete( + PREFIX + '/commits/nonexistent-del-xyz', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 404) + + def test_delete_with_regression_409(self): + """Delete a commit referenced by a Regression returns 409.""" + from v5_test_helpers import create_machine, create_test + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + c = create_commit(session, ts, + commit=f'reg-ref-{uuid.uuid4().hex[:8]}') + c_commit = c.commit + m = create_machine(session, ts, + name=f'reg-del-{uuid.uuid4().hex[:8]}') + t = create_test(session, ts, + name=f'reg-del/test/{uuid.uuid4().hex[:8]}') + + from v5_test_helpers import create_regression + create_regression( + session, ts, + indicators=[{'machine_id': m.id, 'test_id': t.id, + 'metric': 'execution_time'}], + commit=c) + session.commit() + session.close() + + resp = self.client.delete( + PREFIX + f'/commits/{c_commit}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 409) + + def test_delete_cascades_to_runs(self): + """Deleting a commit cascades to its runs.""" + from v5_test_helpers import create_machine, create_run + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + c = create_commit(session, ts, + commit=f'casc-{uuid.uuid4().hex[:8]}') + c_commit = c.commit # save before closing session + m = create_machine( + session, ts, name=f'casc-m-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, m, c) + run_uuid = run.uuid + session.commit() + session.close() + + self.client.delete( + PREFIX + f'/commits/{c_commit}', + headers=admin_headers(), + ) + + # Run should be gone too + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(resp.status_code, 404) + + def test_delete_no_auth_401(self): + """DELETE without auth returns 401.""" + rev = f'delna-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.delete(PREFIX + f'/commits/{rev}') + self.assertEqual(resp.status_code, 401) + + def test_delete_triage_scope_403(self): + """DELETE with triage scope (one below manage) returns 403.""" + rev = f'deltri-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.delete( + PREFIX + f'/commits/{rev}', + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_delete_manage_scope_204(self): + """DELETE with manage scope (the required scope) succeeds.""" + rev = f'delmng-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.delete( + PREFIX + f'/commits/{rev}', + headers=headers, + ) + self.assertEqual(resp.status_code, 204) + + +class TestCommitPagination(unittest.TestCase): + """Exhaustive cursor pagination tests for GET /commits.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._commits = [] + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i in range(5): + rev = f'pag-{uuid.uuid4().hex[:8]}-{i}' + create_commit(session, ts, commit=rev) + cls._commits.append(rev) + session.commit() + session.close() + + def _collect_all_pages(self): + url = PREFIX + '/commits?limit=2' + return collect_all_pages(self, self.client, url) + + def test_pagination_collects_all_items(self): + """Paginating through all pages collects all commits.""" + all_items = self._collect_all_pages() + commits = [item['commit'] for item in all_items] + for rev in self._commits: + self.assertIn(rev, commits) + + def test_no_duplicate_items_across_pages(self): + """No duplicate commits across pages.""" + all_items = self._collect_all_pages() + commits = [item['commit'] for item in all_items] + self.assertEqual(len(commits), len(set(commits))) + + +class TestCommitMachineFilter(unittest.TestCase): + """Tests for GET /api/v5/{ts}/commits?machine={name}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + cls.m1_name = f'mf-m1-{uuid.uuid4().hex[:8]}' + cls.m2_name = f'mf-m2-{uuid.uuid4().hex[:8]}' + m1 = create_machine(session, ts, name=cls.m1_name) + m2 = create_machine(session, ts, name=cls.m2_name) + + cls.c_both = f'mf-both-{uuid.uuid4().hex[:8]}' + cls.c_m1_only = f'mf-m1only-{uuid.uuid4().hex[:8]}' + cls.c_m2_only = f'mf-m2only-{uuid.uuid4().hex[:8]}' + cls.c_no_runs = f'mf-noruns-{uuid.uuid4().hex[:8]}' + + c_both = create_commit(session, ts, commit=cls.c_both) + c_m1 = create_commit(session, ts, commit=cls.c_m1_only) + c_m2 = create_commit(session, ts, commit=cls.c_m2_only) + create_commit(session, ts, commit=cls.c_no_runs) + + create_run(session, ts, m1, c_both) + create_run(session, ts, m2, c_both) + create_run(session, ts, m1, c_m1) + create_run(session, ts, m2, c_m2) + session.commit() + session.close() + + def _get_commits(self, **params): + qs = '&'.join(f'{k}={v}' for k, v in params.items()) + url = PREFIX + '/commits' + if qs: + url += '?' + qs + items = collect_all_pages(self, self.client, url) + return [item['commit'] for item in items] + + def test_filter_by_machine(self): + """Only commits with runs on the specified machine are returned.""" + commits = self._get_commits(machine=self.m1_name) + self.assertIn(self.c_both, commits) + self.assertIn(self.c_m1_only, commits) + self.assertNotIn(self.c_m2_only, commits) + self.assertNotIn(self.c_no_runs, commits) + + def test_filter_by_other_machine(self): + """Filtering by m2 returns m2's commits.""" + commits = self._get_commits(machine=self.m2_name) + self.assertIn(self.c_both, commits) + self.assertIn(self.c_m2_only, commits) + self.assertNotIn(self.c_m1_only, commits) + + def test_unknown_machine_returns_404(self): + """Filtering by a nonexistent machine returns 404.""" + resp = self.client.get( + PREFIX + '/commits?machine=nonexistent-machine-xyz') + self.assertEqual(resp.status_code, 404) + + def test_machine_combined_with_search(self): + """machine= and search= filters combine (intersection).""" + prefix = self.c_m1_only[:10] + commits = self._get_commits(machine=self.m1_name, search=prefix) + self.assertIn(self.c_m1_only, commits) + self.assertNotIn(self.c_both, commits) + + +class TestCommitSortOrdinal(unittest.TestCase): + """Tests for GET /api/v5/{ts}/commits?sort=ordinal.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + # Create commits with specific ordinals (non-contiguous, large to avoid + # collisions with other test classes that also create ordinals). + cls.c1 = f'so-c1-{uuid.uuid4().hex[:8]}' + cls.c2 = f'so-c2-{uuid.uuid4().hex[:8]}' + cls.c3 = f'so-c3-{uuid.uuid4().hex[:8]}' + cls.c_no_ord = f'so-noord-{uuid.uuid4().hex[:8]}' + + cls.ord1 = 500010 + cls.ord2 = 500050 + cls.ord3 = 500100 + + cls.client.post(PREFIX + '/commits', + json={'commit': cls.c1, 'ordinal': cls.ord1}, + headers=admin_headers()) + cls.client.post(PREFIX + '/commits', + json={'commit': cls.c2, 'ordinal': cls.ord2}, + headers=admin_headers()) + cls.client.post(PREFIX + '/commits', + json={'commit': cls.c3, 'ordinal': cls.ord3}, + headers=admin_headers()) + cls.client.post(PREFIX + '/commits', + json={'commit': cls.c_no_ord}, + headers=admin_headers()) + + def test_sort_ordinal_order(self): + """Commits are returned in ascending ordinal order.""" + url = PREFIX + '/commits?sort=ordinal' + items = collect_all_pages(self, self.client, url) + commits = [item['commit'] for item in items] + idx1 = commits.index(self.c1) + idx2 = commits.index(self.c2) + idx3 = commits.index(self.c3) + self.assertLess(idx1, idx2) + self.assertLess(idx2, idx3) + + def test_sort_ordinal_excludes_null(self): + """Commits without ordinals are excluded.""" + url = PREFIX + '/commits?sort=ordinal' + items = collect_all_pages(self, self.client, url) + commits = [item['commit'] for item in items] + self.assertNotIn(self.c_no_ord, commits) + + def test_sort_ordinal_pagination(self): + """Cursor pagination works with ordinal as cursor column.""" + resp = self.client.get( + PREFIX + '/commits?sort=ordinal&limit=1') + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + first = data['items'][0]['ordinal'] + + # Follow the cursor + cursor = data['cursor']['next'] + self.assertIsNotNone(cursor) + resp2 = self.client.get( + PREFIX + f'/commits?sort=ordinal&limit=1&cursor={cursor}') + data2 = resp2.get_json() + self.assertEqual(len(data2['items']), 1) + second = data2['items'][0]['ordinal'] + self.assertGreater(second, first) + + def test_invalid_sort_returns_422(self): + """Invalid sort value returns 422 (schema validation).""" + resp = self.client.get(PREFIX + '/commits?sort=bogus') + self.assertEqual(resp.status_code, 422) + + def test_sort_ordinal_with_machine(self): + """sort=ordinal combines with machine= filter.""" + # Create a machine with runs on c1 and c3 only + m_name = f'so-m-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + m = create_machine(session, ts, name=m_name) + # Look up commits created in setUpClass (they exist in the DB) + c1_obj = ts.get_commit(session, commit=self.c1) + c3_obj = ts.get_commit(session, commit=self.c3) + self.assertIsNotNone(c1_obj, "c1 should exist") + self.assertIsNotNone(c3_obj, "c3 should exist") + create_run(session, ts, m, c1_obj) + create_run(session, ts, m, c3_obj) + session.commit() + session.close() + + url = PREFIX + f'/commits?sort=ordinal&machine={m_name}' + items = collect_all_pages(self, self.client, url) + commits = [item['commit'] for item in items] + self.assertIn(self.c1, commits) + self.assertIn(self.c3, commits) + self.assertNotIn(self.c2, commits) + self.assertNotIn(self.c_no_ord, commits) + # Verify order + self.assertLess(commits.index(self.c1), commits.index(self.c3)) + + +class TestCommitUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_unknown_param_returns_400(self): + """Unknown query param on list returns 400.""" + resp = self.client.get(PREFIX + '/commits?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_detail_unknown_param_returns_400(self): + """Unknown query param on detail returns 400.""" + rev = f'unk-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/commits', + json={'commit': rev}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/commits/{rev}?bogus=1') + self.assertEqual(resp.status_code, 400) + + +class TestCommitResolve(unittest.TestCase): + """Tests for POST /api/v5/{ts}/commits/resolve.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _create(self, commit, **kwargs): + """Create a commit via the API and return the response.""" + body = {'commit': commit, **kwargs} + return self.client.post( + PREFIX + '/commits', json=body, headers=admin_headers()) + + def _resolve(self, commits, headers=None): + """POST to /commits/resolve and return the response.""" + kw = {'json': {'commits': commits}} + if headers is not None: + kw['headers'] = headers + return self.client.post(PREFIX + '/commits/resolve', **kw) + + def test_resolve_basic(self): + """Resolve two existing commits with correct fields and dict-keyed response.""" + rev1 = f'res-{uuid.uuid4().hex[:8]}' + rev2 = f'res-{uuid.uuid4().hex[:8]}' + self._create(rev1, llvm_project_revision='sha-aaa') + self._create(rev2, llvm_project_revision='sha-bbb') + + resp = self._resolve([rev1, rev2]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('results', data) + self.assertIn('not_found', data) + self.assertEqual(len(data['results']), 2) + self.assertEqual(len(data['not_found']), 0) + + # Verify dict keys are the commit strings + self.assertIn(rev1, data['results']) + self.assertIn(rev2, data['results']) + + # Verify each value has the CommitSummarySchema shape + item1 = data['results'][rev1] + self.assertEqual(item1['commit'], rev1) + self.assertIn('ordinal', item1) + self.assertIn('fields', item1) + self.assertEqual(item1['fields']['llvm_project_revision'], 'sha-aaa') + + item2 = data['results'][rev2] + self.assertEqual(item2['commit'], rev2) + self.assertEqual(item2['fields']['llvm_project_revision'], 'sha-bbb') + + def test_resolve_not_found(self): + """Mix of found and missing commits; missing in not_found.""" + rev = f'res-nf-{uuid.uuid4().hex[:8]}' + self._create(rev) + missing = f'res-missing-{uuid.uuid4().hex[:8]}' + + resp = self._resolve([rev, missing]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['results']), 1) + self.assertIn(rev, data['results']) + self.assertEqual(data['not_found'], [missing]) + + def test_resolve_all_not_found(self): + """All missing -> empty results, populated not_found.""" + m1 = f'res-allnf-{uuid.uuid4().hex[:8]}' + m2 = f'res-allnf-{uuid.uuid4().hex[:8]}' + + resp = self._resolve([m1, m2]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['results']), 0) + self.assertEqual(set(data['not_found']), {m1, m2}) + + def test_resolve_empty_list_422(self): + """Empty commits array -> 422.""" + resp = self._resolve([]) + self.assertEqual(resp.status_code, 422) + + def test_resolve_missing_field_422(self): + """No commits key in body -> 422.""" + resp = self.client.post( + PREFIX + '/commits/resolve', + json={}, + ) + self.assertEqual(resp.status_code, 422) + + def test_resolve_unknown_field_422(self): + """Extra field in body -> 422 (BaseSchema raises on unknown).""" + resp = self.client.post( + PREFIX + '/commits/resolve', + json={'commits': ['abc'], 'extra': 'bad'}, + ) + self.assertEqual(resp.status_code, 422) + + def test_resolve_null_in_commits_422(self): + """null value in commits array -> 422.""" + resp = self.client.post( + PREFIX + '/commits/resolve', + json={'commits': ['valid', None]}, + ) + self.assertEqual(resp.status_code, 422) + + def test_resolve_includes_ordinal(self): + """Ordinal value present in resolved commit.""" + rev = f'res-ord-{uuid.uuid4().hex[:8]}' + self._create(rev, ordinal=12345) + + resp = self._resolve([rev]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['results'][rev]['ordinal'], 12345) + + def test_resolve_null_ordinal(self): + """Commit with no ordinal returns ordinal: null.""" + rev = f'res-nullord-{uuid.uuid4().hex[:8]}' + self._create(rev) + + resp = self._resolve([rev]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIsNone(data['results'][rev]['ordinal']) + + def test_resolve_deduplicates(self): + """Duplicate values in request -> single dict entry.""" + rev = f'res-dup-{uuid.uuid4().hex[:8]}' + self._create(rev) + + resp = self._resolve([rev, rev, rev]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['results']), 1) + self.assertIn(rev, data['results']) + self.assertEqual(len(data['not_found']), 0) + + def test_resolve_large_batch(self): + """A large batch (1000 items) succeeds.""" + # Create 1000 commits via direct DB access for speed. + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + revs = [f'res-lim-{uuid.uuid4().hex[:8]}-{i}' for i in range(1000)] + for rev in revs: + create_commit(session, ts, commit=rev) + session.commit() + session.close() + + resp = self._resolve(revs) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['results']), 1000) + self.assertEqual(len(data['not_found']), 0) + + def test_resolve_all_found_empty_not_found(self): + """All found -> not_found is [] (not null/omitted).""" + rev = f'res-af-{uuid.uuid4().hex[:8]}' + self._create(rev) + + resp = self._resolve([rev]) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['not_found'], []) + self.assertIsInstance(data['not_found'], list) + + def test_resolve_unauthenticated_ok(self): + """No auth header -> 200 (read scope allows unauthenticated).""" + rev = f'res-noauth-{uuid.uuid4().hex[:8]}' + self._create(rev) + + # No headers argument -> no Authorization header + resp = self._resolve([rev]) + self.assertEqual(resp.status_code, 200) + + +class TestCommitTag(unittest.TestCase): + """Tests for the built-in 'tag' column on Commit.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _create(self, commit, **kwargs): + """Create a commit via POST and return the response.""" + body = {'commit': commit, **kwargs} + return self.client.post( + PREFIX + '/commits', json=body, headers=admin_headers()) + + def _patch(self, commit, **kwargs): + """PATCH a commit and return the response.""" + return self.client.patch( + PREFIX + f'/commits/{commit}', + json=kwargs, headers=admin_headers()) + + def test_tag_null_on_creation(self): + """POST /commits creates a commit with tag=null.""" + rev = f'tag-null-{uuid.uuid4().hex[:8]}' + resp = self._create(rev) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIn('tag', data) + self.assertIsNone(data['tag']) + + def test_set_tag_via_patch(self): + """PATCH sets the tag value.""" + rev = f'tag-set-{uuid.uuid4().hex[:8]}' + self._create(rev) + resp = self._patch(rev, tag='release-18') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()['tag'], 'release-18') + + def test_clear_tag_via_patch(self): + """PATCH with tag=null clears the tag.""" + rev = f'tag-clr-{uuid.uuid4().hex[:8]}' + self._create(rev) + self._patch(rev, tag='release-18') + resp = self._patch(rev, tag=None) + self.assertEqual(resp.status_code, 200) + self.assertIsNone(resp.get_json()['tag']) + + def test_tag_in_detail_response(self): + """GET /commits/{value} includes the tag.""" + rev = f'tag-det-{uuid.uuid4().hex[:8]}' + self._create(rev) + self._patch(rev, tag='v1.0') + resp = self.client.get(PREFIX + f'/commits/{rev}') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()['tag'], 'v1.0') + + def test_tag_in_list_response(self): + """GET /commits items include the tag key.""" + rev = f'tag-lst-{uuid.uuid4().hex[:8]}' + self._create(rev) + self._patch(rev, tag='list-tag') + # Use search to find our specific commit in the paginated list. + resp = self.client.get(PREFIX + f'/commits?search={rev}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + matching = [i for i in data['items'] if i['commit'] == rev] + self.assertEqual(len(matching), 1) + self.assertIn('tag', matching[0]) + self.assertEqual(matching[0]['tag'], 'list-tag') + + def test_tag_in_resolve_response(self): + """POST /commits/resolve includes the tag.""" + rev = f'tag-res-{uuid.uuid4().hex[:8]}' + self._create(rev) + self._patch(rev, tag='resolved-tag') + resp = self.client.post( + PREFIX + '/commits/resolve', + json={'commits': [rev]}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn(rev, data['results']) + self.assertEqual(data['results'][rev]['tag'], 'resolved-tag') + + def test_tag_in_neighbor_response(self): + """Neighbor commits in detail include tag.""" + unique = uuid.uuid4().hex[:6] + rev1 = f'tag-nb1-{unique}' + rev2 = f'tag-nb2-{unique}' + # Create commits first, then assign unique ordinals via PATCH. + self._create(rev1) + self._create(rev2) + self._patch(rev1, ordinal=7770001, tag='nb-tag-1') + self._patch(rev2, ordinal=7770002, tag='nb-tag-2') + resp = self.client.get(PREFIX + f'/commits/{rev2}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIsNotNone(data['previous_commit'], + 'expected previous_commit to be set') + self.assertIn('tag', data['previous_commit']) + self.assertEqual(data['previous_commit']['tag'], 'nb-tag-1') + + def test_search_matches_tag(self): + """GET /commits?search= matches the tag value.""" + unique = uuid.uuid4().hex[:8] + rev = f'tag-srch-{unique}' + tag_value = f'release-{unique}' + self._create(rev) + self._patch(rev, tag=tag_value) + resp = self.client.get(PREFIX + f'/commits?search=release-{unique}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + commits = [i['commit'] for i in data['items']] + self.assertIn(rev, commits) + + def test_search_does_not_match_null_tag(self): + """Search only matches commits with a matching tag, not null tags.""" + unique = uuid.uuid4().hex[:8] + rev_no_tag = f'tag-notag-{unique}' + rev_with_tag = f'tag-witht-{unique}' + tag_value = f'release-{unique}' + self._create(rev_no_tag) + self._create(rev_with_tag) + self._patch(rev_with_tag, tag=tag_value) + resp = self.client.get(PREFIX + f'/commits?search=release-{unique}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + commits = [i['commit'] for i in data['items']] + self.assertIn(rev_with_tag, commits) + self.assertNotIn(rev_no_tag, commits) + + def test_post_ignores_tag(self): + """POST /commits ignores a tag value (tag is PATCH-only).""" + rev = f'tag-ign-{uuid.uuid4().hex[:8]}' + resp = self._create(rev, tag='foo') + self.assertEqual(resp.status_code, 201) + self.assertIsNone(resp.get_json()['tag']) + + def test_tag_not_unique(self): + """Two commits can have the same tag value.""" + tag_value = f'shared-tag-{uuid.uuid4().hex[:8]}' + rev1 = f'tag-nu1-{uuid.uuid4().hex[:8]}' + rev2 = f'tag-nu2-{uuid.uuid4().hex[:8]}' + self._create(rev1) + self._create(rev2) + resp1 = self._patch(rev1, tag=tag_value) + resp2 = self._patch(rev2, tag=tag_value) + self.assertEqual(resp1.status_code, 200) + self.assertEqual(resp2.status_code, 200) + self.assertEqual(resp1.get_json()['tag'], tag_value) + self.assertEqual(resp2.get_json()['tag'], tag_value) + + def test_tag_length_over_256_rejected(self): + """PATCH with a tag longer than 256 chars returns 422.""" + rev = f'tag-long-{uuid.uuid4().hex[:8]}' + self._create(rev) + resp = self._patch(rev, tag='x' * 257) + self.assertEqual(resp.status_code, 422) + + +class TestCommitHasProfilesFilter(unittest.TestCase): + """Tests for GET /api/v5/{ts}/commits?has_profiles=...""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls.tag = uuid.uuid4().hex[:8] + + profile_b64 = make_profile_base64() + + # Machine M1: commit C1 has a profiled run, C2 has a non-profiled run + cls.m1_name = f'hp-m1-{cls.tag}' + cls.c1 = f'hp-c1-{cls.tag}' + cls.c2 = f'hp-c2-{cls.tag}' + cls.c3 = f'hp-c3-{cls.tag}' + + # C1: profiled run on M1 + submit_run(cls.client, cls.m1_name, cls.c1, [ + {'name': 'test/profiled', 'execution_time': 1.0, + 'profile': profile_b64}, + ]) + + # C2: non-profiled run on M1 + submit_run(cls.client, cls.m1_name, cls.c2, [ + {'name': 'test/noprof', 'execution_time': 2.0}, + ]) + + # C3: no runs at all (created explicitly) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts_ = db.testsuite[TS] + create_commit(session, ts_, commit=cls.c3) + session.commit() + session.close() + + # Machine M2: profiled run on C2 + cls.m2_name = f'hp-m2-{cls.tag}' + submit_run(cls.client, cls.m2_name, cls.c2, [ + {'name': 'test/profiled2', 'execution_time': 3.0, + 'profile': profile_b64}, + ]) + + def _get_commits(self, **params): + qs = '&'.join(f'{k}={v}' for k, v in params.items()) + url = PREFIX + '/commits' + if qs: + url += '?' + qs + items = collect_all_pages(self, self.client, url) + return [item['commit'] for item in items] + + def test_has_profiles_true(self): + """Only commits with profiled runs are returned.""" + commits = self._get_commits(has_profiles='true') + self.assertIn(self.c1, commits) + self.assertIn(self.c2, commits) # has profile on M2 + self.assertNotIn(self.c3, commits) + + def test_has_profiles_false(self): + """Commits without profiled runs are returned (including commits + with no runs at all).""" + commits = self._get_commits(has_profiles='false') + self.assertNotIn(self.c1, commits) + self.assertNotIn(self.c2, commits) + self.assertIn(self.c3, commits) + + def test_has_profiles_with_machine(self): + """has_profiles=true scoped to a specific machine.""" + commits = self._get_commits( + machine=self.m1_name, has_profiles='true') + self.assertIn(self.c1, commits) + self.assertNotIn(self.c2, commits) + self.assertNotIn(self.c3, commits) + + def test_has_profiles_cross_machine(self): + """Each machine sees only its own profiled commits.""" + m1_commits = self._get_commits( + machine=self.m1_name, has_profiles='true') + m2_commits = self._get_commits( + machine=self.m2_name, has_profiles='true') + self.assertIn(self.c1, m1_commits) + self.assertNotIn(self.c2, m1_commits) + self.assertIn(self.c2, m2_commits) + self.assertNotIn(self.c1, m2_commits) + + def test_has_profiles_false_with_machine(self): + """has_profiles=false+machine returns commits with runs on that machine + but no profiled runs on it.""" + commits = self._get_commits( + machine=self.m1_name, has_profiles='false') + self.assertNotIn(self.c1, commits) + self.assertIn(self.c2, commits) # has a run on M1, but no profile + self.assertNotIn(self.c3, commits) # no run on M1 at all + + def test_has_profiles_empty_suite(self): + """has_profiles=true returns empty list when no profiles exist.""" + # Use a search term that matches nothing to isolate from other data + commits = self._get_commits( + has_profiles='true', search='nonexistent-test-data-xyz') + self.assertEqual(commits, []) + + def test_has_profiles_with_search(self): + """has_profiles=true combined with search narrows results.""" + commits = self._get_commits( + has_profiles='true', search=self.tag) + self.assertIn(self.c1, commits) + self.assertIn(self.c2, commits) + self.assertNotIn(self.c3, commits) + + def test_has_profiles_with_sort_ordinal(self): + """has_profiles=true combined with sort=ordinal works.""" + # Assign ordinals to c1 and c2 so sort=ordinal includes them + # Use unique ordinals unlikely to collide + o1 = abs(hash(self.c1)) % 900000 + 100000 + o2 = abs(hash(self.c2)) % 900000 + 100000 + if o1 == o2: + o2 += 1 + set_ordinal(self.client, self.c1, o1) + set_ordinal(self.client, self.c2, o2) + + commits = self._get_commits( + has_profiles='true', sort='ordinal') + self.assertIn(self.c1, commits) + self.assertIn(self.c2, commits) + self.assertNotIn(self.c3, commits) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_discovery.py b/tests/server/api/v5/test_discovery.py new file mode 100644 index 000000000..3ae8e6d6e --- /dev/null +++ b/tests/server/api/v5/test_discovery.py @@ -0,0 +1,114 @@ +# Tests for the v5 discovery endpoint (GET /api/v5/). +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client + + +class TestDiscovery(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_discovery_returns_200(self): + resp = self.client.get('/api/v5/') + self.assertEqual(resp.status_code, 200) + + def test_discovery_contains_test_suites(self): + resp = self.client.get('/api/v5/') + data = resp.get_json() + self.assertIn('test_suites', data) + self.assertIsInstance(data['test_suites'], list) + self.assertGreater(len(data['test_suites']), 0) + + def test_discovery_suite_has_name_and_links(self): + resp = self.client.get('/api/v5/') + data = resp.get_json() + suite = data['test_suites'][0] + self.assertIn('name', suite) + self.assertIn('links', suite) + + def test_discovery_suite_links_are_complete(self): + resp = self.client.get('/api/v5/') + data = resp.get_json() + suite = data['test_suites'][0] + links = suite['links'] + expected_keys = { + 'machines', 'commits', 'runs', 'tests', + 'regressions', 'query' + } + self.assertEqual(set(links.keys()), expected_keys) + + def test_discovery_links_contain_suite_name(self): + resp = self.client.get('/api/v5/') + data = resp.get_json() + for suite in data['test_suites']: + name = suite['name'] + for key, url in suite['links'].items(): + self.assertIn( + name, url, + f"Link {key}={url} does not contain suite name {name}") + + def test_discovery_no_auth_required(self): + """Discovery should work without any auth headers.""" + resp = self.client.get('/api/v5/') + self.assertEqual(resp.status_code, 200) + + def test_discovery_has_nts_suite(self): + """The 'nts' test suite should be present.""" + resp = self.client.get('/api/v5/') + data = resp.get_json() + names = [s['name'] for s in data['test_suites']] + self.assertIn('nts', names) + + def test_discovery_cors_headers(self): + """CORS headers should be present on v5 responses.""" + resp = self.client.get('/api/v5/') + self.assertEqual(resp.headers.get('Access-Control-Allow-Origin'), '*') + + def test_discovery_includes_doc_links(self): + """Discovery should include links to OpenAPI spec and Swagger UI.""" + resp = self.client.get('/api/v5/') + data = resp.get_json() + self.assertIn('links', data) + self.assertIn('openapi', data['links']) + self.assertIn('swagger_ui', data['links']) + self.assertIn('test_suites', data['links']) + + def test_swagger_ui_returns_200(self): + resp = self.client.get('/api/v5/openapi/swagger-ui') + self.assertEqual(resp.status_code, 200) + + def test_openapi_json_returns_200(self): + resp = self.client.get('/api/v5/openapi/openapi.json') + self.assertEqual(resp.status_code, 200) + + +class TestDiscoveryUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_discovery_unknown_param_returns_400(self): + resp = self.client.get('/api/v5/?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_errors.py b/tests/server/api/v5/test_errors.py new file mode 100644 index 000000000..58b1032cd --- /dev/null +++ b/tests/server/api/v5/test_errors.py @@ -0,0 +1,190 @@ +# Tests for the v5 API error format. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client, admin_headers + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +class TestErrors(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_404_has_error_key(self): + resp = self.client.get('/api/v5/nonexistent_suite/machines') + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn('code', data['error']) + self.assertIn('message', data['error']) + + def test_404_error_code(self): + resp = self.client.get('/api/v5/nonexistent_suite/machines') + data = resp.get_json() + self.assertEqual(data['error']['code'], 'not_found') + + def test_405_method_not_allowed(self): + """POST to a GET-only endpoint should return 405.""" + resp = self.client.post('/api/v5/') + self.assertEqual(resp.status_code, 405) + data = resp.get_json() + self.assertIn('error', data) + self.assertEqual(data['error']['code'], 'method_not_allowed') + + def test_v5_errors_are_json(self): + """Error responses should always be JSON.""" + resp = self.client.get('/api/v5/nonexistent_suite/machines') + self.assertTrue(resp.content_type.startswith('application/json')) + + def test_v5_error_cors_headers(self): + """Error responses should also have CORS headers.""" + resp = self.client.get('/api/v5/nonexistent_suite/machines') + self.assertEqual( + resp.headers.get('Access-Control-Allow-Origin'), '*') + + +class TestV5ApiError(unittest.TestCase): + """Test the V5ApiError exception class directly.""" + + def test_v5_api_error_attributes(self): + """V5ApiError stores status_code, error_code, and message.""" + from lnt.server.api.v5.errors import V5ApiError + exc = V5ApiError(404, 'not_found', 'Machine not found') + self.assertEqual(exc.status_code, 404) + self.assertEqual(exc.error_code, 'not_found') + self.assertEqual(exc.message, 'Machine not found') + self.assertEqual(str(exc), 'Machine not found') + + def test_v5_api_error_is_exception(self): + """V5ApiError inherits from Exception.""" + from lnt.server.api.v5.errors import V5ApiError + exc = V5ApiError(400, 'validation_error', 'Bad input') + self.assertIsInstance(exc, Exception) + + def test_abort_with_error_raises_v5_api_error(self): + """abort_with_error raises V5ApiError, not flask.abort.""" + from lnt.server.api.v5.errors import V5ApiError, abort_with_error + with self.assertRaises(V5ApiError) as ctx: + abort_with_error(400, 'test message') + self.assertEqual(ctx.exception.status_code, 400) + self.assertEqual(ctx.exception.error_code, 'validation_error') + self.assertEqual(ctx.exception.message, 'test message') + + def test_abort_with_error_unknown_status(self): + """abort_with_error maps unknown status codes to 'error'.""" + from lnt.server.api.v5.errors import V5ApiError, abort_with_error + with self.assertRaises(V5ApiError) as ctx: + abort_with_error(418, "I'm a teapot") + self.assertEqual(ctx.exception.status_code, 418) + self.assertEqual(ctx.exception.error_code, 'error') + + +class TestAbortWithErrorIntegration(unittest.TestCase): + """Test that abort_with_error produces correct HTTP responses end-to-end. + + These tests trigger abort_with_error through real endpoint calls to + verify the V5ApiError handler returns the expected JSON format and + status code. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls.headers = admin_headers() + + def test_400_from_abort_with_error(self): + """POST with invalid body triggers abort_with_error(400, ...).""" + resp = self.client.post( + PREFIX + '/machines', + data='not json', + content_type='application/json', + headers=self.headers, + ) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('error', data) + self.assertEqual(data['error']['code'], 'validation_error') + self.assertIsInstance(data['error']['message'], str) + self.assertTrue(resp.content_type.startswith('application/json')) + + def test_404_from_abort_with_error(self): + """GET a non-existent machine triggers abort_with_error(404, ...).""" + resp = self.client.get( + PREFIX + '/machines/does_not_exist', + headers=self.headers, + ) + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + self.assertEqual(data['error']['code'], 'not_found') + self.assertIn('does_not_exist', data['error']['message']) + self.assertTrue(resp.content_type.startswith('application/json')) + + def test_404_run_not_found(self): + """GET a non-existent run UUID triggers abort_with_error(404, ...).""" + fake_uuid = '00000000-0000-0000-0000-000000000000' + resp = self.client.get( + PREFIX + '/runs/' + fake_uuid, + headers=self.headers, + ) + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + self.assertEqual(data['error']['code'], 'not_found') + self.assertTrue(resp.content_type.startswith('application/json')) + + def test_409_duplicate_machine(self): + """Creating a duplicate machine triggers abort_with_error(409, ...).""" + import json + import uuid as _uuid + unique_name = 'error-test-machine-' + _uuid.uuid4().hex[:8] + # Create the machine first + resp = self.client.post( + PREFIX + '/machines', + data=json.dumps({'name': unique_name}), + content_type='application/json', + headers=self.headers, + ) + self.assertEqual(resp.status_code, 201) + # Try to create it again + resp = self.client.post( + PREFIX + '/machines', + data=json.dumps({'name': unique_name}), + content_type='application/json', + headers=self.headers, + ) + self.assertEqual(resp.status_code, 409) + data = resp.get_json() + self.assertIn('error', data) + self.assertEqual(data['error']['code'], 'conflict') + self.assertIn(unique_name, data['error']['message']) + + def test_error_response_has_exactly_two_keys(self): + """Error envelope should contain exactly 'code' and 'message'.""" + resp = self.client.get( + PREFIX + '/machines/does_not_exist', + headers=self.headers, + ) + data = resp.get_json() + self.assertEqual(set(data['error'].keys()), {'code', 'message'}) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_etag.py b/tests/server/api/v5/test_etag.py new file mode 100644 index 000000000..d0ed65a33 --- /dev/null +++ b/tests/server/api/v5/test_etag.py @@ -0,0 +1,69 @@ +# Tests for the v5 API ETag utilities. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client + +from lnt.server.api.v5.etag import compute_etag + + +class TestComputeETag(unittest.TestCase): + def test_deterministic(self): + data = {'key': 'value', 'number': 42} + self.assertEqual(compute_etag(data), compute_etag(data)) + + def test_weak_etag_format(self): + etag = compute_etag({'a': 1}) + self.assertTrue(etag.startswith('W/"')) + self.assertTrue(etag.endswith('"')) + + def test_different_data_different_etag(self): + e1 = compute_etag({'a': 1}) + e2 = compute_etag({'a': 2}) + self.assertNotEqual(e1, e2) + + def test_order_independent(self): + """Sort keys ensures order independence.""" + e1 = compute_etag({'a': 1, 'b': 2}) + e2 = compute_etag({'b': 2, 'a': 1}) + self.assertEqual(e1, e2) + + def test_empty_dict(self): + etag = compute_etag({}) + self.assertTrue(etag.startswith('W/"')) + + def test_empty_list(self): + etag = compute_etag([]) + self.assertTrue(etag.startswith('W/"')) + + +class TestETagOnEndpoints(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_machines_endpoint_returns_200(self): + """A GET to /machines should succeed (ETags are applied to detail + endpoints, not lists).""" + resp = self.client.get('/api/v5/nts/machines') + self.assertEqual(resp.status_code, 200) + + def test_test_suite_detail_returns_200(self): + """Test suite detail endpoint returns metadata.""" + resp = self.client.get('/api/v5/test-suites/nts') + self.assertEqual(resp.status_code, 200) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_helpers.py b/tests/server/api/v5/test_helpers.py new file mode 100644 index 000000000..846cd9f13 --- /dev/null +++ b/tests/server/api/v5/test_helpers.py @@ -0,0 +1,151 @@ +# Unit tests for v5 API helper functions. +# +# These tests exercise pure helpers that do not require a database or +# Flask application context, so they can run directly with unittest. +# +# RUN: python %s +# END. + +import datetime +import unittest + +import marshmallow as ma + +from lnt.server.api.v5.helpers import ( + dump_response, escape_like, format_utc, parse_datetime, +) + +UTC = datetime.timezone.utc + + +class TestParseDatetime(unittest.TestCase): + """Tests for parse_datetime().""" + + def test_naive_datetime_assumed_utc(self): + """A naive ISO string (no timezone) should be treated as UTC.""" + result = parse_datetime('2024-01-15T10:00:00') + self.assertEqual(result, datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)) + + def test_utc_timezone(self): + """A datetime with explicit +00:00 offset should remain UTC.""" + result = parse_datetime('2024-01-15T10:00:00+00:00') + self.assertEqual(result, datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)) + + def test_z_suffix(self): + """The 'Z' suffix is equivalent to +00:00 (UTC).""" + result = parse_datetime('2024-01-15T10:00:00Z') + self.assertEqual(result, datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC)) + + def test_positive_offset_converted_to_utc(self): + """+05:00 means the local time is 5 hours ahead of UTC, so + 10:00+05:00 should become 05:00 UTC.""" + result = parse_datetime('2024-01-15T10:00:00+05:00') + self.assertEqual(result, datetime.datetime(2024, 1, 15, 5, 0, 0, tzinfo=UTC)) + + def test_negative_offset_converted_to_utc(self): + """-05:00 means the local time is 5 hours behind UTC, so + 10:00-05:00 should become 15:00 UTC.""" + result = parse_datetime('2024-01-15T10:00:00-05:00') + self.assertEqual(result, datetime.datetime(2024, 1, 15, 15, 0, 0, tzinfo=UTC)) + + def test_result_is_utc_aware(self): + """The returned datetime must always be timezone-aware UTC.""" + result = parse_datetime('2024-01-15T10:00:00+05:00') + self.assertEqual(result.tzinfo, UTC) + + def test_empty_string_returns_none(self): + self.assertIsNone(parse_datetime('')) + + def test_none_returns_none(self): + self.assertIsNone(parse_datetime(None)) + + def test_invalid_string_returns_none(self): + self.assertIsNone(parse_datetime('not-a-date')) + + +class TestFormatUtc(unittest.TestCase): + """Tests for format_utc().""" + + def test_none_returns_none(self): + self.assertIsNone(format_utc(None)) + + def test_aware_utc_datetime_produces_z_suffix(self): + dt = datetime.datetime(2024, 1, 15, 10, 0, 0, tzinfo=UTC) + self.assertEqual(format_utc(dt), '2024-01-15T10:00:00Z') + + def test_aware_utc_with_microseconds(self): + dt = datetime.datetime(2024, 1, 15, 10, 0, 0, 123456, tzinfo=UTC) + self.assertEqual(format_utc(dt), '2024-01-15T10:00:00.123456Z') + + def test_naive_datetime_assumed_utc(self): + """A naive datetime is treated as UTC.""" + dt = datetime.datetime(2024, 1, 15, 10, 0, 0) + self.assertEqual(format_utc(dt), '2024-01-15T10:00:00Z') + + def test_non_utc_offset_converted(self): + """A non-UTC aware datetime is converted to UTC before formatting.""" + tz_plus5 = datetime.timezone(datetime.timedelta(hours=5)) + dt = datetime.datetime(2024, 1, 15, 15, 0, 0, tzinfo=tz_plus5) + self.assertEqual(format_utc(dt), '2024-01-15T10:00:00Z') + + +class TestEscapeLike(unittest.TestCase): + """Tests for escape_like().""" + + def test_no_special_chars(self): + """Plain strings should pass through unchanged.""" + self.assertEqual(escape_like('hello'), 'hello') + + def test_percent_escaped(self): + self.assertEqual(escape_like('100%'), '100\\%') + + def test_underscore_escaped(self): + self.assertEqual(escape_like('a_b'), 'a\\_b') + + def test_backslash_escaped(self): + """Backslashes must be escaped so they are not interpreted as the + LIKE escape character.""" + self.assertEqual(escape_like('a\\b'), 'a\\\\b') + + def test_backslash_escaped_before_wildcards(self): + """A backslash followed by a wildcard must produce an escaped + backslash followed by an escaped wildcard — the order of + replacements matters.""" + self.assertEqual(escape_like('a\\%b'), 'a\\\\\\%b') + self.assertEqual(escape_like('a\\_b'), 'a\\\\\\_b') + + def test_all_special_chars_together(self): + self.assertEqual(escape_like('\\%_'), '\\\\\\%\\_') + + def test_empty_string(self): + self.assertEqual(escape_like(''), '') + + +class _TestSchema(ma.Schema): + """Minimal schema for testing dump_response().""" + name = ma.fields.String(required=True) + value = ma.fields.Integer(required=True) + + +class TestDumpResponse(unittest.TestCase): + """Tests for dump_response().""" + + def setUp(self): + self.schema = _TestSchema() + + def test_valid_data_passes(self): + result = dump_response(self.schema, {'name': 'foo', 'value': 42}) + self.assertEqual(result, {'name': 'foo', 'value': 42}) + + def test_extra_key_raises_value_error(self): + with self.assertRaises(ValueError) as ctx: + dump_response(self.schema, {'name': 'foo', 'value': 42, 'extra': 1}) + self.assertIn('extra', str(ctx.exception)) + + def test_missing_required_field_raises_validation_error(self): + with self.assertRaises(ma.ValidationError): + dump_response(self.schema, {'name': 'foo'}) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/server/api/v5/test_integration.py b/tests/server/api/v5/test_integration.py new file mode 100644 index 000000000..8cc95af53 --- /dev/null +++ b/tests/server/api/v5/test_integration.py @@ -0,0 +1,611 @@ +# End-to-end integration tests for the v5 REST API. +# +# These tests exercise multi-endpoint workflows to verify that the +# endpoints work together correctly, unlike the per-endpoint unit tests. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client, admin_headers, submit_run + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +_DEFAULT_TESTS = [ + { + 'name': 'test.suite/benchmark1', + 'execution_time': [0.1234, 0.1235], + }, + { + 'name': 'test.suite/benchmark2', + 'compile_time': 13.12, + 'execution_time': 0.2135, + }, +] + + +# ----------------------------------------------------------------------- +# 1. TestRunSubmissionWorkflow +# ----------------------------------------------------------------------- + +class TestRunSubmissionWorkflow(unittest.TestCase): + """Submit a run, then verify it via multiple GET endpoints. + + This workflow exercises: + POST /runs (submit) + GET /runs (list) + GET /runs/{uuid} (detail) + GET /runs/{uuid}/samples (samples) + GET /machines (implicit machine creation) + GET /tests (implicit test creation) + """ + + app = None + client = None + + # Shared state populated by setUpClass + _machine_name = None + _revision = None + _run_uuid = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._machine_name = f'submit-wf-{uuid.uuid4().hex[:8]}' + cls._revision = f'r{uuid.uuid4().hex[:8]}' + data = submit_run( + cls.client, cls._machine_name, cls._revision, _DEFAULT_TESTS) + cls._run_uuid = data.get('run_uuid') + + def test_01_submission_succeeded(self): + """The run submission should have returned a valid UUID.""" + self.assertIsNotNone( + self._run_uuid, + "Run submission did not return a run_uuid") + uuid.UUID(self._run_uuid, version=4) + + def test_02_run_appears_in_list(self): + """The submitted run appears in the run list endpoint.""" + resp = self.client.get( + PREFIX + f'/runs?machine={self._machine_name}') + self.assertEqual(resp.status_code, 200, + f"GET /runs returned {resp.status_code}") + data = resp.get_json() + uuids = [item['uuid'] for item in data['items']] + self.assertIn(self._run_uuid, uuids, + "Submitted run not found in run list") + + def test_03_run_detail_is_correct(self): + """GET /runs/{uuid} returns the expected detail.""" + resp = self.client.get(PREFIX + f'/runs/{self._run_uuid}') + self.assertEqual(resp.status_code, 200, + f"GET /runs/{uuid} returned {resp.status_code}") + data = resp.get_json() + self.assertEqual(data['uuid'], self._run_uuid, + "Run detail UUID mismatch") + self.assertEqual(data['machine'], self._machine_name, + "Run detail machine name mismatch") + self.assertIn('commit', data, + "Run detail missing 'commit'") + self.assertEqual( + data['commit'], self._revision, + "Run detail commit mismatch") + self.assertIn('submitted_at', data) + self.assertIn('run_parameters', data) + + def test_04_run_samples_returned(self): + """GET /runs/{uuid}/samples returns the submitted samples.""" + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/samples') + self.assertEqual(resp.status_code, 200, + f"GET /runs/{uuid}/samples returned {resp.status_code}") + data = resp.get_json() + self.assertIn('items', data, + "Samples response missing 'items'") + # We submitted 2 tests, each with samples. The import pipeline + # creates one Sample row per (run, test, repetition) combination. + # benchmark1 has 2 execution_time values, benchmark2 has 1. + self.assertGreater(len(data['items']), 0, + "No samples returned for the submitted run") + + test_names = [s['test'] for s in data['items']] + # The import pipeline may prefix test names with the suite name. + has_benchmark1 = any('benchmark1' in n for n in test_names) + has_benchmark2 = any('benchmark2' in n for n in test_names) + self.assertTrue(has_benchmark1, + f"benchmark1 not found in samples: {test_names}") + self.assertTrue(has_benchmark2, + f"benchmark2 not found in samples: {test_names}") + + def test_05_machine_created_implicitly(self): + """The machine is implicitly created by the run submission.""" + resp = self.client.get( + PREFIX + f'/machines/{self._machine_name}') + self.assertEqual(resp.status_code, 200, + f"Machine '{self._machine_name}' not found after run submission") + data = resp.get_json() + self.assertEqual(data['name'], self._machine_name) + + def test_06_tests_created_implicitly(self): + """The test entities are implicitly created by the run submission.""" + resp = self.client.get( + PREFIX + '/tests?search=test.suite/benchmark1') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + names = [t['name'] for t in data['items']] + has_benchmark1 = any('benchmark1' in n for n in names) + self.assertTrue(has_benchmark1, + f"benchmark1 test not found in tests list: {names}") + + def test_07_commit_created_implicitly(self): + """The commit is implicitly created by the run submission.""" + resp = self.client.get( + PREFIX + f'/commits/{self._revision}') + self.assertEqual(resp.status_code, 200, + "Commit not found after run submission") + self.assertEqual(resp.get_json()['commit'], self._revision) + + +# ----------------------------------------------------------------------- +# 2. TestAPIKeyLifecycle +# ----------------------------------------------------------------------- + +class TestAPIKeyLifecycle(unittest.TestCase): + """Create an API key, use it, revoke it, verify rejection. + + This workflow exercises: + POST /admin/api-keys (create key) + GET /api/v5/ (read with new key) + POST /runs (submit with new key) + DELETE /admin/api-keys/{prefix} (revoke key) + GET /admin/api-keys (verify 401 after revocation) + """ + + app = None + client = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_api_key_full_lifecycle(self): + """Create, use, revoke, and verify rejection of an API key.""" + # Step 1: Create a submit-scoped key via admin endpoint + create_resp = self.client.post( + '/api/v5/admin/api-keys', + json={'name': 'lifecycle-key', 'scope': 'submit'}, + headers=admin_headers(), + ) + self.assertEqual(create_resp.status_code, 201, + f"Failed to create API key: {create_resp.get_data(as_text=True)}") + created = create_resp.get_json() + raw_token = created['key'] + prefix = created['prefix'] + key_headers = {'Authorization': f'Bearer {raw_token}'} + + # Step 2: Use the new key on a read endpoint -- should succeed + read_resp = self.client.get('/api/v5/', headers=key_headers) + self.assertEqual(read_resp.status_code, 200, + "New submit key cannot read discovery endpoint") + + # Step 3: Use the new key to submit a run (within its scope) + machine = f'key-test-{uuid.uuid4().hex[:8]}' + commit = f'r{uuid.uuid4().hex[:8]}' + submit_resp = self.client.post( + PREFIX + '/runs', + json={ + 'format_version': '5', + 'machine': {'name': machine}, + 'commit': commit, + 'tests': _DEFAULT_TESTS, + }, + headers=key_headers, + ) + self.assertIn(submit_resp.status_code, [201, 301], + "Submit-scoped key failed to submit a run: %d" + % submit_resp.status_code) + + # Step 4: Revoke the key + revoke_resp = self.client.delete( + f'/api/v5/admin/api-keys/{prefix}', + headers=admin_headers(), + ) + self.assertEqual(revoke_resp.status_code, 204, + "Failed to revoke API key") + + # Step 5: Using the revoked key on an admin endpoint yields 401 + # (admin endpoints require auth, revoked key is inactive) + reject_resp = self.client.get( + '/api/v5/admin/api-keys', headers=key_headers) + self.assertEqual(reject_resp.status_code, 401, + "Revoked key was not rejected: got %d" + % reject_resp.status_code) + + +# ----------------------------------------------------------------------- +# 3. TestMachineCRUDWorkflow +# ----------------------------------------------------------------------- + +class TestMachineCRUDWorkflow(unittest.TestCase): + """Create, submit runs, rename, and delete a machine. + + This workflow exercises: + POST /machines (create) + POST /runs (submit run to machine) + GET /machines/{name}/runs (list runs) + PATCH /machines/{name} (rename) + GET /machines/{old_name} (verify 404) + GET /machines/{new_name} (verify accessible) + DELETE /machines/{name} (delete with cascade) + """ + + app = None + client = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_machine_crud_workflow(self): + """Full create-update-delete lifecycle for a machine.""" + original_name = f'crud-machine-{uuid.uuid4().hex[:8]}' + new_name = f'crud-renamed-{uuid.uuid4().hex[:8]}' + + # Step 1: Create a machine explicitly + create_resp = self.client.post( + PREFIX + '/machines', + json={'name': original_name, 'info': {'os': 'linux'}}, + headers=admin_headers(), + ) + self.assertEqual(create_resp.status_code, 201, + f"Failed to create machine: {create_resp.get_data(as_text=True)}") + self.assertEqual(create_resp.get_json()['name'], original_name) + + # Step 2: Submit a run to this machine + commit = f'r{uuid.uuid4().hex[:8]}' + submit_data = submit_run(self.client, original_name, commit, + _DEFAULT_TESTS) + run_uuid = submit_data['run_uuid'] + self.assertIsNotNone(run_uuid, + "Run submission did not return a run_uuid") + + # Step 3: List the machine's runs + runs_resp = self.client.get( + PREFIX + f'/machines/{original_name}/runs') + self.assertEqual(runs_resp.status_code, 200) + runs_data = runs_resp.get_json() + run_uuids = [r['uuid'] for r in runs_data['items']] + self.assertIn(run_uuid, run_uuids, + "Submitted run not in machine runs list") + + # Step 4: Rename the machine + rename_resp = self.client.patch( + PREFIX + f'/machines/{original_name}', + json={'name': new_name}, + headers=admin_headers(), + ) + self.assertEqual(rename_resp.status_code, 200, + f"Rename failed: {rename_resp.get_data(as_text=True)}") + self.assertEqual(rename_resp.get_json()['name'], new_name) + location = rename_resp.headers.get('Location') + self.assertIsNotNone(location, + "Rename did not return Location header") + self.assertIn(new_name, location) + + # Step 5: Old name returns 404 + old_resp = self.client.get( + PREFIX + f'/machines/{original_name}') + self.assertEqual(old_resp.status_code, 404, + "Old machine name still accessible: %d" + % old_resp.status_code) + + # Step 6: New name returns the machine + new_resp = self.client.get( + PREFIX + f'/machines/{new_name}') + self.assertEqual(new_resp.status_code, 200, + "New machine name not accessible") + self.assertEqual(new_resp.get_json()['name'], new_name) + + # Step 7: Runs are still accessible under the renamed machine + runs_resp2 = self.client.get( + PREFIX + f'/machines/{new_name}/runs') + self.assertEqual(runs_resp2.status_code, 200) + run_uuids2 = [r['uuid'] for r in runs_resp2.get_json()['items']] + self.assertIn(run_uuid, run_uuids2, + "Run missing after machine rename") + + # Step 8: Delete the machine (cascading) + delete_resp = self.client.delete( + PREFIX + f'/machines/{new_name}', + headers=admin_headers(), + ) + self.assertEqual(delete_resp.status_code, 204, + f"Delete failed: {delete_resp.status_code}") + + # Step 9: Machine is gone + gone_resp = self.client.get( + PREFIX + f'/machines/{new_name}') + self.assertEqual(gone_resp.status_code, 404, + "Machine still found after deletion") + + # Step 10: The run is also gone (cascading delete) + run_resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(run_resp.status_code, 404, + "Run still exists after machine deletion") + + +# ----------------------------------------------------------------------- +# 4. TestDiscoveryNavigability +# ----------------------------------------------------------------------- + +class TestDiscoveryNavigability(unittest.TestCase): + """Follow every link from the discovery endpoint and verify 200. + + This workflow exercises: + GET /api/v5/ (discovery) + GET each link in the discovery response + """ + + app = None + client = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_all_discovery_links_resolve(self): + """Every link in the discovery response should return 200.""" + disco_resp = self.client.get('/api/v5/') + self.assertEqual(disco_resp.status_code, 200, + "Discovery endpoint returned %d" + % disco_resp.status_code) + data = disco_resp.get_json() + + self.assertIn('test_suites', data) + self.assertGreater(len(data['test_suites']), 0, + "No test suites in discovery response") + + for suite in data['test_suites']: + self.assertIn('name', suite) + self.assertIn('links', suite) + suite_name = suite['name'] + + for link_name, url in suite['links'].items(): + # The /query endpoint requires a mandatory 'metric' + # parameter, so a bare GET returns 422 by design. + # Skip it in the navigability check. + if link_name == 'query': + continue + resp = self.client.get(url) + self.assertEqual( + resp.status_code, 200, + f"Link '{link_name}' ({url}) for suite '{suite_name}' " + f"returned {resp.status_code}") + + def test_discovery_nts_suite_has_all_expected_links(self): + """The 'nts' suite should have all the expected resource links.""" + disco_resp = self.client.get('/api/v5/') + data = disco_resp.get_json() + + nts_suites = [s for s in data['test_suites'] if s['name'] == 'nts'] + self.assertEqual(len(nts_suites), 1, + "Expected exactly one 'nts' suite") + links = nts_suites[0]['links'] + + expected_keys = { + 'machines', 'commits', 'runs', 'tests', + 'regressions', 'query', + } + self.assertEqual(set(links.keys()), expected_keys, + "Discovery links mismatch") + + +# ----------------------------------------------------------------------- +# 6. TestCORSOnAllEndpoints +# ----------------------------------------------------------------------- + +class TestCORSOnAllEndpoints(unittest.TestCase): + """Verify CORS headers are present on various endpoint types. + + This workflow exercises the CORS middleware across: + GET /api/v5/ (discovery) + GET /api/v5/{ts}/machines (list) + GET /api/v5/{ts}/runs (list) + POST /api/v5/{ts}/runs (write) + DELETE /api/v5/{ts}/machines/nonexistent (error response) + """ + + app = None + client = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _assert_cors(self, resp, context): + """Assert that the CORS allow-origin header is present.""" + self.assertEqual( + resp.headers.get('Access-Control-Allow-Origin'), '*', + f"Missing CORS header on {context} (status {resp.status_code})") + + def test_cors_on_discovery(self): + """CORS header on GET /api/v5/.""" + resp = self.client.get('/api/v5/') + self._assert_cors(resp, 'GET /api/v5/') + + def test_cors_on_machine_list(self): + """CORS header on GET /machines.""" + resp = self.client.get(PREFIX + '/machines') + self._assert_cors(resp, 'GET /machines') + + def test_cors_on_run_list(self): + """CORS header on GET /runs.""" + resp = self.client.get(PREFIX + '/runs') + self._assert_cors(resp, 'GET /runs') + + def test_cors_on_run_submit(self): + """CORS header on POST /runs.""" + machine = f'cors-m-{uuid.uuid4().hex[:8]}' + commit = f'r{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/runs', + json={ + 'format_version': '5', + 'machine': {'name': machine}, + 'commit': commit, + 'tests': _DEFAULT_TESTS, + }, + headers=admin_headers(), + ) + self._assert_cors(resp, 'POST /runs') + + def test_cors_on_error_response(self): + """CORS header present even on 404 error responses.""" + resp = self.client.get( + PREFIX + '/machines/nonexistent-cors-test-xyz') + self.assertEqual(resp.status_code, 404) + self._assert_cors(resp, 'GET /machines/nonexistent (404)') + + def test_cors_on_delete_error(self): + """CORS header present on 401 (unauthenticated DELETE).""" + resp = self.client.delete( + PREFIX + '/machines/nonexistent-cors-del') + # Should be 401 (no auth) -- but we care about the header + self._assert_cors(resp, 'DELETE /machines/nonexistent (no auth)') + + def test_cors_on_options_preflight(self): + """OPTIONS preflight request returns proper CORS headers.""" + resp = self.client.options( + PREFIX + '/machines', + headers={ + 'Origin': 'https://example.com', + 'Access-Control-Request-Method': 'GET', + }, + ) + self._assert_cors(resp, 'OPTIONS /machines') + # Verify the Allow-Methods and Allow-Headers are present + self.assertIn( + 'GET', + resp.headers.get('Access-Control-Allow-Methods', ''), + "OPTIONS response missing GET in Allow-Methods") + self.assertIn( + 'Authorization', + resp.headers.get('Access-Control-Allow-Headers', ''), + "OPTIONS response missing Authorization in Allow-Headers") + + +# ----------------------------------------------------------------------- +# 7. TestQueryWorkflow +# ----------------------------------------------------------------------- + +class TestQueryWorkflow(unittest.TestCase): + """Submit runs with data, then query them via POST /query. + + This workflow exercises: + POST /runs (submit with data) + PATCH /commits/{value} (assign ordinals) + POST /query (time-series query) + """ + + app = None + client = None + + _machine = None + _test = None + _commits = None + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._machine = f'query-wf-m-{uuid.uuid4().hex[:8]}' + cls._test = f'query-wf/test/{uuid.uuid4().hex[:8]}' + cls._commits = [] + + import datetime + from v5_test_helpers import ( + create_machine, create_commit, create_run, + create_test, create_sample, + ) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=cls._machine) + test = create_test(session, ts, name=cls._test) + for i in range(5): + c = create_commit(session, ts, + commit=f'qwf-c-{uuid.uuid4().hex[:8]}') + ts.update_commit(session, c, ordinal=5000 + i) + run = create_run(session, ts, machine, c, + submitted_at=datetime.datetime( + 2024, 1, 1 + i, 12, 0, 0)) + create_sample(session, ts, run, test, + execution_time=10.0 + i) + cls._commits.append(c.commit) + session.commit() + session.close() + + def test_query_returns_all_data(self): + """POST /query returns all 5 submitted data points.""" + resp = self.client.post( + PREFIX + '/query', + json={ + 'metric': 'execution_time', + 'machine': self._machine, + 'test': [self._test], + }, + ) + self.assertEqual(resp.status_code, 200, + f"POST /query returned {resp.status_code}") + items = resp.get_json()['items'] + self.assertEqual(len(items), 5, + f"Expected 5 items, got {len(items)}") + for item in items: + self.assertIn('commit', item) + self.assertIn('ordinal', item) + self.assertIn('submitted_at', item) + self.assertNotIn('order', item, + "v4 'order' field leaked into v5 response") + self.assertNotIn('timestamp', item, + "v4 'timestamp' field leaked into v5 response") + + def test_query_filter_by_time_range(self): + """POST /query with after_time/before_time filters correctly.""" + resp = self.client.post( + PREFIX + '/query', + json={ + 'metric': 'execution_time', + 'machine': self._machine, + 'after_time': '2024-01-02T00:00:00', + 'before_time': '2024-01-04T00:00:00', + }, + ) + self.assertEqual(resp.status_code, 200) + items = resp.get_json()['items'] + self.assertEqual(len(items), 2) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_machines.py b/tests/server/api/v5/test_machines.py new file mode 100644 index 000000000..cf4e1d0d7 --- /dev/null +++ b/tests/server/api/v5/test_machines.py @@ -0,0 +1,786 @@ +# Tests for the v5 machine endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import datetime +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, + create_machine, create_commit, create_run, + create_test, create_regression, + collect_all_pages, submit_run, +) + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +class TestMachineList(unittest.TestCase): + """Tests for GET /api/v5/{ts}/machines.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_empty(self): + """Empty list when no machines exist initially (or just returns 200).""" + resp = self.client.get(PREFIX + '/machines') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_list_has_pagination_envelope(self): + resp = self.client.get(PREFIX + '/machines') + data = resp.get_json() + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + def test_list_with_total(self): + """Offset-paginated lists include a total count.""" + resp = self.client.get(PREFIX + '/machines') + data = resp.get_json() + self.assertIn('total', data) + self.assertIsInstance(data['total'], int) + + +class TestMachineCreate(unittest.TestCase): + """Tests for POST /api/v5/{ts}/machines.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_create_machine(self): + """Create a machine and verify 201 response.""" + name = f'create-test-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['name'], name) + + def test_create_machine_with_info(self): + """Create a machine with metadata.""" + name = f'create-info-{uuid.uuid4().hex[:8]}' + resp = self.client.post( + PREFIX + '/machines', + json={'name': name, 'info': {'arch': 'x86_64', 'os': 'linux'}}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['name'], name) + self.assertIn('info', data) + self.assertEqual(data['info'].get('arch'), 'x86_64') + + def test_create_machine_appears_in_list(self): + """Newly created machine appears in the list.""" + name = f'create-list-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + '/machines') + data = resp.get_json() + names = [m['name'] for m in data['items']] + self.assertIn(name, names) + + def test_create_machine_no_auth_401(self): + """Creating without auth should return 401.""" + resp = self.client.post( + PREFIX + '/machines', + json={'name': 'no-auth-test'}, + ) + self.assertEqual(resp.status_code, 401) + + def test_create_machine_triage_scope_403(self): + """Creating with triage scope (one below manage) returns 403.""" + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.post( + PREFIX + '/machines', + json={'name': 'triage-only-test'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_machine_manage_scope_201(self): + """Creating with manage scope (the required scope) succeeds.""" + name = f'manage-create-{uuid.uuid4().hex[:8]}' + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + + def test_create_machine_missing_name_422(self): + """Creating without name should return 422 (schema validation).""" + resp = self.client.post( + PREFIX + '/machines', + json={}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_create_duplicate_409(self): + """Creating a machine with a duplicate name should return 409.""" + name = f'dup-test-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 409) + + +class TestMachineDetail(unittest.TestCase): + """Tests for GET /api/v5/{ts}/machines/{machine_name}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_get_machine_detail(self): + """Get machine detail by name.""" + name = f'detail-test-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name, 'info': {'foo': 'bar'}}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['name'], name) + self.assertIn('info', data) + + def test_get_nonexistent_404(self): + """Getting a nonexistent machine should return 404.""" + resp = self.client.get( + PREFIX + '/machines/nonexistent-machine-xyz') + self.assertEqual(resp.status_code, 404) + + +class TestMachineDetailETag(unittest.TestCase): + """ETag tests for GET /api/v5/{ts}/machines/{machine_name}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_etag_present_on_detail(self): + """Machine detail response should include an ETag header.""" + name = f'etag-present-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 200) + etag = resp.headers.get('ETag') + self.assertIsNotNone(etag) + self.assertTrue(etag.startswith('W/"')) + + def test_etag_304_on_match(self): + """Sending If-None-Match with the same ETag returns 304.""" + name = f'etag-304-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/machines/{name}') + etag = resp.headers.get('ETag') + + resp2 = self.client.get( + PREFIX + f'/machines/{name}', + headers={'If-None-Match': etag}, + ) + self.assertEqual(resp2.status_code, 304) + + def test_etag_200_on_mismatch(self): + """Sending If-None-Match with a different ETag returns 200.""" + name = f'etag-200-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get( + PREFIX + f'/machines/{name}', + headers={'If-None-Match': 'W/"stale-etag-value"'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertIsNotNone(resp.get_json()) + + +class TestMachineUpdate(unittest.TestCase): + """Tests for PATCH /api/v5/{ts}/machines/{machine_name}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_rename_machine(self): + """Rename a machine and verify Location header.""" + old_name = f'rename-old-{uuid.uuid4().hex[:8]}' + new_name = f'rename-new-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': old_name}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/machines/{old_name}', + json={'name': new_name}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['name'], new_name) + location = resp.headers.get('Location') + self.assertIsNotNone(location) + self.assertIn(new_name, location) + + def test_update_info(self): + """Update machine info without rename.""" + name = f'update-info-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/machines/{name}', + json={'info': {'key': 'value'}}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['info'].get('key'), 'value') + + def test_rename_to_existing_409(self): + """Renaming to an existing name should return 409.""" + name1 = f'rename-dup-a-{uuid.uuid4().hex[:8]}' + name2 = f'rename-dup-b-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', json={'name': name1}, + headers=admin_headers(), + ) + self.client.post( + PREFIX + '/machines', json={'name': name2}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/machines/{name1}', + json={'name': name2}, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 409) + + def test_update_no_auth_401(self): + """PATCH without auth returns 401.""" + name = f'upd-noauth-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.patch( + PREFIX + f'/machines/{name}', + json={'info': {'k': 'v'}}, + ) + self.assertEqual(resp.status_code, 401) + + def test_update_triage_scope_403(self): + """PATCH with triage scope (one below manage) returns 403.""" + name = f'upd-triage-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.patch( + PREFIX + f'/machines/{name}', + json={'info': {'k': 'v'}}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_update_manage_scope_200(self): + """PATCH with manage scope (the required scope) succeeds.""" + name = f'upd-manage-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.patch( + PREFIX + f'/machines/{name}', + json={'info': {'k': 'v'}}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + + +class TestMachineDelete(unittest.TestCase): + """Tests for DELETE /api/v5/{ts}/machines/{machine_name}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_machine(self): + """Delete machine and verify 204, then verify it's gone.""" + name = f'delete-test-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.delete( + PREFIX + f'/machines/{name}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + # Verify it's gone + resp = self.client.get(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 404) + + def test_delete_machine_with_runs(self): + """Delete machine that has runs -- verify cascading deletion works.""" + name = f'delete-runs-{uuid.uuid4().hex[:8]}' + submit_run(self.client, name, f'rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': [0.0]}]) + + resp = self.client.delete( + PREFIX + f'/machines/{name}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + # Verify machine is gone + resp = self.client.get(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 404) + + def test_delete_machine_with_regression_indicators(self): + """Delete machine whose RegressionIndicators reference it. + + Verifies the delete handler cleans up RegressionIndicators (which + have no CASCADE from machine_id) before deleting the machine. + The Regression itself remains (it may have other indicators on + different machines). + """ + name = f'delete-ri-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + machine = create_machine(session, ts, name=name) + test = create_test( + session, ts, name=f'ri/test/{uuid.uuid4().hex[:8]}') + create_regression( + session, ts, title=f'Reg for {name}', + indicators=[{'machine_id': machine.id, 'test_id': test.id, + 'metric': 'execution_time'}]) + session.commit() + session.close() + + # Delete the machine via the API. + resp = self.client.delete( + PREFIX + f'/machines/{name}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + # Verify machine is gone. + resp = self.client.get(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 404) + + def test_delete_nonexistent_404(self): + """Deleting a nonexistent machine should return 404.""" + resp = self.client.delete( + PREFIX + '/machines/nonexistent-del-xyz', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 404) + + def test_delete_no_auth_401(self): + """DELETE without auth returns 401.""" + name = f'del-noauth-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.delete(PREFIX + f'/machines/{name}') + self.assertEqual(resp.status_code, 401) + + def test_delete_triage_scope_403(self): + """DELETE with triage scope (one below manage) returns 403.""" + name = f'del-triage-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.delete( + PREFIX + f'/machines/{name}', + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_delete_manage_scope_204(self): + """DELETE with manage scope (the required scope) succeeds.""" + name = f'del-manage-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.delete( + PREFIX + f'/machines/{name}', + headers=headers, + ) + self.assertEqual(resp.status_code, 204) + + +class TestMachineRuns(unittest.TestCase): + """Tests for GET /api/v5/{ts}/machines/{machine_name}/runs.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_runs_for_machine(self): + """List runs for a machine.""" + name = f'runs-list-{uuid.uuid4().hex[:8]}' + submit_run(self.client, name, f'rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': [0.0]}]) + + resp = self.client.get(PREFIX + f'/machines/{name}/runs') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertGreater(len(data['items']), 0) + # Verify run fields + item = data['items'][0] + self.assertIn('uuid', item) + self.assertIn('commit', item) + self.assertIn('submitted_at', item) + + def test_list_runs_empty(self): + """Machine with no runs returns empty list.""" + name = f'runs-empty-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/machines/{name}/runs') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_list_runs_pagination(self): + """Test pagination of runs for a machine.""" + name = f'runs-page-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + # Create 3 runs with distinct timestamps + for i in range(3): + commit = create_commit( + session, ts, + commit=f'page-rev-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, commit, + submitted_at=datetime.datetime( + 2024, 1, 1 + i, 12, 0, 0)) + session.commit() + session.close() + + # Request with limit=2 + resp = self.client.get( + PREFIX + f'/machines/{name}/runs?limit=2') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 2) + self.assertIsNotNone(data['cursor']['next']) + + # Follow the cursor + cursor = data['cursor']['next'] + resp2 = self.client.get( + PREFIX + f'/machines/{name}/runs?limit=2&cursor={cursor}') + self.assertEqual(resp2.status_code, 200) + data2 = resp2.get_json() + self.assertEqual(len(data2['items']), 1) + self.assertIsNone(data2['cursor']['next']) + + def test_list_runs_after_filter(self): + """Filter runs by after datetime.""" + name = f'runs-after-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + c1 = create_commit( + session, ts, commit=f'after-1-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c1, + submitted_at=datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + c2 = create_commit( + session, ts, commit=f'after-2-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c2, + submitted_at=datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + resp = self.client.get( + PREFIX + f'/machines/{name}/runs?after=2024-03-01T00:00:00') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + def test_list_runs_sort_descending(self): + """Sort runs by -submitted_at returns newest first.""" + name = f'runs-sort-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + for month in (1, 4, 7): + c = create_commit( + session, ts, + commit=f'sort-{month}-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c, + submitted_at=datetime.datetime( + 2024, month, 1, 12, 0, 0)) + session.commit() + session.close() + + # Default order (ascending by ID) + resp_default = self.client.get( + PREFIX + f'/machines/{name}/runs') + default_times = [ + item['submitted_at'] + for item in resp_default.get_json()['items']] + + # Descending by submitted_at + resp_sorted = self.client.get( + PREFIX + f'/machines/{name}/runs?sort=-submitted_at') + sorted_times = [ + item['submitted_at'] + for item in resp_sorted.get_json()['items']] + + self.assertEqual(len(sorted_times), 3) + self.assertEqual(sorted_times, list(reversed(default_times))) + + def test_list_runs_nonexistent_machine_404(self): + """Listing runs for a nonexistent machine should return 404.""" + resp = self.client.get( + PREFIX + '/machines/nonexistent-machine-runs/runs') + self.assertEqual(resp.status_code, 404) + + def test_list_runs_before_filter(self): + """Filter runs by before datetime.""" + name = f'runs-before-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + c1 = create_commit( + session, ts, commit=f'before-1-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c1, + submitted_at=datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + c2 = create_commit( + session, ts, commit=f'before-2-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c2, + submitted_at=datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + resp = self.client.get( + PREFIX + f'/machines/{name}/runs?before=2024-03-01T00:00:00') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string on machine runs should return 400.""" + name = f'cursor-bad-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get( + PREFIX + f'/machines/{name}/runs?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + def test_machine_runs_pagination(self): + """Paginating through machine runs collects all items.""" + name = f'pag-mruns-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + for i in range(5): + c = create_commit( + session, ts, + commit=f'pag-mr-{i}-{uuid.uuid4().hex[:8]}') + create_run(session, ts, machine, c, + submitted_at=datetime.datetime(2024, 1, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + url = PREFIX + f'/machines/{name}/runs?limit=2' + all_items = collect_all_pages(self, self.client, url) + self.assertEqual(len(all_items), 5) + uuids = [item['uuid'] for item in all_items] + self.assertEqual(len(set(uuids)), 5) + + +class TestMachineSearch(unittest.TestCase): + """Test machine list search parameter.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_search_by_name_substring(self): + """Search machines by name substring.""" + unique = uuid.uuid4().hex[:8] + middle = f'srch{unique}' + name = f'prefix-{middle}-suffix' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + # Substring in the middle should match + resp = self.client.get( + PREFIX + f'/machines?search={middle}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + names = [m['name'] for m in data['items']] + self.assertIn(name, names) + + def test_search_case_insensitive(self): + """Search is case-insensitive.""" + unique = uuid.uuid4().hex[:8] + name = f'CaSeMaCh-{unique}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + # Search with all-lowercase + resp = self.client.get( + PREFIX + f'/machines?search=casemach-{unique}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + names = [m['name'] for m in data['items']] + self.assertIn(name, names) + + # Search with all-uppercase + resp = self.client.get( + PREFIX + f'/machines?search=CASEMACH-{unique.upper()}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + names = [m['name'] for m in data['items']] + self.assertIn(name, names) + + def test_search_by_machine_field(self): + """Search matches against searchable machine fields, not just name.""" + unique = uuid.uuid4().hex[:8] + name = f'field-search-{unique}' + os_value = f'SpecialOS-{unique}' + self.client.post( + PREFIX + '/machines', + json={'name': name, 'info': {'os': os_value}}, + headers=admin_headers(), + ) + # Search by a substring of the os field value — should find the + # machine even though the name doesn't match the search term. + resp = self.client.get( + PREFIX + f'/machines?search=SpecialOS-{unique}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + names = [m['name'] for m in data['items']] + self.assertIn(name, names) + + +class TestMachineUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_machines_list_unknown_param_returns_400(self): + resp = self.client.get(PREFIX + '/machines?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_machine_detail_unknown_param_returns_400(self): + name = f'unk-det-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get(PREFIX + f'/machines/{name}?bogus=1') + self.assertEqual(resp.status_code, 400) + + def test_machine_runs_unknown_param_returns_400(self): + name = f'unk-runs-{uuid.uuid4().hex[:8]}' + self.client.post( + PREFIX + '/machines', + json={'name': name}, + headers=admin_headers(), + ) + resp = self.client.get( + PREFIX + f'/machines/{name}/runs?bogus=1') + self.assertEqual(resp.status_code, 400) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_pagination.py b/tests/server/api/v5/test_pagination.py new file mode 100644 index 000000000..83102944a --- /dev/null +++ b/tests/server/api/v5/test_pagination.py @@ -0,0 +1,246 @@ +# Tests for the v5 API pagination utilities. +# +# RUN: python %s +# END. + +"""These are pure unit tests that do not require a running LNT instance.""" + +import sys +import os +import unittest + +sys.path.insert(0, os.path.dirname(__file__)) + +from lnt.server.api.v5.pagination import ( + encode_cursor, decode_cursor, cursor_paginate, make_paginated_response, +) + + +class TestCursorEncoding(unittest.TestCase): + def test_encode_decode_roundtrip(self): + for value in [1, 42, 999999]: + cursor = encode_cursor(value) + self.assertEqual(decode_cursor(cursor), value) + + def test_decode_none(self): + self.assertIsNone(decode_cursor(None)) + + def test_decode_empty(self): + self.assertIsNone(decode_cursor('')) + + def test_decode_malformed(self): + self.assertIsNone(decode_cursor('not-valid-base64!@#')) + + def test_encode_returns_string(self): + cursor = encode_cursor(42) + self.assertIsInstance(cursor, str) + + def test_different_ids_produce_different_cursors(self): + c1 = encode_cursor(1) + c2 = encode_cursor(2) + self.assertNotEqual(c1, c2) + + +class TestCursorPaginate(unittest.TestCase): + """Test cursor_paginate with a real SQLAlchemy in-memory SQLite database.""" + + @classmethod + def setUpClass(cls): + from sqlalchemy import create_engine, Column, Integer + from sqlalchemy.ext.declarative import declarative_base + from sqlalchemy.orm import sessionmaker + + cls.engine = create_engine('sqlite:///:memory:') + cls.Base = declarative_base() + + class Item(cls.Base): + __tablename__ = 'items' + id = Column(Integer, primary_key=True) + + cls.Item = Item + cls.Base.metadata.create_all(cls.engine) + cls.Session = sessionmaker(bind=cls.engine) + + def setUp(self): + """Insert 10 items with ids 1..10.""" + self.session = self.Session() + # Clear any leftover rows + self.session.query(self.Item).delete() + for i in range(1, 11): + self.session.add(self.Item(id=i)) + self.session.commit() + + def tearDown(self): + self.session.close() + + # -- ascending (default) ------------------------------------------------- + + def test_ascending_first_page(self): + query = self.session.query(self.Item) + items, next_cursor = cursor_paginate( + query, self.Item.id, limit=3) + ids = [item.id for item in items] + self.assertEqual(ids, [1, 2, 3]) + self.assertIsNotNone(next_cursor) + + def test_ascending_second_page(self): + query = self.session.query(self.Item) + _, cursor = cursor_paginate(query, self.Item.id, limit=3) + items, next_cursor = cursor_paginate( + query, self.Item.id, cursor_str=cursor, limit=3) + ids = [item.id for item in items] + self.assertEqual(ids, [4, 5, 6]) + self.assertIsNotNone(next_cursor) + + def test_ascending_all_pages(self): + """Walk through all pages and collect all items.""" + query = self.session.query(self.Item) + all_ids = [] + cursor = None + for _ in range(20): # safety limit + items, cursor = cursor_paginate( + query, self.Item.id, cursor_str=cursor, limit=3) + all_ids.extend(item.id for item in items) + if cursor is None: + break + self.assertEqual(all_ids, list(range(1, 11))) + + def test_ascending_exact_page_boundary(self): + """When items exactly fill the limit, next page should be empty.""" + query = self.session.query(self.Item) + items, cursor = cursor_paginate(query, self.Item.id, limit=10) + self.assertEqual(len(items), 10) + self.assertIsNone(cursor) + + # -- descending ---------------------------------------------------------- + + def test_descending_first_page(self): + query = self.session.query(self.Item) + items, next_cursor = cursor_paginate( + query, self.Item.id, limit=3, descending=True) + ids = [item.id for item in items] + self.assertEqual(ids, [10, 9, 8]) + self.assertIsNotNone(next_cursor) + + def test_descending_second_page(self): + query = self.session.query(self.Item) + _, cursor = cursor_paginate( + query, self.Item.id, limit=3, descending=True) + items, next_cursor = cursor_paginate( + query, self.Item.id, cursor_str=cursor, limit=3, + descending=True) + ids = [item.id for item in items] + self.assertEqual(ids, [7, 6, 5]) + self.assertIsNotNone(next_cursor) + + def test_descending_all_pages(self): + """Walk through all pages descending and collect all items.""" + query = self.session.query(self.Item) + all_ids = [] + cursor = None + for _ in range(20): # safety limit + items, cursor = cursor_paginate( + query, self.Item.id, cursor_str=cursor, limit=3, + descending=True) + all_ids.extend(item.id for item in items) + if cursor is None: + break + self.assertEqual(all_ids, list(range(10, 0, -1))) + + def test_descending_exact_page_boundary(self): + """When items exactly fill the limit descending, no next page.""" + query = self.session.query(self.Item) + items, cursor = cursor_paginate( + query, self.Item.id, limit=10, descending=True) + self.assertEqual(len(items), 10) + self.assertIsNone(cursor) + + def test_descending_single_item_pages(self): + """Walking one-at-a-time descending yields all items.""" + query = self.session.query(self.Item) + all_ids = [] + cursor = None + for _ in range(20): + items, cursor = cursor_paginate( + query, self.Item.id, cursor_str=cursor, limit=1, + descending=True) + all_ids.extend(item.id for item in items) + if cursor is None: + break + self.assertEqual(all_ids, list(range(10, 0, -1))) + + # -- edge cases ---------------------------------------------------------- + + def test_empty_table_ascending(self): + self.session.query(self.Item).delete() + self.session.commit() + query = self.session.query(self.Item) + items, cursor = cursor_paginate(query, self.Item.id, limit=5) + self.assertEqual(items, []) + self.assertIsNone(cursor) + + def test_empty_table_descending(self): + self.session.query(self.Item).delete() + self.session.commit() + query = self.session.query(self.Item) + items, cursor = cursor_paginate( + query, self.Item.id, limit=5, descending=True) + self.assertEqual(items, []) + self.assertIsNone(cursor) + + def test_limit_clamped_low(self): + """Limit < 1 should be clamped to 1.""" + query = self.session.query(self.Item) + items, _ = cursor_paginate(query, self.Item.id, limit=0) + self.assertEqual(len(items), 1) + + def test_limit_clamped_high(self): + """Limit > 10000 should be clamped to 10000.""" + query = self.session.query(self.Item) + items, _ = cursor_paginate(query, self.Item.id, limit=99999) + # Only 10 items in the table + self.assertEqual(len(items), 10) + + def test_limit_above_old_cap_now_allowed(self): + """Limits above the old 500 cap should now work (up to 10000).""" + for i in range(11, 612): + self.session.add(self.Item(id=i)) + self.session.commit() + + query = self.session.query(self.Item) + items, _ = cursor_paginate(query, self.Item.id, limit=611) + self.assertEqual(len(items), 611) + + +class TestPaginatedResponse(unittest.TestCase): + def test_basic_envelope(self): + result = make_paginated_response( + items=[{'id': 1}, {'id': 2}], + next_cursor='abc123', + ) + self.assertIn('items', result) + self.assertIn('cursor', result) + self.assertEqual(result['cursor']['next'], 'abc123') + self.assertIsNone(result['cursor']['previous']) + + def test_no_next_cursor(self): + result = make_paginated_response(items=[], next_cursor=None) + self.assertIsNone(result['cursor']['next']) + + def test_total_included_when_provided(self): + result = make_paginated_response( + items=[], next_cursor=None, total=42) + self.assertEqual(result['total'], 42) + + def test_total_omitted_when_not_provided(self): + result = make_paginated_response(items=[], next_cursor=None) + self.assertNotIn('total', result) + + def test_items_preserved(self): + items = [{'name': 'a'}, {'name': 'b'}] + result = make_paginated_response(items=items, next_cursor=None) + self.assertEqual(result['items'], items) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_profiles.py b/tests/server/api/v5/test_profiles.py new file mode 100644 index 000000000..0e027df02 --- /dev/null +++ b/tests/server/api/v5/test_profiles.py @@ -0,0 +1,411 @@ +# Tests for the v5 profile endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import os +import sys +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( # noqa: E402 + create_app, create_client, admin_headers, make_scoped_headers, + submit_run, make_profile_base64, +) + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _submit_run_with_profile(client, tag=None): + """Submit a run with a profiled test. Returns (run_uuid, test_name).""" + suffix = tag or uuid.uuid4().hex[:8] + machine_name = f'prof-machine-{suffix}' + commit = f'prof-commit-{suffix}' + test_name = f'test.suite/profiled-{suffix}' + profile_b64 = make_profile_base64() + + data = submit_run(client, machine_name, commit, [ + { + 'name': test_name, + 'execution_time': 1.23, + 'profile': profile_b64, + }, + ]) + return data['run_uuid'], test_name + + +# --------------------------------------------------------------------------- +# Profile listing +# --------------------------------------------------------------------------- + +class TestProfileListing(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs/{uuid}/profiles.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._run_uuid, cls._test_name = _submit_run_with_profile( + cls.client, tag='listing') + + def test_list_profiles(self): + """Run with profile -> listing returns [{test, uuid}].""" + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/profiles') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIsInstance(data, list) + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['test'], self._test_name) + self.assertIn('uuid', data[0]) + + def test_list_profiles_empty(self): + """Run with no profiles -> empty list.""" + data = submit_run(self.client, 'no-prof-machine', 'no-prof-commit', [ + {'name': 'test.suite/no-profile', 'execution_time': 1.0}, + ]) + resp = self.client.get( + PREFIX + f'/runs/{data["run_uuid"]}/profiles') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json(), []) + + def test_list_profiles_nonexistent_run(self): + """Nonexistent run UUID -> 404.""" + resp = self.client.get( + PREFIX + f'/runs/{uuid.uuid4()}/profiles') + self.assertEqual(resp.status_code, 404) + + +# --------------------------------------------------------------------------- +# Profile submission via POST /runs +# --------------------------------------------------------------------------- + +class TestProfileSubmission(unittest.TestCase): + """Tests for profile submission as part of run submission.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_submit_with_profile(self): + """Profile field in test entry creates a Profile row.""" + run_uuid, test_name = _submit_run_with_profile( + self.client, tag='submit-ok') + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/profiles') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data), 1) + self.assertEqual(data[0]['test'], test_name) + + def test_submit_without_profile(self): + """No profile field -> no profiles created.""" + data = submit_run(self.client, 'no-prof-m2', 'no-prof-c2', [ + {'name': 'test.suite/noprof2', 'execution_time': 2.0}, + ]) + resp = self.client.get( + PREFIX + f'/runs/{data["run_uuid"]}/profiles') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json(), []) + + def test_submit_invalid_base64(self): + """Invalid base64 in profile -> 400.""" + resp = self.client.post( + f'/api/v5/{TS}/runs', + json={ + 'format_version': '5', + 'machine': {'name': 'bad-b64-machine'}, + 'commit': 'bad-b64-commit', + 'tests': [ + {'name': 'test.suite/bad-b64', 'profile': '!!!not-base64!!!'}, + ], + }, + headers=admin_headers(), + ) + self.assertIn(resp.status_code, (400, 422)) + + def test_profile_not_in_sample_metrics(self): + """The 'profile' key should not appear as a metric in samples.""" + run_uuid, test_name = _submit_run_with_profile( + self.client, tag='not-in-metric') + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + for sample in data['items']: + self.assertNotIn('profile', sample.get('metrics', {})) + + def test_duplicate_profile_for_same_run_test(self): + """Submitting a second run with the same commit+machine but a + different profile for the same test should create a separate profile + (different run). The unique constraint is per (run_id, test_id), + not per (commit, test).""" + suffix = uuid.uuid4().hex[:8] + machine = f'dup-machine-{suffix}' + commit = f'dup-commit-{suffix}' + test_name = f'test.suite/dup-{suffix}' + profile_b64 = make_profile_base64() + + # Two separate run submissions for the same machine+commit+test + r1 = submit_run(self.client, machine, commit, [ + {'name': test_name, 'execution_time': 1.0, 'profile': profile_b64}, + ]) + r2 = submit_run(self.client, machine, commit, [ + {'name': test_name, 'execution_time': 2.0, 'profile': profile_b64}, + ]) + + # Each run should have its own profile + resp1 = self.client.get(PREFIX + f'/runs/{r1["run_uuid"]}/profiles') + resp2 = self.client.get(PREFIX + f'/runs/{r2["run_uuid"]}/profiles') + self.assertEqual(len(resp1.get_json()), 1) + self.assertEqual(len(resp2.get_json()), 1) + # Different profile UUIDs + self.assertNotEqual( + resp1.get_json()[0]['uuid'], resp2.get_json()[0]['uuid']) + + +# --------------------------------------------------------------------------- +# Profile metadata +# --------------------------------------------------------------------------- + +class TestProfileMetadata(unittest.TestCase): + """Tests for GET /api/v5/{ts}/profiles/{uuid}.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + run_uuid, cls._test_name = _submit_run_with_profile( + cls.client, tag='metadata') + # Get the profile UUID from the listing + resp = cls.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + cls._profile_uuid = resp.get_json()[0]['uuid'] + cls._run_uuid = run_uuid + + def test_metadata(self): + """Get profile metadata with correct fields.""" + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['uuid'], self._profile_uuid) + self.assertEqual(data['test'], self._test_name) + self.assertEqual(data['run_uuid'], self._run_uuid) + self.assertIn('counters', data) + self.assertEqual(data['counters']['cycles'], 1000) + self.assertEqual(data['counters']['branch-misses'], 50) + self.assertEqual(data['disassembly_format'], 'raw') + + def test_metadata_nonexistent_uuid(self): + """Nonexistent profile UUID -> 404.""" + resp = self.client.get( + PREFIX + f'/profiles/{uuid.uuid4()}') + self.assertEqual(resp.status_code, 404) + + +# --------------------------------------------------------------------------- +# Profile functions +# --------------------------------------------------------------------------- + +class TestProfileFunctions(unittest.TestCase): + """Tests for GET /api/v5/{ts}/profiles/{uuid}/functions.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + run_uuid, _ = _submit_run_with_profile(cls.client, tag='functions') + resp = cls.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + cls._profile_uuid = resp.get_json()[0]['uuid'] + + def test_function_list(self): + """Functions endpoint returns sorted list.""" + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('functions', data) + funcs = data['functions'] + self.assertEqual(len(funcs), 2) + + fn_names = [f['name'] for f in funcs] + self.assertIn('main', fn_names) + self.assertIn('helper', fn_names) + + # Sorted by total counter value descending (main > helper) + self.assertEqual(funcs[0]['name'], 'main') + + for fn in funcs: + self.assertIn('counters', fn) + self.assertIn('length', fn) + self.assertIsInstance(fn['counters'], dict) + + +# --------------------------------------------------------------------------- +# Profile function detail +# --------------------------------------------------------------------------- + +class TestProfileFunctionDetail(unittest.TestCase): + """Tests for GET /api/v5/{ts}/profiles/{uuid}/functions/{fn_name}.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + run_uuid, _ = _submit_run_with_profile(cls.client, tag='fndetail') + resp = cls.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + cls._profile_uuid = resp.get_json()[0]['uuid'] + + def test_function_detail(self): + """Get disassembly for a specific function.""" + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions/main') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['name'], 'main') + self.assertIn('counters', data) + self.assertEqual(data['disassembly_format'], 'raw') + self.assertIn('instructions', data) + self.assertEqual(len(data['instructions']), 2) + + inst = data['instructions'][0] + self.assertIn('address', inst) + self.assertIn('counters', inst) + self.assertIn('text', inst) + self.assertEqual(inst['address'], 0x1000) + self.assertEqual(inst['text'], 'push rbp') + + def test_function_detail_nonexistent(self): + """404 for a function not in the profile.""" + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions/no_such_fn') + self.assertEqual(resp.status_code, 404) + + +# --------------------------------------------------------------------------- +# Auth +# --------------------------------------------------------------------------- + +class TestProfileAuth(unittest.TestCase): + """Auth tests for profile endpoints (all require read scope).""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + run_uuid, _ = _submit_run_with_profile(cls.client, tag='auth') + resp = cls.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + cls._profile_uuid = resp.get_json()[0]['uuid'] + cls._run_uuid = run_uuid + + def test_listing_no_auth_allowed(self): + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/profiles') + self.assertEqual(resp.status_code, 200) + + def test_metadata_read_scope(self): + headers = make_scoped_headers(self.app, 'read') + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}', + headers=headers) + self.assertEqual(resp.status_code, 200) + + def test_functions_no_auth_allowed(self): + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions') + self.assertEqual(resp.status_code, 200) + + def test_function_detail_no_auth_allowed(self): + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions/main') + self.assertEqual(resp.status_code, 200) + + +# --------------------------------------------------------------------------- +# Unknown params +# --------------------------------------------------------------------------- + +class TestProfileUnknownParams(unittest.TestCase): + """Unknown query parameters are rejected with 400.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + run_uuid, _ = _submit_run_with_profile(cls.client, tag='unkparams') + resp = cls.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + cls._profile_uuid = resp.get_json()[0]['uuid'] + cls._run_uuid = run_uuid + + def test_listing_unknown_param(self): + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/profiles?bogus=1') + self.assertEqual(resp.status_code, 400) + + def test_metadata_unknown_param(self): + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}?bogus=1') + self.assertEqual(resp.status_code, 400) + + def test_functions_unknown_param(self): + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions?bogus=1') + self.assertEqual(resp.status_code, 400) + + def test_function_detail_unknown_param(self): + resp = self.client.get( + PREFIX + f'/profiles/{self._profile_uuid}/functions/main?bogus=1') + self.assertEqual(resp.status_code, 400) + + +# --------------------------------------------------------------------------- +# Cascade delete +# --------------------------------------------------------------------------- + +class TestProfileCascadeDelete(unittest.TestCase): + """Deleting a run should cascade to its profiles.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_run_cascades_to_profiles(self): + run_uuid, _ = _submit_run_with_profile(self.client, tag='cascade') + + # Verify profile exists + resp = self.client.get(PREFIX + f'/runs/{run_uuid}/profiles') + self.assertEqual(len(resp.get_json()), 1) + profile_uuid = resp.get_json()[0]['uuid'] + + # Delete the run + resp = self.client.delete( + PREFIX + f'/runs/{run_uuid}', headers=admin_headers()) + self.assertEqual(resp.status_code, 204) + + # Profile should be gone + resp = self.client.get( + PREFIX + f'/profiles/{profile_uuid}') + self.assertEqual(resp.status_code, 404) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_query.py b/tests/server/api/v5/test_query.py new file mode 100644 index 000000000..bbef6e47e --- /dev/null +++ b/tests/server/api/v5/test_query.py @@ -0,0 +1,1629 @@ +# Tests for the v5 query endpoint (POST /api/v5/{ts}/query). +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import datetime +import random +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import create_app, create_client, set_ordinal, submit_run + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +def _setup_query_data(client, app, machine_name, test_name, num_points=5): + """Create a machine, test, and several runs with samples. + + Returns a dict with the created entities for assertions. + """ + rev_prefix = uuid.uuid4().hex[:6] + # Use a random base to avoid ordinal collisions across test classes. + ordinal_base = int(uuid.uuid4().hex[:6], 16) + run_uuids = [] + commits = [] + for i in range(num_points): + commit_str = f'{100 + i}-{rev_prefix}' + data = submit_run( + client, machine_name, commit_str, + [{'name': test_name, 'execution_time': [float(i + 1) * 1.5]}]) + run_uuids.append(data['run_uuid']) + commits.append(commit_str) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + for i, commit_str in enumerate(commits): + set_ordinal(client, commit_str, ordinal_base + i) + + # Set sequential timestamps via direct DB (no API for submitted_at) + db = app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i, run_uuid in enumerate(run_uuids): + run = ts.get_run(session, uuid=run_uuid) + run.submitted_at = datetime.datetime(2024, 1, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc) + session.commit() + session.close() + + return { + 'machine': machine_name, + 'test': test_name, + 'run_uuids': run_uuids, + 'num_points': num_points, + 'commits': commits, + } + + +class TestQueryNotFound(unittest.TestCase): + """Tests for 404 responses when entities don't exist.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_nonexistent_machine_returns_404(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': 'nonexistent-machine-xyz', + 'test': ['some_test'], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + + def test_nonexistent_test_returns_empty(self): + # Create a real machine so only the test is missing. + # With multi-value test support, unknown test names are silently + # skipped, returning an empty result set instead of 404. + unique = uuid.uuid4().hex[:8] + name = f'series-nf-test-{unique}' + submit_run(self.client, name, f'1-{unique}', + [{'name': f'dummy-{unique}', 'execution_time': [1.0]}]) + + resp = self.client.post( + PREFIX + '/query', + json={'machine': name, + 'test': ['nonexistent-test-xyz'], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_nonexistent_field_returns_400(self): + unique = uuid.uuid4().hex[:8] + mname = f'series-nf-field-m-{unique}' + tname = f'series-nf-field-t/{unique}' + submit_run(self.client, mname, f'1-{unique}', + [{'name': tname, 'execution_time': [1.0]}]) + + resp = self.client.post( + PREFIX + '/query', + json={'machine': mname, + 'test': [tname], 'metric': 'nonexistent_field'}) + self.assertEqual(resp.status_code, 400) + + +class TestQueryValidQuery(unittest.TestCase): + """Tests for valid queries that return data.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'series-valid-m-{unique}', + test_name=f'series-valid-t/{unique}', + num_points=5, + ) + + def test_returns_200(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + + def test_returns_items(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + self.assertIn('items', data) + self.assertEqual(len(data['items']), d['num_points']) + + def test_data_point_structure(self): + """Each data point must have all required fields.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + for item in data['items']: + self.assertIn('test', item) + self.assertIn('machine', item) + self.assertIn('metric', item) + self.assertIn('value', item) + self.assertIn('commit', item) + self.assertIn('run_uuid', item) + self.assertIn('submitted_at', item) + self.assertIsInstance(item['value'], (int, float)) + self.assertIsInstance(item['commit'], str) + self.assertIsInstance(item['run_uuid'], str) + self.assertEqual(item['test'], d['test']) + self.assertEqual(item['machine'], d['machine']) + self.assertEqual(item['metric'], 'execution_time') + + def test_commit_is_a_string(self): + """Commit field should be a plain string (not a dict).""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + for item in data['items']: + self.assertIsInstance(item['commit'], str) + + def test_run_uuids_are_valid(self): + """All run_uuid values should be from the runs we created.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + returned_uuids = {item['run_uuid'] for item in data['items']} + expected_uuids = set(d['run_uuids']) + self.assertEqual(returned_uuids, expected_uuids) + + def test_values_are_correct(self): + """Values should match what we inserted.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + values = sorted([item['value'] for item in data['items']]) + expected = sorted([float(i + 1) * 1.5 for i in range(d['num_points'])]) + self.assertEqual(values, expected) + + def test_cursor_envelope(self): + """Response should have cursor with next and previous.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + def test_no_auth_required_for_read(self): + """Query endpoint should work without auth (read scope).""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + + +class TestQueryEmptyResult(unittest.TestCase): + """Tests for queries that match no data.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_valid_entities_no_samples_returns_empty(self): + """When machine/test/field exist but no samples match, return empty.""" + unique = uuid.uuid4().hex[:8] + mname = f'series-empty-m-{unique}' + tname = f'series-empty-t/{unique}' + + # Create machine (and a dummy test) via submit_run, then query with + # a different test name that has no samples for this machine. + submit_run(self.client, mname, f'1-{unique}', + [{'name': f'dummy-{unique}', 'execution_time': [1.0]}]) + + resp = self.client.post( + PREFIX + '/query', + json={'machine': mname, 'test': [tname], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + self.assertIsNone(data['cursor']['next']) + + +class TestQueryOrdering(unittest.TestCase): + """Tests that data points are returned in order.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + mname = f'query-order-m-{unique}' + tname = f'query-order-t/{unique}' + + # Create orders in sequential revision order + revisions = ['100', '200', '300', '400', '500'] + rev_prefix = uuid.uuid4().hex[:6] + for rev in revisions: + submit_run( + cls.client, mname, f'{rev}-{rev_prefix}', + [{'name': tname, 'execution_time': [float(rev)]}], + ) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + for i, rev in enumerate(revisions): + commit_str = f'{rev}-{rev_prefix}' + set_ordinal(cls.client, commit_str, ordinal_base + i) + + cls._data = { + 'machine': mname, + 'test': tname, + 'expected_revisions': [f'{r}-{rev_prefix}' for r in revisions], + } + + def test_data_sorted_by_ordinal(self): + """Data points should be sorted by ordinal value.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], 'metric': 'execution_time'}) + data = resp.get_json() + ordinals = [item['ordinal'] for item in data['items']] + self.assertEqual(ordinals, sorted(ordinals)) + + +class TestQueryRangeFilters(unittest.TestCase): + """Tests for after_commit/before_commit and after_time/before_time filtering.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + mname = f'query-filter-m-{unique}' + tname = f'query-filter-t/{unique}' + + cls._commits = [] + for i in range(10): + rev = str(100 + i * 10) # 100, 110, ..., 190 + submit_run( + cls.client, mname, rev, + [{'name': tname, 'execution_time': [float(100 + i * 10)]}], + ) + cls._commits.append(rev) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + for i, commit_str in enumerate(cls._commits): + set_ordinal(cls.client, commit_str, ordinal_base + i) + + # Set sequential timestamps via direct DB (no API for submitted_at) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i, commit_str in enumerate(cls._commits): + c = ts.get_commit(session, commit=commit_str) + runs = ts.list_runs(session, commit_id=c.id) + for run in runs: + run.submitted_at = datetime.datetime(2024, 1, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc) + session.commit() + session.close() + + cls._data = { + 'machine': mname, + 'test': tname, + } + + def test_after_commit_filter(self): + """Only data points after the given commit should be returned.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'after_commit': '150'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + rev = int(item['commit']) + self.assertGreater(rev, 150) + + def test_before_commit_filter(self): + """Only data points before the given commit should be returned.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'before_commit': '150'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + rev = int(item['commit']) + self.assertLess(rev, 150) + + def test_after_commit_and_before_commit_combined(self): + """Combining after_commit and before_commit narrows the range.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'after_commit': '120', + 'before_commit': '170'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + for item in data['items']: + rev = int(item['commit']) + self.assertGreater(rev, 120) + self.assertLess(rev, 170) + self.assertGreater(len(data['items']), 0) + + def test_after_commit_nonexistent_returns_404(self): + """Filtering with a non-existent commit value returns 404.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'after_commit': '999999'}) + self.assertEqual(resp.status_code, 404) + + def test_before_commit_nonexistent_returns_404(self): + """Filtering with a non-existent commit value returns 404.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'before_commit': '999999'}) + self.assertEqual(resp.status_code, 404) + + def test_after_time_filter(self): + """Only data points from runs after the given time should be returned.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'after_time': '2024-01-06T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + self.assertGreater(item['submitted_at'], '2024-01-06T00:00:00Z') + + def test_before_time_filter(self): + """Only data points from runs before the given time should be returned.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'before_time': '2024-01-04T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + self.assertLess(item['submitted_at'], '2024-01-04T00:00:00Z') + + def test_after_time_and_before_time_combined(self): + """Combining time range filters narrows the results.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'after_time': '2024-01-03T00:00:00', + 'before_time': '2024-01-07T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + self.assertGreater(item['submitted_at'], '2024-01-03T00:00:00Z') + self.assertLess(item['submitted_at'], '2024-01-07T00:00:00Z') + + def test_commit_and_time_filters_compose(self): + """Both commit and time filters can be used together.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'after_commit': '120', + 'before_time': '2024-01-07T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + for item in data['items']: + rev = int(item['commit']) + self.assertGreater(rev, 120) + self.assertLess(item['submitted_at'], '2024-01-07T00:00:00Z') + + def test_after_time_future_returns_empty(self): + """Filtering with after_time far in the future returns 0 items.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'after_time': '2027-02-23T15:01:11'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_before_time_past_returns_empty(self): + """Filtering with before_time far in the past returns 0 items.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'before_time': '2020-01-01T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + +class TestQueryLimit(unittest.TestCase): + """Tests for the limit parameter.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'series-limit-m-{unique}', + test_name=f'series-limit-t/{unique}', + num_points=10, + ) + + def test_limit_reduces_results(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 3}) + data = resp.get_json() + self.assertEqual(len(data['items']), 3) + + def test_limit_with_next_cursor(self): + """When limit truncates results, next cursor should be set.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 3}) + data = resp.get_json() + self.assertIsNotNone(data['cursor']['next']) + + def test_limit_larger_than_data(self): + """When limit is larger than data, all data returned, no next cursor.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 500}) + data = resp.get_json() + self.assertEqual(len(data['items']), d['num_points']) + self.assertIsNone(data['cursor']['next']) + + +class TestQueryPagination(unittest.TestCase): + """Tests for cursor-based pagination.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'series-page-m-{unique}', + test_name=f'series-page-t/{unique}', + num_points=7, + ) + + def test_pagination_collects_all_items(self): + """Paginating through all pages should return all data points.""" + d = self._data + all_items = [] + params = {'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 3} + + # First page + resp = self.client.post(PREFIX + '/query', json=params) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + + # Keep fetching until no next cursor + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 10: + self.fail("Too many pages; infinite loop detected") + + self.assertEqual(len(all_items), d['num_points']) + + def test_no_duplicate_items_across_pages(self): + """Items should not appear on multiple pages.""" + d = self._data + all_uuids = [] + params = {'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 2} + + resp = self.client.post(PREFIX + '/query', json=params) + data = resp.get_json() + all_uuids.extend(item['run_uuid'] for item in data['items']) + cursor = data['cursor']['next'] + + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + data = resp.get_json() + all_uuids.extend(item['run_uuid'] for item in data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 10: + break + + # Check no duplicates + self.assertEqual(len(all_uuids), len(set(all_uuids))) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', + 'cursor': 'not-a-valid-cursor!!!'}) + self.assertEqual(resp.status_code, 400) + + +def _setup_multi_test_data(client, app, machine_name, test_names, num_orders=5): + """Create a machine, multiple tests, and samples for each.""" + base = random.randint(100000, 999000) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + commits = [] + for i in range(num_orders): + tests = [ + {'name': tn, 'execution_time': [float((i + 1) * 10 + j)]} + for j, tn in enumerate(test_names) + ] + commit_str = str(base + i) + submit_run( + client, machine_name, commit_str, + tests, + ) + commits.append(commit_str) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + for i, commit_str in enumerate(commits): + set_ordinal(client, commit_str, ordinal_base + i) + + # Set sequential timestamps via direct DB (no API for submitted_at) + db = app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i, commit_str in enumerate(commits): + c = ts.get_commit(session, commit=commit_str) + runs = ts.list_runs(session, commit_id=c.id) + for run in runs: + run.submitted_at = datetime.datetime(2024, 6, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc) + session.commit() + session.close() + + return { + 'machine': machine_name, + 'test_names': test_names, + 'num_orders': num_orders, + 'commits': commits, + } + + +class TestQueryOptionalParams(unittest.TestCase): + """Verify each filter parameter is independently optional.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_multi_test_data( + cls.client, + cls.app, + machine_name=f'query-opt-m-{unique}', + test_names=[f'query-opt-t1/{unique}', + f'query-opt-t2/{unique}', + f'query-opt-t3/{unique}'], + num_orders=3, + ) + + def test_omitting_test_returns_all_tests(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + returned_tests = {item['test'] for item in data['items']} + self.assertEqual(returned_tests, set(d['test_names'])) + + def test_omitting_machine_returns_data(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'test': [d['test_names'][0]], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + + def test_omitting_field_returns_422(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test_names'][0]]}) + self.assertEqual(resp.status_code, 422) + + def test_omitting_all_params_returns_422(self): + resp = self.client.post(PREFIX + '/query', json={}) + self.assertEqual(resp.status_code, 422) + + def test_nonexistent_machine_returns_404(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': 'nonexistent-machine-xyz', + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 404) + + def test_nonexistent_test_returns_empty(self): + resp = self.client.post( + PREFIX + '/query', + json={'test': ['nonexistent-test-xyz'], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_nonexistent_field_returns_400(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'nonexistent_field'}) + self.assertEqual(resp.status_code, 400) + + +class TestQueryResponseShape(unittest.TestCase): + """Response shape is always the same regardless of filters.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_multi_test_data( + cls.client, + cls.app, + machine_name=f'query-shape-m-{unique}', + test_names=[f'query-shape-t/{unique}'], + num_orders=3, + ) + + def _assert_item_shape(self, item): + """Assert a single item has all required fields.""" + for key in ('test', 'machine', 'metric', + 'value', 'commit', 'run_uuid', 'submitted_at', 'tag'): + self.assertIn(key, item, f"Missing key: {key}") + + def test_items_with_all_filters(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test_names'][0]], + 'metric': 'execution_time'}) + data = resp.get_json() + for item in data['items']: + self._assert_item_shape(item) + + def test_items_with_no_filters_returns_422(self): + resp = self.client.post(PREFIX + '/query', json={}) + self.assertEqual(resp.status_code, 422) + + def test_items_with_only_machine_returns_422(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine']}) + self.assertEqual(resp.status_code, 422) + + +class TestQueryMultiTestPagination(unittest.TestCase): + """Pagination across multiple tests.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_multi_test_data( + cls.client, + cls.app, + machine_name=f'query-mtp-m-{unique}', + test_names=[f'query-mtp-t1/{unique}', + f'query-mtp-t2/{unique}', + f'query-mtp-t3/{unique}'], + num_orders=5, + ) + + def test_pagination_collects_all_items(self): + """Paginating should return all 3 tests * 5 orders = 15 items.""" + d = self._data + all_items = [] + params = {'machine': d['machine'], + 'metric': 'execution_time', 'limit': 4} + + resp = self.client.post(PREFIX + '/query', json=params) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 20: + self.fail("Too many pages; infinite loop detected") + + expected = len(d['test_names']) * d['num_orders'] + self.assertEqual(len(all_items), expected) + + def test_no_duplicates_across_pages(self): + d = self._data + all_keys = [] + params = {'machine': d['machine'], + 'metric': 'execution_time', 'limit': 4} + + resp = self.client.post(PREFIX + '/query', json=params) + data = resp.get_json() + all_keys.extend( + (item['test'], item['commit']) + for item in data['items']) + cursor = data['cursor']['next'] + + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + data = resp.get_json() + all_keys.extend( + (item['test'], item['commit']) + for item in data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 20: + break + + self.assertEqual(len(all_keys), len(set(all_keys))) + + +class TestQuerySort(unittest.TestCase): + """Tests for the sort parameter.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_multi_test_data( + cls.client, + cls.app, + machine_name=f'query-sort-m-{unique}', + test_names=[f'query-sort-a/{unique}', + f'query-sort-b/{unique}', + f'query-sort-c/{unique}'], + num_orders=3, + ) + + def test_sort_by_test_commit(self): + """sort=test,commit groups results by test name.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], + 'metric': 'execution_time', 'sort': 'test,commit'}) + data = resp.get_json() + test_names = [item['test'] for item in data['items']] + self.assertEqual(test_names, sorted(test_names)) + + def test_sort_by_commit_test(self): + """sort=commit,test is the default ordering.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], + 'metric': 'execution_time', 'sort': 'commit,test'}) + data = resp.get_json() + # Items should be grouped by commit + self.assertGreater(len(data['items']), 0) + + def test_sort_descending(self): + """-commit,test returns newest commits first.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test_names'][0]], + 'metric': 'execution_time', 'sort': '-commit'}) + data = resp.get_json() + commits = [ + item['commit'] + for item in data['items'] + ] + self.assertEqual(commits, sorted(commits, reverse=True)) + + def test_sort_invalid_field_returns_400(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'sort': 'invalid_field'}) + self.assertEqual(resp.status_code, 400) + + def test_sort_with_pagination(self): + """Sort order is preserved across cursor pages.""" + d = self._data + all_test_names = [] + params = {'machine': d['machine'], + 'metric': 'execution_time', 'sort': 'test,commit', + 'limit': 4} + + resp = self.client.post(PREFIX + '/query', json=params) + data = resp.get_json() + all_test_names.extend(item['test'] for item in data['items']) + cursor = data['cursor']['next'] + + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + data = resp.get_json() + all_test_names.extend(item['test'] for item in data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 20: + break + + self.assertEqual(all_test_names, sorted(all_test_names)) + + +class TestQueryCursorMixedAscDesc(unittest.TestCase): + """Test cursor pagination with mixed ascending/descending sort.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_multi_test_data( + cls.client, + cls.app, + machine_name=f'query-mixed-m-{unique}', + test_names=[f'query-mixed-a/{unique}', + f'query-mixed-b/{unique}'], + num_orders=5, + ) + + def test_desc_commit_pagination_collects_all(self): + """Paginating with -commit,test collects all items.""" + d = self._data + all_items = [] + params = {'machine': d['machine'], + 'metric': 'execution_time', 'sort': '-commit,test', + 'limit': 3} + resp = self.client.post(PREFIX + '/query', json=params) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 20: + self.fail("Too many pages") + expected = len(d['test_names']) * d['num_orders'] + self.assertEqual(len(all_items), expected) + + def test_desc_commit_pagination_no_duplicates(self): + """No duplicates across pages with -commit,test.""" + d = self._data + all_keys = [] + params = {'machine': d['machine'], + 'metric': 'execution_time', 'sort': '-commit,test', + 'limit': 3} + resp = self.client.post(PREFIX + '/query', json=params) + data = resp.get_json() + all_keys.extend( + (item['test'], item['commit']) + for item in data['items']) + cursor = data['cursor']['next'] + pages = 1 + while cursor: + resp = self.client.post( + PREFIX + '/query', json={**params, 'cursor': cursor}) + data = resp.get_json() + all_keys.extend( + (item['test'], item['commit']) + for item in data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > 20: + break + self.assertEqual(len(all_keys), len(set(all_keys))) + + def test_desc_commit_is_actually_descending(self): + """Results with -commit are in descending order.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test_names'][0]], + 'metric': 'execution_time', 'sort': '-commit'}) + data = resp.get_json() + commits = [ + int(item['commit']) + for item in data['items'] + ] + self.assertEqual(commits, sorted(commits, reverse=True)) + + +class TestQueryMalformedTimestamp(unittest.TestCase): + """Test error handling for malformed timestamp filters.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_malformed_after_time_returns_400(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'after_time': 'not-a-date'}) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('error', data) + + def test_malformed_before_time_returns_400(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'before_time': 'yesterday'}) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('error', data) + + +class TestQueryMetricRequired(unittest.TestCase): + """Test that omitting the metric parameter returns 422.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + mname = f'query-mreq-m-{unique}' + tname = f'query-mreq-t/{unique}' + rev_prefix = uuid.uuid4().hex[:6] + commits = [] + for i in range(3): + commit_str = f'{2000 + i}-{rev_prefix}' + submit_run( + cls.client, mname, commit_str, + [{'name': tname, 'execution_time': [float(i + 1)]}], + ) + commits.append(commit_str) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + for i, cs in enumerate(commits): + set_ordinal(cls.client, cs, ordinal_base + i) + cls._data = {'machine': mname, 'test': tname} + + def test_omitting_metric_returns_422(self): + """Omitting the metric parameter should return 422.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'limit': 2}) + self.assertEqual(resp.status_code, 422) + + def test_with_metric_returns_200(self): + """Providing metric should return 200 with data.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 2}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + + +class TestQueryOrderRangeBoundaries(unittest.TestCase): + """Test boundary conditions for commit range filters.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + mname = f'query-orb-m-{unique}' + tname = f'query-orb-t/{unique}' + cls._commits = [] + for i in range(5): + rev = str(3000 + i * 10) # 3000, 3010, 3020, 3030, 3040 + submit_run( + cls.client, mname, rev, + [{'name': tname, 'execution_time': [float(3000 + i * 10)]}], + ) + cls._commits.append(rev) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + for i, commit_str in enumerate(cls._commits): + set_ordinal(cls.client, commit_str, ordinal_base + i) + + # Set sequential timestamps via direct DB (no API for submitted_at) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + for i, commit_str in enumerate(cls._commits): + c = ts.get_commit(session, commit=commit_str) + runs = ts.list_runs(session, commit_id=c.id) + for run in runs: + run.submitted_at = datetime.datetime(2024, 7, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc) + session.commit() + session.close() + + cls._data = {'machine': mname, 'test': tname} + + def test_same_after_and_before_commit_returns_empty(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'after_commit': '3020', + 'before_commit': '3020'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_inverted_commit_range_returns_empty(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'after_commit': '3040', + 'before_commit': '3000'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_exact_commit_filter(self): + """The commit param returns data at exactly that commit.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'commit': '3020'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + self.assertEqual(data['items'][0]['commit'], '3020') + + def test_exact_commit_nonexistent_returns_404(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'commit': '999999'}) + self.assertEqual(resp.status_code, 404) + + def test_commit_with_after_commit_returns_400(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'commit': '3020', + 'after_commit': '3000'}) + self.assertEqual(resp.status_code, 400) + + def test_commit_with_before_commit_returns_400(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'commit': '3020', + 'before_commit': '3040'}) + self.assertEqual(resp.status_code, 400) + + def test_exact_commit_with_time_filter(self): + """The commit param can be combined with time filters.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'commit': '3020', + 'after_time': '2024-07-01T00:00:00', + 'before_time': '2024-07-10T00:00:00'}) + self.assertEqual(resp.status_code, 200, resp.get_json()) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + def test_exact_commit_no_samples_for_machine(self): + """Commit exists but has no data for the given machine -- 200 empty.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': 'nonexistent-machine-' + d['machine'], + 'test': [d['test']], 'metric': 'execution_time', + 'commit': '3020'}) + # Machine doesn't exist -> 404 from _resolve_machine + self.assertEqual(resp.status_code, 404) + + +class TestQueryLimitBoundaries(unittest.TestCase): + """Test boundary conditions for the limit parameter.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'query-limb-m-{unique}', + test_name=f'query-limb-t/{unique}', + num_points=5, + ) + + def test_limit_one_returns_one_item(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 1}) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + self.assertIsNotNone(data['cursor']['next']) + + def test_limit_exceeding_max_is_clamped(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 99999}) + self.assertEqual(resp.status_code, 200) + # Should not error; returns all 5 items (clamped limit > data size) + data = resp.get_json() + self.assertEqual(len(data['items']), d['num_points']) + + def test_limit_non_integer_uses_default(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 'abc'}) + # Marshmallow validates limit as Integer; non-integer values -> 422 + self.assertEqual(resp.status_code, 422) + + def test_limit_zero_is_clamped_to_one(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 0}) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + +class TestQueryCursorEdgeCases(unittest.TestCase): + """Test cursor edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'query-cec-m-{unique}', + test_name=f'query-cec-t/{unique}', + num_points=3, + ) + + def test_cursor_wrong_field_count_returns_400(self): + """Cursor with wrong number of fields should be rejected.""" + import base64 + import json + # Default sort is commit,test -> 2 fields. Encode 3 fields. + bad_cursor = base64.urlsafe_b64encode( + json.dumps([1, "x", "extra"]).encode()).decode() + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'cursor': bad_cursor}) + self.assertEqual(resp.status_code, 400) + + def test_cursor_from_different_sort_order_is_rejected(self): + """Cursor from sort=commit,test used with sort=test,commit should fail + gracefully since the cursor values don't match the sort columns.""" + d = self._data + # Get cursor from sort=commit,test (default) + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'limit': 1}) + cursor = resp.get_json()['cursor']['next'] + # Use with sort=test,commit -- mismatched cursor + resp2 = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'sort': 'test,commit', + 'cursor': cursor}) + self.assertEqual(resp2.status_code, 400) + + +class TestQuerySortValidation(unittest.TestCase): + """Test sort parameter validation edge cases.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_sort_duplicate_field_is_deduplicated(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'sort': 'commit,commit,test'}) + self.assertEqual(resp.status_code, 200) + + def test_sort_empty_string_uses_default(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'sort': ''}) + # Empty sort string should use default (commit,test) + self.assertEqual(resp.status_code, 200) + + def test_sort_dash_invalid_field_returns_400(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'sort': '-bogus'}) + self.assertEqual(resp.status_code, 400) + + +class TestQueryErrorResponseFormat(unittest.TestCase): + """Test that all error responses use the standard JSON format.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _assert_error_format(self, resp): + """Assert the response body has the standard error format.""" + data = resp.get_json() + self.assertIsNotNone(data, "Response body should be JSON") + self.assertIn('error', data) + self.assertIn('code', data['error']) + self.assertIn('message', data['error']) + + def test_404_nonexistent_machine_has_error_format(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': 'nonexistent-xyz', 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 404) + self._assert_error_format(resp) + + def test_nonexistent_test_returns_empty(self): + """Unknown test names are silently skipped, returning empty results.""" + resp = self.client.post( + PREFIX + '/query', + json={'test': ['nonexistent-xyz'], 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_400_nonexistent_field_has_error_format(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'nonexistent_xyz'}) + self.assertEqual(resp.status_code, 400) + self._assert_error_format(resp) + + def test_400_invalid_sort_has_error_format(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'sort': 'invalid'}) + self.assertEqual(resp.status_code, 400) + self._assert_error_format(resp) + + def test_400_invalid_cursor_has_error_format(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', + 'cursor': '!!!invalid!!!'}) + self.assertEqual(resp.status_code, 400) + self._assert_error_format(resp) + + def test_400_malformed_time_has_error_format(self): + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'after_time': 'not-a-date'}) + self.assertEqual(resp.status_code, 400) + self._assert_error_format(resp) + + +class TestQueryNoInternalFieldsLeak(unittest.TestCase): + """Test that no internal fields (starting with _) leak in response.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'query-noleak-m-{unique}', + test_name=f'query-noleak-t/{unique}', + num_points=3, + ) + + def test_no_underscore_prefixed_fields(self): + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time'}) + data = resp.get_json() + for item in data['items']: + internal_keys = [k for k in item.keys() if k.startswith('_')] + self.assertEqual(internal_keys, [], + f"Internal fields leaked: {internal_keys}") + + +class TestQueryUnknownParameters(unittest.TestCase): + """Test that unknown query parameters are rejected with 422.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + unique = uuid.uuid4().hex[:8] + cls._data = _setup_query_data( + cls.client, + cls.app, + machine_name=f'query-unknown-m-{unique}', + test_name=f'query-unknown-t/{unique}', + num_points=3, + ) + + def test_single_unknown_param_returns_422(self): + """A single unknown parameter should be rejected.""" + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'bogus': 'value'}) + self.assertEqual(resp.status_code, 422) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn('bogus', data['error']['message']) + + def test_multiple_unknown_params_returns_422(self): + """Multiple unknown parameters should all be mentioned.""" + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', + 'metric_name': 'execution_time', + 'after_timestamp': '2027-02-23T15:01:11'}) + self.assertEqual(resp.status_code, 422) + data = resp.get_json() + self.assertIn('metric_name', data['error']['message']) + self.assertIn('after_timestamp', data['error']['message']) + + def test_unknown_mixed_with_valid_returns_422(self): + """Unknown params mixed with valid ones should still be rejected.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], + 'metric': 'execution_time', 'bad_param': '1'}) + self.assertEqual(resp.status_code, 422) + data = resp.get_json() + self.assertIn('bad_param', data['error']['message']) + + def test_error_message_mentions_unknown_field(self): + """The error message should mention the unknown field name.""" + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'bogus': 1}) + data = resp.get_json() + msg = data['error']['message'] + self.assertIn('bogus', msg) + + def test_error_response_has_standard_format(self): + """Unknown param error should use the standard error format.""" + resp = self.client.post( + PREFIX + '/query', + json={'metric': 'execution_time', 'unknown': 1}) + self.assertEqual(resp.status_code, 422) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn('code', data['error']) + self.assertIn('message', data['error']) + self.assertEqual(data['error']['code'], 'validation_error') + + def test_valid_params_still_work(self): + """All valid parameters should still be accepted.""" + d = self._data + resp = self.client.post( + PREFIX + '/query', + json={'machine': d['machine'], 'test': [d['test']], + 'metric': 'execution_time', 'sort': 'commit', + 'limit': 10}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertEqual(len(data['items']), d['num_points']) + + def test_no_params_returns_422(self): + """Query with no parameters should return 422 (metric is required).""" + resp = self.client.post(PREFIX + '/query', json={}) + self.assertEqual(resp.status_code, 422) + + +class TestQueryMultiValueTest(unittest.TestCase): + """Tests for multi-value test= parameter (disjunction).""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._prefix = uuid.uuid4().hex[:8] + rev_prefix = uuid.uuid4().hex[:6] + + cls.machine_name = f'mv-m-{cls._prefix}' + cls.test_a = f'mv-test-alpha-{cls._prefix}' + cls.test_b = f'mv-test-beta-{cls._prefix}' + cls.test_c = f'mv-test-gamma-{cls._prefix}' + + for i, tname in enumerate([cls.test_a, cls.test_b, cls.test_c]): + submit_run( + cls.client, cls.machine_name, f'{500 + i}-{rev_prefix}', + [{'name': tname, 'execution_time': [float(i + 1) * 2.0]}], + ) + + # Assign ordinals via API (D11: ordinals set exclusively via PATCH) + ordinal_base = int(uuid.uuid4().hex[:6], 16) + for i in range(3): + set_ordinal(cls.client, f'{500 + i}-{rev_prefix}', + ordinal_base + i) + + def test_single_test_param_returns_only_that_test(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, 'test': [self.test_a], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + tests = {item['test'] for item in data['items']} + self.assertEqual(tests, {self.test_a}) + + def test_two_test_params_returns_both(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, + 'test': [self.test_a, self.test_b], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + tests = {item['test'] for item in data['items']} + self.assertEqual(tests, {self.test_a, self.test_b}) + + def test_three_test_params_returns_all_three(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, + 'test': [self.test_a, self.test_b, self.test_c], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + tests = {item['test'] for item in data['items']} + self.assertEqual(tests, {self.test_a, self.test_b, self.test_c}) + + def test_unknown_test_names_silently_skipped(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, + 'test': [self.test_a, 'nonexistent-xyz-999'], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + tests = {item['test'] for item in data['items']} + self.assertEqual(tests, {self.test_a}) + + def test_all_unknown_returns_empty(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, + 'test': ['nonexistent-1', 'nonexistent-2'], + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_no_test_param_returns_all_tests(self): + resp = self.client.post( + PREFIX + '/query', + json={'machine': self.machine_name, + 'metric': 'execution_time'}) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + tests = {item['test'] for item in data['items']} + self.assertTrue( + {self.test_a, self.test_b, self.test_c}.issubset(tests)) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_regression_state_mapping.py b/tests/server/api/v5/test_regression_state_mapping.py new file mode 100644 index 000000000..e1b2d9472 --- /dev/null +++ b/tests/server/api/v5/test_regression_state_mapping.py @@ -0,0 +1,69 @@ +# Unit tests for the regression state mapping helpers (state_to_api / state_to_db). +# These are pure-function tests that do not require a database. +# +# RUN: python %s +# END. + +import sys +import unittest + +# Ensure the project root is importable. +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', '..', '..', '..')) + +from lnt.server.api.v5.schemas.regressions import ( + STATE_TO_DB, + state_to_api, + state_to_db, +) + + +class TestStateToApi(unittest.TestCase): + """Tests for state_to_api().""" + + def test_all_known_states_round_trip(self): + """Every known DB integer maps to its expected API string.""" + for api_string, db_int in STATE_TO_DB.items(): + with self.subTest(db_int=db_int, expected=api_string): + self.assertEqual(state_to_api(db_int), api_string) + + def test_unknown_int_returns_unknown_prefix(self): + """An unmapped integer returns 'unknown_' instead of 'detected'.""" + result = state_to_api(999) + self.assertEqual(result, 'unknown_999') + + def test_unknown_negative_int(self): + result = state_to_api(-1) + self.assertEqual(result, 'unknown_-1') + + def test_unknown_none_value(self): + """None is not a valid DB state and should be flagged.""" + result = state_to_api(None) + self.assertEqual(result, 'unknown_None') + + def test_unknown_state_logs_warning(self): + """A warning should be logged when an unknown state is encountered.""" + with self.assertLogs( + 'lnt.server.api.v5.schemas.regressions', level='WARNING' + ) as cm: + state_to_api(999) + self.assertTrue( + any('999' in msg for msg in cm.output), + f"Expected '999' in log output, got: {cm.output}", + ) + + +class TestStateToDb(unittest.TestCase): + """Tests for state_to_db().""" + + def test_all_known_strings_round_trip(self): + for api_string, db_int in STATE_TO_DB.items(): + with self.subTest(api_string=api_string, expected=db_int): + self.assertEqual(state_to_db(api_string), db_int) + + def test_unknown_string_returns_none(self): + self.assertIsNone(state_to_db('bogus_state')) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_regressions.py b/tests/server/api/v5/test_regressions.py new file mode 100644 index 000000000..0974677a6 --- /dev/null +++ b/tests/server/api/v5/test_regressions.py @@ -0,0 +1,1284 @@ +# Tests for the v5 regression endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, make_scoped_headers, + collect_all_pages, submit_run, submit_regression, +) + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +def _triage_headers(app): + return make_scoped_headers(app, 'triage') + + +def _setup_regression_with_indicators(client, num_indicators=2, + state='active', commit=None): + """Create a regression with indicators via the API. + + Returns (regression_uuid, [indicator_uuid, ...]). + """ + tag = uuid.uuid4().hex[:8] + machine = f'reg-m-{tag}' + tests = [f'reg/test/{tag}/{i}' for i in range(num_indicators)] + + # Ensure machine and tests exist by submitting a run + submit_run(client, machine, f'reg-rev-{tag}', + [{'name': t, 'execution_time': [1.0 + i]} + for i, t in enumerate(tests)]) + + indicators = [ + {'machine': machine, 'test': t, 'metric': 'execution_time'} + for t in tests + ] + reg = submit_regression(client, indicators=indicators, + state=state, commit=commit) + indicator_uuids = [ind['uuid'] for ind in reg['indicators']] + return reg['uuid'], indicator_uuids + + +# ========================================================================== +# Regression List Tests +# ========================================================================== + +def _find_in_list(items, uuid): + """Find an item by UUID in a list response's items array.""" + for r in items: + if r['uuid'] == uuid: + return r + return None + + +class TestRegressionList(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_returns_200_with_envelope(self): + resp = self.client.get(PREFIX + '/regressions') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + def test_list_item_has_expected_fields(self): + reg_uuid, _ = _setup_regression_with_indicators(self.client, 1) + resp = self.client.get(PREFIX + '/regressions?limit=500') + data = resp.get_json() + item = _find_in_list(data['items'], reg_uuid) + self.assertIsNotNone(item) + self.assertIn('uuid', item) + self.assertIn('title', item) + self.assertIn('bug', item) + self.assertIn('state', item) + self.assertIn('commit', item) + self.assertIn('machine_count', item) + self.assertIn('test_count', item) + # List items should NOT have indicators embedded + self.assertNotIn('indicators', item) + + def test_list_item_machine_and_test_counts(self): + """Create a regression with 2 indicators (1 machine, 2 tests). + Verify machine_count == 1 and test_count == 2.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 2) + resp = self.client.get(PREFIX + '/regressions?limit=500') + data = resp.get_json() + item = _find_in_list(data['items'], reg_uuid) + self.assertIsNotNone(item) + self.assertEqual(item['machine_count'], 1) + self.assertEqual(item['test_count'], 2) + + def test_list_filter_by_state(self): + _setup_regression_with_indicators(self.client, 1) + resp = self.client.get(PREFIX + '/regressions?state=active') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + for r in data['items']: + self.assertEqual(r['state'], 'active') + + def test_list_filter_by_state_multiple(self): + tag = uuid.uuid4().hex[:8] + machine = f'state-m-{tag}' + test1 = f'state-test/{tag}/1' + test2 = f'state-test/{tag}/2' + + submit_run(self.client, machine, f'state-rev-{tag}', [ + {'name': test1, 'execution_time': [1.0]}, + {'name': test2, 'execution_time': [1.0]}, + ]) + + submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test1, + 'metric': 'execution_time'}], + state='active') + submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test2, + 'metric': 'execution_time'}], + state='detected') + + resp = self.client.get( + PREFIX + '/regressions?state=active,detected') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + states = {r['state'] for r in data['items']} + self.assertTrue(states.issubset({'active', 'detected'})) + + def test_list_filter_invalid_state_400(self): + resp = self.client.get(PREFIX + '/regressions?state=invalid_state') + self.assertEqual(resp.status_code, 400) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + resp = self.client.get( + PREFIX + '/regressions?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + def test_list_pagination(self): + # Create 3 regressions + for _ in range(3): + _setup_regression_with_indicators(self.client, 1) + resp = self.client.get(PREFIX + '/regressions?limit=2') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertLessEqual(len(data['items']), 2) + if data['cursor']['next']: + cursor = data['cursor']['next'] + resp2 = self.client.get( + PREFIX + f'/regressions?limit=2&cursor={cursor}') + self.assertEqual(resp2.status_code, 200) + + +# ========================================================================== +# Regression List Filter Tests +# ========================================================================== + +class TestRegressionListFilters(unittest.TestCase): + """Tests for machine, test, metric, commit, and has_commit filters.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _collect_filtered(self, query_string): + """Collect all regression UUIDs across pages for a filtered query.""" + url = PREFIX + '/regressions?' + query_string + '&limit=2' + items = collect_all_pages(self, self.client, url, page_limit=100) + return [r['uuid'] for r in items] + + def test_list_filter_by_machine(self): + """Filter by machine name returns only regressions on that machine.""" + tag = uuid.uuid4().hex[:8] + machine_name = f'filter-m-{tag}' + test_name = f'filter/test/{tag}' + + submit_run(self.client, machine_name, f'filter-r1-{tag}', + [{'name': test_name, 'execution_time': [1.0]}]) + + reg = submit_regression( + self.client, + indicators=[{'machine': machine_name, 'test': test_name, + 'metric': 'execution_time'}]) + + uuids = self._collect_filtered(f'machine={machine_name}') + self.assertIn(reg['uuid'], uuids) + + def test_list_filter_by_test(self): + """Filter by test name returns only regressions on that test.""" + tag = uuid.uuid4().hex[:8] + machine_name = f'filter-tm-{tag}' + test_name = f'filter/testname/{tag}' + + submit_run(self.client, machine_name, f'filter-tr1-{tag}', + [{'name': test_name, 'execution_time': [1.0]}]) + + reg = submit_regression( + self.client, + indicators=[{'machine': machine_name, 'test': test_name, + 'metric': 'execution_time'}]) + + uuids = self._collect_filtered(f'test={test_name}') + self.assertIn(reg['uuid'], uuids) + + def test_list_filter_by_metric(self): + """Filter by metric returns only regressions with that metric.""" + tag = uuid.uuid4().hex[:8] + machine_name = f'filter-mm-{tag}' + test_ct = f'filter/compile/{tag}' + test_et = f'filter/exec/{tag}' + + # Submit runs with both metrics + submit_run(self.client, machine_name, f'filter-mr1-{tag}', [ + {'name': test_ct, 'compile_time': [5.0]}, + {'name': test_et, 'execution_time': [1.0]}, + ]) + + # Create regression for compile_time + reg_ct = submit_regression( + self.client, + indicators=[{'machine': machine_name, 'test': test_ct, + 'metric': 'compile_time'}]) + + # Create regression for execution_time + reg_et = submit_regression( + self.client, + indicators=[{'machine': machine_name, 'test': test_et, + 'metric': 'execution_time'}]) + + # Filter by execution_time -- should include reg_et, exclude reg_ct + uuids = self._collect_filtered('metric=execution_time') + self.assertIn(reg_et['uuid'], uuids) + self.assertNotIn(reg_ct['uuid'], uuids) + + def test_list_filter_by_metric_unknown_returns_400(self): + """Filtering by a nonexistent metric returns 400.""" + resp = self.client.get( + PREFIX + '/regressions?metric=nonexistent_metric') + self.assertEqual(resp.status_code, 400) + + def test_list_filter_nonexistent_machine_404(self): + """Filtering by a nonexistent machine name returns 404.""" + resp = self.client.get( + PREFIX + '/regressions?machine=no-such-machine-xyz') + self.assertEqual(resp.status_code, 404) + + def test_list_filter_nonexistent_test_404(self): + """Filtering by a nonexistent test name returns 404.""" + resp = self.client.get( + PREFIX + '/regressions?test=no/such/test/xyz') + self.assertEqual(resp.status_code, 404) + + def test_list_filter_combined(self): + """Combined machine + test + metric filter narrows results.""" + tag = uuid.uuid4().hex[:8] + machine_name = f'filter-cm-{tag}' + test_name = f'filter/combined/{tag}' + + submit_run(self.client, machine_name, f'filter-cr1-{tag}', + [{'name': test_name, 'execution_time': [1.0]}]) + + reg = submit_regression( + self.client, + indicators=[{'machine': machine_name, 'test': test_name, + 'metric': 'execution_time'}]) + + uuids = self._collect_filtered( + f'machine={machine_name}&test={test_name}' + f'&metric=execution_time') + self.assertIn(reg['uuid'], uuids) + + def test_list_filter_by_commit(self): + """Filter by commit returns only regressions with that commit.""" + tag = uuid.uuid4().hex[:8] + machine = f'fc-m-{tag}' + test = f'fc/test/{tag}' + rev1 = f'fc-rev1-{tag}' + rev2 = f'fc-rev2-{tag}' + + submit_run(self.client, machine, rev1, + [{'name': test, 'execution_time': [1.0]}]) + submit_run(self.client, machine, rev2, + [{'name': test, 'execution_time': [2.0]}]) + + reg1 = submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test, + 'metric': 'execution_time'}], + commit=rev1) + reg2 = submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test, + 'metric': 'execution_time'}], + commit=rev2) + + uuids = self._collect_filtered(f'commit={rev1}') + self.assertIn(reg1['uuid'], uuids) + self.assertNotIn(reg2['uuid'], uuids) + + def test_list_filter_by_has_commit(self): + """has_commit=true/false filters correctly.""" + tag = uuid.uuid4().hex[:8] + machine = f'hc-m-{tag}' + test1 = f'hc/test1/{tag}' + test2 = f'hc/test2/{tag}' + rev = f'hc-rev-{tag}' + + submit_run(self.client, machine, rev, [ + {'name': test1, 'execution_time': [1.0]}, + {'name': test2, 'execution_time': [1.0]}, + ]) + + reg_with = submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test1, + 'metric': 'execution_time'}], + commit=rev) + reg_without = submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test2, + 'metric': 'execution_time'}]) + + for filter_val, in_with, in_without in [ + ('true', True, False), + ('false', False, True), + ]: + with self.subTest(has_commit=filter_val): + uuids = self._collect_filtered(f'has_commit={filter_val}') + check = self.assertIn if in_with else self.assertNotIn + check(reg_with['uuid'], uuids) + check = self.assertIn if in_without else self.assertNotIn + check(reg_without['uuid'], uuids) + + def test_list_item_commit_value(self): + """List item contains the commit string value.""" + tag = uuid.uuid4().hex[:8] + machine = f'lcv-m-{tag}' + test = f'lcv/test/{tag}' + rev = f'lcv-rev-{tag}' + + submit_run(self.client, machine, rev, + [{'name': test, 'execution_time': [1.0]}]) + + reg = submit_regression( + self.client, + indicators=[{'machine': machine, 'test': test, + 'metric': 'execution_time'}], + commit=rev) + + resp = self.client.get(PREFIX + '/regressions?limit=500') + data = resp.get_json() + item = _find_in_list(data['items'], reg['uuid']) + self.assertIsNotNone(item) + self.assertEqual(item['commit'], rev) + + +# ========================================================================== +# Regression Create Tests +# ========================================================================== + +class TestRegressionCreate(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _setup_machine_and_test(self): + """Create machine and test via a run, return (machine_name, test_name).""" + tag = uuid.uuid4().hex[:8] + machine = f'cr-m-{tag}' + test = f'cr/test/{tag}' + submit_run(self.client, machine, f'cr-rev-{tag}', + [{'name': test, 'execution_time': [1.0]}]) + return machine, test + + def test_create_regression(self): + machine, test = self._setup_machine_and_test() + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'indicators': [ + {'machine': machine, 'test': test, 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIn('uuid', data) + self.assertIn('indicators', data) + self.assertEqual(len(data['indicators']), 1) + ind = data['indicators'][0] + self.assertIn('uuid', ind) + self.assertEqual(ind['machine'], machine) + self.assertEqual(ind['test'], test) + self.assertEqual(ind['metric'], 'execution_time') + + def test_create_with_custom_title(self): + machine, test = self._setup_machine_and_test() + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={ + 'indicators': [ + {'machine': machine, 'test': test, + 'metric': 'execution_time'} + ], + 'title': 'Custom Title', + }, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['title'], 'Custom Title') + + def test_create_with_state(self): + machine, test = self._setup_machine_and_test() + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={ + 'indicators': [ + {'machine': machine, 'test': test, + 'metric': 'execution_time'} + ], + 'state': 'active', + }, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['state'], 'active') + + def test_create_default_state_detected(self): + machine, test = self._setup_machine_and_test() + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'indicators': [ + {'machine': machine, 'test': test, 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['state'], 'detected') + + def test_create_empty_body_succeeds(self): + """Empty body (no indicators) should succeed with NULL title.""" + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIn('uuid', data) + self.assertIsNone(data['title']) + self.assertEqual(len(data['indicators']), 0) + + def test_create_with_explicit_title(self): + """Providing a title stores it.""" + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'title': 'My regression'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['title'], 'My regression') + + def test_create_with_commit(self): + """Create a regression with a commit field.""" + tag = uuid.uuid4().hex[:8] + machine = f'cc-m-{tag}' + test = f'cc/test/{tag}' + rev = f'cc-rev-{tag}' + submit_run(self.client, machine, rev, + [{'name': test, 'execution_time': [1.0]}]) + + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={ + 'indicators': [ + {'machine': machine, 'test': test, + 'metric': 'execution_time'} + ], + 'commit': rev, + }, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['commit'], rev) + + def test_create_with_notes(self): + """Create a regression with notes field.""" + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'notes': 'Investigation notes here'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['notes'], 'Investigation notes here') + + def test_create_nonexistent_machine_404(self): + """Indicator referencing a nonexistent machine returns 404.""" + tag = uuid.uuid4().hex[:8] + # Create a test but not a machine + machine_ok = f'cnm-m-{tag}' + test_name = f'cnm/test/{tag}' + submit_run(self.client, machine_ok, f'cnm-rev-{tag}', + [{'name': test_name, 'execution_time': [1.0]}]) + + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'indicators': [ + {'machine': 'nonexistent-machine-xyz', + 'test': test_name, 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_create_nonexistent_test_404(self): + """Indicator referencing a nonexistent test returns 404.""" + tag = uuid.uuid4().hex[:8] + machine = f'cnt-m-{tag}' + submit_run(self.client, machine, f'cnt-rev-{tag}', + [{'name': f'cnt/test/{tag}', 'execution_time': [1.0]}]) + + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'indicators': [ + {'machine': machine, + 'test': 'nonexistent/test/xyz', + 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_create_unknown_metric_400(self): + """Indicator referencing an unknown metric returns 400.""" + machine, test = self._setup_machine_and_test() + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'indicators': [ + {'machine': machine, 'test': test, + 'metric': 'nonexistent_metric'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 400) + + def test_create_nonexistent_commit_404(self): + """Commit field referencing a nonexistent commit returns 404.""" + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'commit': 'nonexistent-commit-xyz'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_create_invalid_state_422(self): + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={'state': 'bogus_state'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 422) + + def test_create_no_auth_401(self): + resp = self.client.post( + PREFIX + '/regressions', + json={}, + ) + self.assertEqual(resp.status_code, 401) + + def test_create_submit_scope_403(self): + """Submit scope (one below triage) returns 403.""" + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.post( + PREFIX + '/regressions', + json={}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_create_triage_scope_201(self): + """Triage scope (the required scope) succeeds.""" + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + '/regressions', + json={}, + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + + +# ========================================================================== +# Regression Detail Tests +# ========================================================================== + +class TestRegressionDetail(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_get_detail(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['uuid'], reg_uuid) + self.assertIn('title', data) + self.assertIn('bug', data) + self.assertIn('notes', data) + self.assertIn('state', data) + self.assertIn('commit', data) + self.assertIn('indicators', data) + self.assertEqual(len(data['indicators']), 1) + ind = data['indicators'][0] + self.assertIn('uuid', ind) + self.assertIn('test', ind) + self.assertIn('machine', ind) + self.assertIn('metric', ind) + # Old fields should NOT be present + self.assertNotIn('field_change_uuid', ind) + self.assertNotIn('old_value', ind) + self.assertNotIn('new_value', ind) + self.assertNotIn('start_commit', ind) + self.assertNotIn('end_commit', ind) + + def test_detail_nonexistent_404(self): + resp = self.client.get( + PREFIX + '/regressions/nonexistent-uuid-12345') + self.assertEqual(resp.status_code, 404) + + def test_detail_state_is_string(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + data = resp.get_json() + self.assertIsInstance(data['state'], str) + self.assertEqual(data['state'], 'active') + + +class TestRegressionDetailETag(unittest.TestCase): + """ETag tests for GET /api/v5/{ts}/regressions/{uuid}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_etag_present_on_detail(self): + """Regression detail response should include an ETag header.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + self.assertEqual(resp.status_code, 200) + etag = resp.headers.get('ETag') + self.assertIsNotNone(etag) + self.assertTrue(etag.startswith('W/"')) + + def test_etag_304_on_match(self): + """Sending If-None-Match with the same ETag returns 304.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + etag = resp.headers.get('ETag') + + resp2 = self.client.get( + PREFIX + f'/regressions/{reg_uuid}', + headers={'If-None-Match': etag}, + ) + self.assertEqual(resp2.status_code, 304) + + def test_etag_200_on_mismatch(self): + """Sending If-None-Match with a different ETag returns 200.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get( + PREFIX + f'/regressions/{reg_uuid}', + headers={'If-None-Match': 'W/"stale-etag-value"'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertIsNotNone(resp.get_json()) + + +# ========================================================================== +# Regression Update Tests +# ========================================================================== + +class TestRegressionUpdate(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_update_title(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'title': 'Updated Title'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['title'], 'Updated Title') + + def test_update_bug(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'bug': 'https://bugs.example.com/123'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['bug'], 'https://bugs.example.com/123') + + def test_update_state(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'state': 'fixed'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['state'], 'fixed') + + def test_update_notes(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'notes': 'Updated investigation notes'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['notes'], 'Updated investigation notes') + + def test_update_commit(self): + tag = uuid.uuid4().hex[:8] + machine = f'uc-m-{tag}' + test = f'uc/test/{tag}' + rev = f'uc-rev-{tag}' + submit_run(self.client, machine, rev, + [{'name': test, 'execution_time': [1.0]}]) + + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'commit': rev}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['commit'], rev) + + def test_clear_commit(self): + """PATCH commit=null clears the commit.""" + tag = uuid.uuid4().hex[:8] + machine = f'clc-m-{tag}' + test = f'clc/test/{tag}' + rev = f'clc-rev-{tag}' + submit_run(self.client, machine, rev, + [{'name': test, 'execution_time': [1.0]}]) + + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1, commit=rev) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'commit': None}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIsNone(data['commit']) + + def test_clear_notes(self): + """PATCH notes=null clears the notes.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + # Set notes first + self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'notes': 'Some notes'}, + headers=headers, + ) + # Clear notes + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'notes': None}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIsNone(data['notes']) + + def test_update_state_any_transition(self): + """State transitions are unconstrained -- any -> any.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + # active -> false_positive + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'state': 'false_positive'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()['state'], 'false_positive') + # false_positive -> detected + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'state': 'detected'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.get_json()['state'], 'detected') + + def test_update_invalid_state_422(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'state': 'not_a_real_state'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 422) + + def test_update_nonexistent_404(self): + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + '/regressions/nonexistent-uuid', + json={'title': 'x'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_update_no_auth_401(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'title': 'x'}, + ) + self.assertEqual(resp.status_code, 401) + + def test_update_submit_scope_403(self): + """Submit scope (one below triage) returns 403.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'title': 'x'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_update_triage_scope_200(self): + """Triage scope (the required scope) succeeds.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'title': 'Triage update'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + + def test_update_returns_indicators(self): + """PATCH response should include indicators.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 2) + headers = _triage_headers(self.app) + resp = self.client.patch( + PREFIX + f'/regressions/{reg_uuid}', + json={'title': 'With Indicators'}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('indicators', data) + self.assertEqual(len(data['indicators']), 2) + + +# ========================================================================== +# Regression Delete Tests +# ========================================================================== + +class TestRegressionDelete(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_regression(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}', + headers=headers, + ) + self.assertEqual(resp.status_code, 204) + + # Verify it's gone + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + self.assertEqual(resp.status_code, 404) + + def test_delete_nonexistent_404(self): + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + '/regressions/nonexistent-uuid', + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_delete_no_auth_401(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}', + ) + self.assertEqual(resp.status_code, 401) + + def test_delete_submit_scope_403(self): + """Submit scope (one below triage) returns 403.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}', + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_delete_triage_scope_204(self): + """Triage scope (the required scope) succeeds.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}', + headers=headers, + ) + self.assertEqual(resp.status_code, 204) + + +# ========================================================================== +# Regression Indicators Tests +# ========================================================================== + +class TestRegressionIndicators(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_add_indicator(self): + """Add an indicator to an existing regression.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + + # Create a new test/machine for the new indicator + tag = uuid.uuid4().hex[:8] + machine = f'add-m-{tag}' + test = f'add/test/{tag}' + submit_run(self.client, machine, f'add-rev-{tag}', + [{'name': test, 'execution_time': [1.0]}]) + + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': machine, 'test': test, + 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('indicators', data) + self.assertEqual(len(data['indicators']), 2) + + def test_add_duplicate_silently_ignored(self): + """Adding a duplicate indicator is silently ignored.""" + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 1) + + # Get the existing indicator details + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + data = resp.get_json() + existing = data['indicators'][0] + + headers = _triage_headers(self.app) + resp2 = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': existing['machine'], 'test': existing['test'], + 'metric': existing['metric']} + ]}, + headers=headers, + ) + self.assertEqual(resp2.status_code, 200) + data2 = resp2.get_json() + self.assertEqual(len(data2['indicators']), 1) + + def test_add_nonexistent_machine_404(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': 'nonexistent-machine-xyz', + 'test': 'some/test', 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 404) + + def test_add_nonexistent_test_404(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + # Get the existing indicator machine name + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + existing_machine = resp.get_json()['indicators'][0]['machine'] + + headers = _triage_headers(self.app) + resp2 = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': existing_machine, + 'test': 'nonexistent/test/xyz', + 'metric': 'execution_time'} + ]}, + headers=headers, + ) + self.assertEqual(resp2.status_code, 404) + + def test_add_unknown_metric_400(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get(PREFIX + f'/regressions/{reg_uuid}') + existing = resp.get_json()['indicators'][0] + + headers = _triage_headers(self.app) + resp2 = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': existing['machine'], + 'test': existing['test'], + 'metric': 'nonexistent_metric'} + ]}, + headers=headers, + ) + self.assertEqual(resp2.status_code, 400) + + def test_add_indicator_no_auth_401(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': 'x', 'test': 'y', 'metric': 'z'} + ]}, + ) + self.assertEqual(resp.status_code, 401) + + def test_add_indicator_submit_scope_403(self): + """Submit scope (one below triage) returns 403.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': [ + {'machine': 'x', 'test': 'y', 'metric': 'z'} + ]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_add_empty_list_422(self): + """POST with empty indicators list returns 422.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.post( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicators': []}, + headers=headers, + ) + self.assertEqual(resp.status_code, 422) + + def test_remove_indicator(self): + """Remove indicators via batch DELETE.""" + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 2) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': [ind_uuids[0]]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['indicators']), 1) + + def test_remove_multiple_batch(self): + """Remove 2 of 3 indicators in one batch.""" + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 3) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': ind_uuids[:2]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['indicators']), 1) + + def test_remove_unknown_uuid_silently_ignored(self): + """Unknown UUIDs in batch remove are silently ignored.""" + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': ['nonexistent-uuid-xyz']}, + headers=headers, + ) + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['indicators']), 1) + + def test_remove_no_auth_401(self): + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': [ind_uuids[0]]}, + ) + self.assertEqual(resp.status_code, 401) + + def test_remove_submit_scope_403(self): + """Submit scope (one below triage) returns 403.""" + reg_uuid, ind_uuids = _setup_regression_with_indicators( + self.client, 1) + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': [ind_uuids[0]]}, + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_remove_empty_list_422(self): + """DELETE with empty indicator_uuids list returns 422.""" + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + headers = _triage_headers(self.app) + resp = self.client.delete( + PREFIX + f'/regressions/{reg_uuid}/indicators', + json={'indicator_uuids': []}, + headers=headers, + ) + self.assertEqual(resp.status_code, 422) + + +class TestRegressionZPagination(unittest.TestCase): + """Exhaustive cursor pagination tests for GET /api/v5/{ts}/regressions.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._reg_uuids = [] + for _ in range(5): + reg_uuid, _ = _setup_regression_with_indicators( + cls.client, 1) + cls._reg_uuids.append(reg_uuid) + + def _collect_all_pages(self): + url = PREFIX + '/regressions?limit=2' + return collect_all_pages(self, self.client, url, page_limit=100) + + def test_pagination_collects_all_items(self): + """Paginating through all pages collects all created regressions.""" + all_items = self._collect_all_pages() + collected_uuids = [item['uuid'] for item in all_items] + for reg_uuid in self._reg_uuids: + self.assertIn(reg_uuid, collected_uuids) + + def test_no_duplicate_items_across_pages(self): + """No duplicate regression UUIDs across pages.""" + all_items = self._collect_all_pages() + uuids = [item['uuid'] for item in all_items] + self.assertEqual(len(uuids), len(set(uuids))) + + +class TestRegressionUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_regressions_list_unknown_param_returns_400(self): + resp = self.client.get(PREFIX + '/regressions?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_regression_detail_unknown_param_returns_400(self): + reg_uuid, _ = _setup_regression_with_indicators( + self.client, 1) + resp = self.client.get( + PREFIX + f'/regressions/{reg_uuid}?bogus=1') + self.assertEqual(resp.status_code, 400) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_runs.py b/tests/server/api/v5/test_runs.py new file mode 100644 index 000000000..904d6b68d --- /dev/null +++ b/tests/server/api/v5/test_runs.py @@ -0,0 +1,1297 @@ +# Tests for the v5 run endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import datetime +import json +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, + create_machine, create_commit, create_run, + collect_all_pages, submit_run, make_profile_base64, +) + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +def _make_submission_payload(machine_name=None, commit_str=None): + """Build a valid v5-format JSON submission payload.""" + if machine_name is None: + machine_name = f'submit-machine-{uuid.uuid4().hex[:8]}' + if commit_str is None: + commit_str = f'r{uuid.uuid4().hex[:8]}' + + return json.dumps({ + 'format_version': '5', + 'machine': { + 'name': machine_name, + }, + 'commit': commit_str, + 'tests': [ + { + 'name': 'test.suite/benchmark1', + 'execution_time': 0.1234, + }, + ], + }) + + +class TestRunListEmpty(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs with no data.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_returns_200(self): + resp = self.client.get(PREFIX + '/runs') + self.assertEqual(resp.status_code, 200) + + def test_list_has_items_key(self): + resp = self.client.get(PREFIX + '/runs') + data = resp.get_json() + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_list_has_pagination_envelope(self): + resp = self.client.get(PREFIX + '/runs') + data = resp.get_json() + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + +class TestRunListWithData(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs with existing data.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_includes_created_runs(self): + """Runs created via API appear in list.""" + name = f'list-data-{uuid.uuid4().hex[:8]}' + rev = f'list-rev-{uuid.uuid4().hex[:6]}' + data = submit_run(self.client, name, rev, + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get(PREFIX + f'/runs?machine={name}') + rdata = resp.get_json() + uuids = [item['uuid'] for item in rdata['items']] + self.assertIn(run_uuid, uuids) + + def test_list_run_has_expected_fields(self): + """Each run in the list has uuid, machine, commit, submitted_at, run_parameters.""" + name = f'list-fields-{uuid.uuid4().hex[:8]}' + submit_run(self.client, name, f'fields-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + + resp = self.client.get(PREFIX + f'/runs?machine={name}') + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + item = data['items'][0] + self.assertIn('uuid', item) + self.assertIn('machine', item) + self.assertIn('commit', item) + self.assertIn('submitted_at', item) + self.assertIn('run_parameters', item) + # Must NOT have internal IDs or v4 fields + self.assertNotIn('id', item) + self.assertNotIn('machine_id', item) + self.assertNotIn('order', item) + self.assertNotIn('start_time', item) + self.assertNotIn('end_time', item) + self.assertNotIn('parameters', item) + + def test_list_never_exposes_internal_ids(self): + """Run list items never contain internal database IDs.""" + name = f'no-ids-{uuid.uuid4().hex[:8]}' + submit_run(self.client, name, f'noid-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + + resp = self.client.get(PREFIX + f'/runs?machine={name}') + data = resp.get_json() + for item in data['items']: + self.assertNotIn('id', item) + self.assertNotIn('machine_id', item) + self.assertNotIn('commit_id', item) + + +class TestRunListPagination(unittest.TestCase): + """Tests for run list pagination.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_pagination(self): + """Create multiple runs and paginate through them.""" + name = f'page-{uuid.uuid4().hex[:8]}' + for i in range(3): + submit_run(self.client, name, + f'page-rev-{uuid.uuid4().hex[:6]}-{i}', + [{'name': 'p/test', 'execution_time': 0.0}]) + + # Get first page with limit=2 + resp = self.client.get(PREFIX + f'/runs?machine={name}&limit=2') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 2) + self.assertIsNotNone(data['cursor']['next']) + + # Follow cursor + cursor = data['cursor']['next'] + resp2 = self.client.get( + PREFIX + f'/runs?machine={name}&limit=2&cursor={cursor}') + self.assertEqual(resp2.status_code, 200) + data2 = resp2.get_json() + self.assertEqual(len(data2['items']), 1) + self.assertIsNone(data2['cursor']['next']) + + +class TestRunSubmit(unittest.TestCase): + """Tests for POST /api/v5/{ts}/runs (run submission).""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_submit_valid_payload(self): + """Submit a valid JSON payload and verify response.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertTrue(data.get('success')) + self.assertIn('run_uuid', data) + self.assertIsNotNone(data['run_uuid']) + self.assertIn('result_url', data) + + def test_submit_returns_uuid(self): + """Submitted run has a valid UUID.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + data = resp.get_json() + run_uuid = data.get('run_uuid') + self.assertIsNotNone(run_uuid) + # Verify UUID format (should be valid UUID4) + try: + uuid.UUID(run_uuid, version=4) + except ValueError: + self.fail(f"run_uuid is not a valid UUID: {run_uuid}") + + def test_submit_run_appears_in_list(self): + """After submission, the run appears in the list endpoint.""" + machine_name = f'submit-list-{uuid.uuid4().hex[:8]}' + payload = _make_submission_payload(machine_name=machine_name) + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + data = resp.get_json() + run_uuid = data['run_uuid'] + + # Verify the run appears in the list + list_resp = self.client.get( + PREFIX + f'/runs?machine={machine_name}') + list_data = list_resp.get_json() + uuids = [item['uuid'] for item in list_data['items']] + self.assertIn(run_uuid, uuids) + + def test_submit_run_detail_accessible(self): + """After submission, the run detail is accessible by UUID.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + data = resp.get_json() + run_uuid = data['run_uuid'] + + # Fetch run detail + detail_resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(detail_resp.status_code, 200) + detail = detail_resp.get_json() + self.assertEqual(detail['uuid'], run_uuid) + + def test_submit_invalid_payload_422(self): + """Submitting a JSON object without required fields returns 422.""" + resp = self.client.post( + PREFIX + '/runs', + data='{"not": "valid report"}', + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_submit_empty_body_422(self): + """Submitting an empty body returns 422.""" + resp = self.client.post( + PREFIX + '/runs', + data='', + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_submit_no_auth_401(self): + """Submitting without auth returns 401.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + ) + self.assertEqual(resp.status_code, 401) + + def test_submit_read_scope_403(self): + """Submitting with read scope returns 403.""" + headers = make_scoped_headers(self.app, 'read') + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_submit_with_submit_scope_succeeds(self): + """Submitting with submit scope succeeds.""" + headers = make_scoped_headers(self.app, 'submit') + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=headers, + ) + self.assertEqual(resp.status_code, 201) + + def test_submit_result_url_format(self): + """Result URL should point to the v5 run detail.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + data = resp.get_json() + result_url = data.get('result_url') + self.assertIsNotNone(result_url) + self.assertIn(f'/api/v5/{TS}/runs/', result_url) + + +class TestRunSubmitFormatValidation(unittest.TestCase): + """Tests that POST /api/v5/{ts}/runs mandates format_version '5'.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_submit_non_json_body_400(self): + """Non-JSON request body returns 400.""" + resp = self.client.post( + PREFIX + '/runs', + data='not json at all', + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 400) + + def test_submit_json_array_body_422(self): + """A JSON array (not object) returns 422.""" + resp = self.client.post( + PREFIX + '/runs', + data='[1, 2, 3]', + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_submit_missing_format_version_422(self): + """A JSON object without format_version returns 422.""" + payload = json.dumps({ + 'machine': {'name': 'dummy'}, + 'commit': 'rev1', + 'tests': [], + }) + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + def test_submit_wrong_format_version_400(self): + """format_version '2' (v4 format) is rejected.""" + payload = json.dumps({ + 'format_version': '2', + 'machine': {'name': 'dummy'}, + 'commit': 'rev1', + 'tests': [], + }) + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 400) + msg = resp.get_json()['error']['message'] + self.assertIn('format_version', msg) + + def test_submit_integer_format_version_400(self): + """format_version as integer 5 (not string '5') is rejected.""" + payload = json.dumps({ + 'format_version': 5, + 'machine': {'name': 'dummy'}, + 'commit': 'rev1', + 'tests': [], + }) + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 400) + msg = resp.get_json()['error']['message'] + self.assertIn('format_version', msg) + + def test_submit_v5_format_accepted(self): + """A valid format_version '5' payload is accepted.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + self.assertTrue(resp.get_json().get('success')) + + +class TestRunSubmitMachineConflict(unittest.TestCase): + """Tests for the on_machine_conflict query parameter on POST /runs.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_default_is_reject(self): + """Omitting on_machine_conflict uses reject by default.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + self.assertTrue(resp.get_json().get('success')) + + def test_reject_value_accepted(self): + """on_machine_conflict=reject is accepted.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs?on_machine_conflict=reject', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + + def test_update_value_accepted(self): + """on_machine_conflict=update is accepted.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs?on_machine_conflict=update', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + + def test_invalid_value_returns_422(self): + """An invalid on_machine_conflict value returns 422.""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs?on_machine_conflict=bogus', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 422) + + +def _make_submission_with_info(machine_name, machine_info, commit_str=None): + """Build a v5-format JSON submission payload with machine info fields.""" + if commit_str is None: + commit_str = f'r{uuid.uuid4().hex[:8]}' + machine = {'name': machine_name} + machine.update(machine_info) + return json.dumps({ + 'format_version': '5', + 'machine': machine, + 'commit': commit_str, + 'tests': [ + { + 'name': 'test.suite/benchmark1', + 'execution_time': 0.1234, + }, + ], + }) + + +class TestMachineConflictUpdateBehavior(unittest.TestCase): + """Behavioral tests for on_machine_conflict on POST /runs. + + These tests verify that the 'reject' strategy raises on + machine field conflicts, and that the 'update' strategy does not + create duplicates. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _submit_run(self, machine_name, machine_info, conflict='update'): + """Helper: submit a run with the given machine info and conflict mode.""" + payload = _make_submission_with_info(machine_name, machine_info) + url = PREFIX + '/runs' + if conflict is not None: + url += f'?on_machine_conflict={conflict}' + return self.client.post( + url, + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + + def _list_machines_by_name(self, machine_name): + """Helper: list machines filtered by exact name prefix.""" + resp = self.client.get( + PREFIX + f'/machines?search={machine_name}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + # Filter to exact name matches (search could match longer names) + return [m for m in data['items'] if m['name'] == machine_name] + + def _get_machine(self, machine_name): + """Helper: GET a machine by name and return (status_code, json).""" + resp = self.client.get(PREFIX + f'/machines/{machine_name}') + return resp.status_code, resp.get_json() + + def test_update_does_not_create_new_machine(self): + """on_machine_conflict=update reuses the existing machine, no duplicate.""" + name = f'mc-update-nodup-{uuid.uuid4().hex[:8]}' + + # First submission creates the machine. + resp1 = self._submit_run(name, {'os': 'Linux'}) + self.assertEqual(resp1.status_code, 201) + + # Second submission with different info and update mode. + resp2 = self._submit_run(name, {'os': 'Linux-v2'}, conflict='update') + self.assertEqual(resp2.status_code, 201) + + # Verify only one machine with this name exists. + machines = self._list_machines_by_name(name) + self.assertEqual(len(machines), 1, + f"Expected 1 machine named '{name}', got {len(machines)}") + + def test_update_changes_machine_info(self): + """on_machine_conflict=update actually modifies the machine's info.""" + name = f'mc-update-info-{uuid.uuid4().hex[:8]}' + + # First submission creates the machine with os=Linux. + resp1 = self._submit_run(name, {'os': 'Linux'}) + self.assertEqual(resp1.status_code, 201) + + # Verify initial info. + status, data = self._get_machine(name) + self.assertEqual(status, 200) + self.assertEqual(data['info']['os'], 'Linux') + + # Second submission with updated os. + resp2 = self._submit_run(name, {'os': 'Linux-v2'}, conflict='update') + self.assertEqual(resp2.status_code, 201) + + # Verify updated info. + status, data = self._get_machine(name) + self.assertEqual(status, 200) + self.assertEqual(data['info']['os'], 'Linux-v2') + + def test_reject_default_raises_on_conflict(self): + """Default reject mode returns 400 when machine info has changed.""" + name = f'mc-reject-err-{uuid.uuid4().hex[:8]}' + + # First submission creates the machine with os=Linux. + resp1 = self._submit_run(name, {'os': 'Linux'}, conflict=None) + self.assertEqual(resp1.status_code, 201) + + # Second submission with different os and default (reject) mode. + resp2 = self._submit_run(name, {'os': 'Linux-v2'}, conflict=None) + self.assertEqual(resp2.status_code, 400) + + def test_update_with_same_info_succeeds(self): + """on_machine_conflict=update with identical info succeeds, no duplicate.""" + name = f'mc-update-same-{uuid.uuid4().hex[:8]}' + + # Both submissions use the same info. + resp1 = self._submit_run(name, {'os': 'Linux'}, conflict='update') + self.assertEqual(resp1.status_code, 201) + + resp2 = self._submit_run(name, {'os': 'Linux'}, conflict='update') + self.assertEqual(resp2.status_code, 201) + + # Still only one machine. + machines = self._list_machines_by_name(name) + self.assertEqual(len(machines), 1, + f"Expected 1 machine named '{name}', got {len(machines)}") + + def test_update_preserves_existing_fields_when_new_is_null(self): + """on_machine_conflict=update preserves fields not in the new submission.""" + name = f'mc-update-preserve-{uuid.uuid4().hex[:8]}' + + # First submission with both os and hardware. + resp1 = self._submit_run(name, {'os': 'Linux', 'hardware': 'x86_64'}) + self.assertEqual(resp1.status_code, 201) + + # Verify both fields are set. + status, data = self._get_machine(name) + self.assertEqual(status, 200) + self.assertEqual(data['info']['os'], 'Linux') + self.assertEqual(data['info']['hardware'], 'x86_64') + + # Second submission with only os (no hardware). + resp2 = self._submit_run(name, {'os': 'Linux-v2'}, conflict='update') + self.assertEqual(resp2.status_code, 201) + + # Verify os is updated but hardware is preserved. + status, data = self._get_machine(name) + self.assertEqual(status, 200) + self.assertEqual(data['info']['os'], 'Linux-v2') + self.assertEqual(data['info']['hardware'], 'x86_64') + + +class TestRunDetail(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs/{uuid}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_get_run_detail(self): + """Get run detail by UUID.""" + name = f'detail-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'detail-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(data['uuid'], run_uuid) + self.assertEqual(data['machine'], name) + self.assertIn('commit', data) + self.assertIn('submitted_at', data) + self.assertIn('run_parameters', data) + + def test_get_run_detail_has_no_internal_ids(self): + """Run detail does not expose internal IDs.""" + name = f'detail-noid-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'noid-detail-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + data = resp.get_json() + self.assertNotIn('id', data) + self.assertNotIn('machine_id', data) + self.assertNotIn('commit_id', data) + + def test_get_nonexistent_uuid_404(self): + """Getting a run with a nonexistent UUID returns 404.""" + fake_uuid = str(uuid.uuid4()) + resp = self.client.get(PREFIX + f'/runs/{fake_uuid}') + self.assertEqual(resp.status_code, 404) + + def test_get_invalid_uuid_format_404(self): + """Getting a run with an invalid UUID string returns 404.""" + resp = self.client.get(PREFIX + '/runs/not-a-valid-uuid') + self.assertEqual(resp.status_code, 404) + + +class TestRunDetailETag(unittest.TestCase): + """ETag tests for GET /api/v5/{ts}/runs/{uuid}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_etag_present_on_detail(self): + """Run detail response should include an ETag header.""" + name = f'etag-present-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'etag-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(resp.status_code, 200) + etag = resp.headers.get('ETag') + self.assertIsNotNone(etag) + self.assertTrue(etag.startswith('W/"')) + + def test_etag_304_on_match(self): + """Sending If-None-Match with the same ETag returns 304.""" + name = f'etag-304-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'etag-304-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + etag = resp.headers.get('ETag') + + resp2 = self.client.get( + PREFIX + f'/runs/{run_uuid}', + headers={'If-None-Match': etag}, + ) + self.assertEqual(resp2.status_code, 304) + + def test_etag_200_on_mismatch(self): + """Sending If-None-Match with a different ETag returns 200.""" + name = f'etag-200-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'etag-200-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}', + headers={'If-None-Match': 'W/"stale-etag-value"'}, + ) + self.assertEqual(resp.status_code, 200) + self.assertIsNotNone(resp.get_json()) + + +class TestRunDelete(unittest.TestCase): + """Tests for DELETE /api/v5/{ts}/runs/{uuid}.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_delete_run(self): + """Delete a run and verify 204, then verify it's gone.""" + name = f'delete-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'del-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.delete( + PREFIX + f'/runs/{run_uuid}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 204) + + # Verify it's gone + resp = self.client.get(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(resp.status_code, 404) + + def test_delete_nonexistent_404(self): + """Deleting a nonexistent run returns 404.""" + fake_uuid = str(uuid.uuid4()) + resp = self.client.delete( + PREFIX + f'/runs/{fake_uuid}', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 404) + + def test_delete_no_auth_401(self): + """Deleting without auth returns 401.""" + name = f'del-noauth-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'del-noauth-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + resp = self.client.delete(PREFIX + f'/runs/{run_uuid}') + self.assertEqual(resp.status_code, 401) + + def test_delete_triage_scope_403(self): + """Deleting with triage scope (one below manage) returns 403.""" + name = f'del-scope-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'del-scope-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.delete( + PREFIX + f'/runs/{run_uuid}', + headers=headers, + ) + self.assertEqual(resp.status_code, 403) + + def test_delete_manage_scope_204(self): + """Deleting with manage scope (the required scope) succeeds.""" + name = f'del-mng-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'del-mng-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + + headers = make_scoped_headers(self.app, 'manage') + resp = self.client.delete( + PREFIX + f'/runs/{run_uuid}', + headers=headers, + ) + self.assertEqual(resp.status_code, 204) + + +class TestRunFilterByMachine(unittest.TestCase): + """Test filtering runs by machine name.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_filter_by_machine_name(self): + """Filter runs by machine name.""" + name = f'filter-machine-{uuid.uuid4().hex[:8]}' + submit_run(self.client, name, f'fm-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + + resp = self.client.get(PREFIX + f'/runs?machine={name}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for item in data['items']: + self.assertEqual(item['machine'], name) + + def test_filter_by_nonexistent_machine(self): + """Filtering by a machine that doesn't exist returns empty results.""" + resp = self.client.get( + PREFIX + '/runs?machine=nonexistent-machine-xyz-abc') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + +class TestRunFilterByCommit(unittest.TestCase): + """Test filtering runs by commit string.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_filter_by_commit(self): + """Filter runs by commit string.""" + name = f'commit-filter-{uuid.uuid4().hex[:8]}' + rev1 = f'cfilt-rev1-{uuid.uuid4().hex[:6]}' + rev2 = f'cfilt-rev2-{uuid.uuid4().hex[:6]}' + submit_run(self.client, name, rev1, + [{'name': 'p/test', 'execution_time': 0.0}]) + submit_run(self.client, name, rev2, + [{'name': 'p/test', 'execution_time': 0.0}]) + + resp = self.client.get( + PREFIX + f'/runs?machine={name}&commit={rev1}') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + self.assertEqual(data['items'][0]['commit'], rev1) + + def test_filter_by_nonexistent_commit(self): + """Filtering by a nonexistent commit returns empty results.""" + resp = self.client.get( + PREFIX + '/runs?commit=nonexistent-commit-xyz-abc') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + +class TestRunFilterByDatetime(unittest.TestCase): + """Test filtering runs by after/before datetime (submitted_at). + + Since submitted_at is set server-side, we use direct DB helpers + to create runs with specific timestamps. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_filter_after(self): + """Filter runs submitted after a given datetime.""" + name = f'after-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + c1 = create_commit( + session, ts, + commit=f'after-rev1-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c1, + submitted_at=datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + c2 = create_commit( + session, ts, + commit=f'after-rev2-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c2, + submitted_at=datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + resp = self.client.get( + PREFIX + f'/runs?machine={name}&after=2024-03-01T00:00:00') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + def test_filter_before(self): + """Filter runs submitted before a given datetime.""" + name = f'before-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + c1 = create_commit( + session, ts, + commit=f'before-rev1-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c1, + submitted_at=datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + c2 = create_commit( + session, ts, + commit=f'before-rev2-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c2, + submitted_at=datetime.datetime(2024, 6, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + resp = self.client.get( + PREFIX + f'/runs?machine={name}&before=2024-03-01T00:00:00') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + + def test_filter_after_and_before(self): + """Filter runs within a datetime range.""" + name = f'range-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + for month in (1, 4, 7, 10): + c = create_commit( + session, ts, + commit=f'range-rev-{month}-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c, + submitted_at=datetime.datetime( + 2024, month, 15, 12, 0, 0, + tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + resp = self.client.get( + PREFIX + f'/runs?machine={name}&after=2024-03-01T00:00:00&before=2024-08-01T00:00:00') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + # Should match runs from month 4 and 7 + self.assertEqual(len(data['items']), 2) + + def test_filter_invalid_after_datetime_400(self): + """Invalid after datetime returns 400.""" + resp = self.client.get(PREFIX + '/runs?after=not-a-date') + self.assertEqual(resp.status_code, 400) + + def test_filter_invalid_before_datetime_400(self): + """Invalid before datetime returns 400.""" + resp = self.client.get(PREFIX + '/runs?before=not-a-date') + self.assertEqual(resp.status_code, 400) + + +class TestRunSort(unittest.TestCase): + """Test sorting runs.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_sort_descending_submitted_at(self): + """Sort runs by -submitted_at returns newest first.""" + name = f'sort-run-{uuid.uuid4().hex[:8]}' + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + machine = create_machine(session, ts, name=name) + for month in (1, 4, 7): + c = create_commit( + session, ts, + commit=f'sort-rev-{month}-{uuid.uuid4().hex[:6]}') + create_run(session, ts, machine, c, + submitted_at=datetime.datetime( + 2024, month, 1, 12, 0, 0, + tzinfo=datetime.timezone.utc)) + session.commit() + session.close() + + # Default order (ascending by ID) + resp_default = self.client.get( + PREFIX + f'/runs?machine={name}') + self.assertEqual(resp_default.status_code, 200) + default_times = [ + item['submitted_at'] + for item in resp_default.get_json()['items']] + + # Descending by submitted_at + resp_sorted = self.client.get( + PREFIX + f'/runs?machine={name}&sort=-submitted_at') + self.assertEqual(resp_sorted.status_code, 200) + sorted_times = [ + item['submitted_at'] + for item in resp_sorted.get_json()['items']] + + self.assertEqual(len(sorted_times), 3) + self.assertEqual(sorted_times, list(reversed(default_times))) + + +class TestRunPagination(unittest.TestCase): + """Exhaustive cursor pagination tests for GET /api/v5/{ts}/runs.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._machine_name = f'pag-machine-{uuid.uuid4().hex[:8]}' + for i in range(5): + submit_run(cls.client, cls._machine_name, + f'pag-run-rev-{uuid.uuid4().hex[:6]}-{i}', + [{'name': 'p/test', 'execution_time': 0.0}]) + + def _collect_all_pages(self): + url = PREFIX + f'/runs?machine={self._machine_name}&limit=2' + return collect_all_pages(self, self.client, url) + + def test_pagination_collects_all_items(self): + """Paginating through all pages collects all 5 runs.""" + all_items = self._collect_all_pages() + self.assertEqual(len(all_items), 5) + + def test_no_duplicate_items_across_pages(self): + """No duplicate run UUIDs across pages.""" + all_items = self._collect_all_pages() + uuids = [item['uuid'] for item in all_items] + self.assertEqual(len(uuids), len(set(uuids))) + + +class TestRunListInvalidCursor(unittest.TestCase): + """Tests that an invalid cursor returns 400 for the run list endpoint.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + resp = self.client.get( + PREFIX + '/runs?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + +class TestRunUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_runs_list_unknown_param_returns_400(self): + resp = self.client.get(PREFIX + '/runs?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_run_detail_unknown_param_returns_400(self): + name = f'unk-det-{uuid.uuid4().hex[:8]}' + data = submit_run(self.client, name, f'unk-det-rev-{uuid.uuid4().hex[:6]}', + [{'name': 'p/test', 'execution_time': 0.0}]) + run_uuid = data['run_uuid'] + resp = self.client.get(PREFIX + f'/runs/{run_uuid}?bogus=1') + self.assertEqual(resp.status_code, 400) + + def test_run_submit_ignore_regressions_rejected(self): + """ignore_regressions is no longer accepted by v5 POST /runs.""" + headers = admin_headers() + headers['Content-Type'] = 'application/json' + body = json.dumps({ + 'format_version': '5', + 'machine': {'name': 'dummy'}, + 'commit': 'rev-ignore-test', + 'tests': [], + }) + resp = self.client.post( + PREFIX + '/runs?ignore_regressions=true', + data=body, + headers=headers, + ) + self.assertIn(resp.status_code, [400, 422]) + + +class TestRunHasProfilesFilter(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs?has_profiles=...""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls.tag = uuid.uuid4().hex[:8] + + profile_b64 = make_profile_base64() + + cls.machine = f'hp-run-m-{cls.tag}' + cls.commit = f'hp-run-c-{cls.tag}' + + # R1: profiled run + data1 = submit_run(cls.client, cls.machine, cls.commit, [ + {'name': 'test/profiled', 'execution_time': 1.0, + 'profile': profile_b64}, + ]) + cls.r1_uuid = data1['run_uuid'] + + # R2: non-profiled run + data2 = submit_run(cls.client, cls.machine, cls.commit, [ + {'name': 'test/noprof', 'execution_time': 2.0}, + ]) + cls.r2_uuid = data2['run_uuid'] + + def _get_run_uuids(self, **params): + qs = '&'.join(f'{k}={v}' for k, v in params.items()) + url = PREFIX + '/runs' + if qs: + url += '?' + qs + items = collect_all_pages(self, self.client, url) + return [item['uuid'] for item in items] + + def test_has_profiles_true(self): + """Only runs with profiles are returned.""" + uuids = self._get_run_uuids(has_profiles='true') + self.assertIn(self.r1_uuid, uuids) + self.assertNotIn(self.r2_uuid, uuids) + + def test_has_profiles_false(self): + """Only runs without profiles are returned.""" + uuids = self._get_run_uuids(has_profiles='false') + self.assertNotIn(self.r1_uuid, uuids) + self.assertIn(self.r2_uuid, uuids) + + def test_has_profiles_with_machine_commit(self): + """has_profiles=true combined with machine and commit filters.""" + uuids = self._get_run_uuids( + machine=self.machine, commit=self.commit, + has_profiles='true') + self.assertIn(self.r1_uuid, uuids) + self.assertNotIn(self.r2_uuid, uuids) + + def test_has_profiles_empty(self): + """has_profiles=true returns empty when no matching profiled runs.""" + # Use a machine name that doesn't exist — returns empty (not 404) + uuids = self._get_run_uuids( + machine='nonexistent-machine-xyz', has_profiles='true') + self.assertEqual(uuids, []) + + +class TestRunSubmitClientUUID(unittest.TestCase): + """Tests for client-provided UUIDs on POST /api/v5/{ts}/runs.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _submit_with_uuid(self, client_uuid, machine_name=None, + commit_str=None): + """Submit a run with a client-provided UUID, return the response.""" + if machine_name is None: + machine_name = f'uuid-m-{uuid.uuid4().hex[:8]}' + if commit_str is None: + commit_str = f'uuid-c-{uuid.uuid4().hex[:8]}' + payload = { + 'format_version': '5', + 'uuid': client_uuid, + 'machine': {'name': machine_name}, + 'commit': commit_str, + 'tests': [ + {'name': 'test.suite/bench', 'execution_time': 1.0}, + ], + } + resp = self.client.post( + PREFIX + '/runs', + json=payload, + headers=admin_headers(), + ) + return resp + + def test_submit_with_client_uuid(self): + """Client-provided UUID is accepted and returned in the response.""" + client_uuid = str(uuid.uuid4()) + resp = self._submit_with_uuid(client_uuid) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['run_uuid'], client_uuid) + # Location header should contain the client UUID. + self.assertIn(client_uuid, resp.headers.get('Location', '')) + + def test_submit_with_client_uuid_uppercase(self): + """Uppercase UUID is normalized to lowercase.""" + raw = str(uuid.uuid4()) + upper_uuid = raw.upper() + resp = self._submit_with_uuid(upper_uuid) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertEqual(data['run_uuid'], raw.lower()) + + def test_submit_with_client_uuid_in_detail(self): + """Client-provided UUID is retrievable via GET /runs/{uuid}.""" + client_uuid = str(uuid.uuid4()) + resp = self._submit_with_uuid(client_uuid) + self.assertEqual(resp.status_code, 201) + + detail = self.client.get(PREFIX + f'/runs/{client_uuid}') + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.get_json()['uuid'], client_uuid) + + def test_submit_with_client_uuid_in_list(self): + """Client-provided UUID appears in the run list endpoint.""" + machine = f'uuid-list-{uuid.uuid4().hex[:8]}' + client_uuid = str(uuid.uuid4()) + self._submit_with_uuid(client_uuid, machine_name=machine) + + resp = self.client.get(PREFIX + f'/runs?machine={machine}') + self.assertEqual(resp.status_code, 200) + uuids = [item['uuid'] for item in resp.get_json()['items']] + self.assertIn(client_uuid, uuids) + + def test_submit_duplicate_uuid_409(self): + """Submitting with a UUID that already exists returns 409.""" + client_uuid = str(uuid.uuid4()) + resp1 = self._submit_with_uuid(client_uuid) + self.assertEqual(resp1.status_code, 201) + + # Second submission with the same UUID should fail. + resp2 = self._submit_with_uuid(client_uuid) + self.assertEqual(resp2.status_code, 409) + msg = resp2.get_json()['error']['message'] + self.assertIn(client_uuid, msg) + + def test_submit_invalid_uuid_format_422(self): + """Invalid UUID format is rejected by schema validation.""" + resp = self._submit_with_uuid('not-a-uuid') + self.assertEqual(resp.status_code, 422) + + def test_submit_empty_uuid_422(self): + """Empty string UUID is rejected by schema validation.""" + resp = self._submit_with_uuid('') + self.assertEqual(resp.status_code, 422) + + def test_submit_null_uuid_generates_one(self): + """Explicit null UUID behaves like omitting it (server generates).""" + machine = f'uuid-null-{uuid.uuid4().hex[:8]}' + payload = { + 'format_version': '5', + 'uuid': None, + 'machine': {'name': machine}, + 'commit': f'uuid-null-c-{uuid.uuid4().hex[:8]}', + 'tests': [ + {'name': 'test.suite/bench', 'execution_time': 1.0}, + ], + } + resp = self.client.post( + PREFIX + '/runs', + json=payload, + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIsNotNone(data['run_uuid']) + # Verify it's a valid UUID. + uuid.UUID(data['run_uuid']) + + def test_submit_without_uuid_still_works(self): + """Omitting UUID entirely still works (backward compatibility).""" + payload = _make_submission_payload() + resp = self.client.post( + PREFIX + '/runs', + data=payload, + content_type='application/json', + headers=admin_headers(), + ) + self.assertEqual(resp.status_code, 201) + data = resp.get_json() + self.assertIsNotNone(data['run_uuid']) + # Verify it's a valid UUID. + uuid.UUID(data['run_uuid']) + + def test_lookup_by_uppercase_uuid(self): + """GET /runs/{UUID} with uppercase still finds the run.""" + client_uuid = str(uuid.uuid4()) + self._submit_with_uuid(client_uuid) + + upper = client_uuid.upper() + detail = self.client.get(PREFIX + f'/runs/{upper}') + self.assertEqual(detail.status_code, 200) + self.assertEqual(detail.get_json()['uuid'], client_uuid) + + def test_duplicate_uuid_different_case_409(self): + """Submitting the same UUID in different case returns 409.""" + client_uuid = str(uuid.uuid4()) + resp1 = self._submit_with_uuid(client_uuid) + self.assertEqual(resp1.status_code, 201) + + # Submit with uppercase variant — same UUID per RFC 9562. + resp2 = self._submit_with_uuid(client_uuid.upper()) + self.assertEqual(resp2.status_code, 409) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_samples.py b/tests/server/api/v5/test_samples.py new file mode 100644 index 000000000..c82b3cefc --- /dev/null +++ b/tests/server/api/v5/test_samples.py @@ -0,0 +1,362 @@ +# Tests for the v5 sample endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import os +import sys +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, make_scoped_headers, + create_machine, create_commit, create_run, create_test, create_sample, + collect_all_pages, +) + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +class TestRunSamples(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs/{uuid}/samples.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _setup_run_with_samples(self): + """Create a run with several samples for testing.""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + + machine = create_machine( + session, ts, f'sample-test-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'sample-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + + test1 = create_test( + session, ts, f'test.suite/bench1-{uuid.uuid4().hex[:8]}') + test2 = create_test( + session, ts, f'test.suite/bench2-{uuid.uuid4().hex[:8]}') + + create_sample(session, ts, run, test1) + create_sample(session, ts, run, test2) + + session.commit() + # Save values before closing session to avoid DetachedInstanceError + run_uuid = run.uuid + test1_name = test1.name + test2_name = test2.name + session.close() + return run_uuid, test1_name, test2_name + + def test_list_samples_empty_run(self): + """A run with no samples returns an empty list.""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + machine = create_machine( + session, ts, f'empty-run-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'empty-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + session.commit() + run_uuid = run.uuid + session.close() + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertEqual(len(data['items']), 0) + + def test_list_samples_with_data(self): + """A run with samples returns them.""" + run_uuid, test1_name, test2_name = self._setup_run_with_samples() + + resp = self.client.get(PREFIX + f'/runs/{run_uuid}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertGreaterEqual(len(data['items']), 2) + + # Verify sample structure (v5: test + metrics, no has_profile) + sample = data['items'][0] + self.assertIn('test', sample) + self.assertIn('metrics', sample) + self.assertNotIn('has_profile', sample) + self.assertIsInstance(sample['metrics'], dict) + + def test_list_samples_has_pagination(self): + """Sample list has pagination envelope.""" + run_uuid, _, _ = self._setup_run_with_samples() + resp = self.client.get(PREFIX + f'/runs/{run_uuid}/samples') + data = resp.get_json() + self.assertIn('cursor', data) + self.assertIn('next', data['cursor']) + self.assertIn('previous', data['cursor']) + + def test_list_samples_nonexistent_run(self): + """404 for a nonexistent run UUID.""" + fake_uuid = str(uuid.uuid4()) + resp = self.client.get(PREFIX + f'/runs/{fake_uuid}/samples') + self.assertEqual(resp.status_code, 404) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + run_uuid, _, _ = self._setup_run_with_samples() + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/samples?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + +class TestRunTestSamples(unittest.TestCase): + """Tests for GET /api/v5/{ts}/runs/{uuid}/tests/{test_name}/samples.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def _setup_run_with_samples(self): + """Create a run with samples for testing.""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + + machine = create_machine( + session, ts, f'test-sample-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'ts-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + + test = create_test( + session, ts, f'test.suite/specific-{uuid.uuid4().hex[:8]}') + create_sample(session, ts, run, test) + + session.commit() + run_uuid = run.uuid + test_name = test.name + session.close() + return run_uuid, test_name + + def test_samples_for_specific_test(self): + """Get samples for a specific test in a run.""" + run_uuid, test_name = self._setup_run_with_samples() + + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/tests/{test_name}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertGreaterEqual(len(data['items']), 1) + self.assertEqual(data['items'][0]['test'], test_name) + + def test_samples_for_nonexistent_run(self): + """404 for a nonexistent run UUID.""" + fake_uuid = str(uuid.uuid4()) + resp = self.client.get( + PREFIX + f'/runs/{fake_uuid}/tests/some.test/samples') + self.assertEqual(resp.status_code, 404) + + def test_samples_for_nonexistent_test(self): + """404 for a nonexistent test name.""" + # Create a real run first + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + machine = create_machine( + session, ts, f'nonexist-test-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'ne-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + session.commit() + run_uuid = run.uuid + session.close() + + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/tests/no.such.test/samples') + self.assertEqual(resp.status_code, 404) + + def test_samples_test_name_with_slashes(self): + """Test names with slashes work (using path converter).""" + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + + machine = create_machine( + session, ts, f'slash-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'sl-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + + # Test name with slashes + test_name = f'test/suite/sub/bench-{uuid.uuid4().hex[:8]}' + test = create_test(session, ts, test_name) + create_sample(session, ts, run, test) + + session.commit() + run_uuid = run.uuid + session.close() + + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/tests/{test_name}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertIn('items', data) + self.assertEqual(len(data['items']), 1) + self.assertEqual(data['items'][0]['test'], test_name) + + def test_samples_returns_metrics(self): + """Sample response includes metrics dict with field values.""" + run_uuid, test_name = self._setup_run_with_samples() + resp = self.client.get( + PREFIX + f'/runs/{run_uuid}/tests/{test_name}/samples') + self.assertEqual(resp.status_code, 200) + data = resp.get_json() + self.assertGreaterEqual(len(data['items']), 1) + sample = data['items'][0] + self.assertIn('metrics', sample) + self.assertIsInstance(sample['metrics'], dict) + + +class TestSampleAuth(unittest.TestCase): + """Auth tests for sample endpoints (all use @require_scope('read')).""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + # Create a run with samples for testing + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + machine = create_machine( + session, ts, f'auth-sample-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'auth-sample-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + test = create_test( + session, ts, f'test.suite/auth-bench-{uuid.uuid4().hex[:8]}') + create_sample(session, ts, run, test) + session.commit() + cls._run_uuid = run.uuid + cls._test_name = test.name + session.close() + + def test_run_samples_no_auth_allowed(self): + """Unauthenticated GET for run samples is allowed by default.""" + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/samples') + self.assertEqual(resp.status_code, 200) + + def test_run_samples_read_scope_allowed(self): + """A valid read-scoped token works for run samples.""" + headers = make_scoped_headers(self.app, 'read') + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/samples', + headers=headers) + self.assertEqual(resp.status_code, 200) + + def test_test_samples_no_auth_allowed(self): + """Unauthenticated GET for test-specific samples is allowed by default.""" + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/tests/{self._test_name}/samples') + self.assertEqual(resp.status_code, 200) + + def test_test_samples_read_scope_allowed(self): + """A valid read-scoped token works for test-specific samples.""" + headers = make_scoped_headers(self.app, 'read') + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/tests/{self._test_name}/samples', + headers=headers) + self.assertEqual(resp.status_code, 200) + + +class TestSamplePagination(unittest.TestCase): + """Exhaustive cursor pagination tests for GET /api/v5/{ts}/runs/{uuid}/samples.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + machine = create_machine( + session, ts, f'pag-sample-m-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'pag-sample-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + for i in range(5): + test = create_test( + session, ts, + f'pag-sample/test-{uuid.uuid4().hex[:8]}-{i}') + create_sample(session, ts, run, test) + session.commit() + cls._run_uuid = run.uuid + session.close() + + def _collect_all_pages(self): + url = PREFIX + f'/runs/{self._run_uuid}/samples?limit=2' + return collect_all_pages(self, self.client, url) + + def test_pagination_collects_all_items(self): + """Paginating through all pages collects all 5 samples.""" + all_items = self._collect_all_pages() + self.assertEqual(len(all_items), 5) + + def test_no_duplicate_items_across_pages(self): + """No duplicate test names across pages.""" + all_items = self._collect_all_pages() + names = [item['test'] for item in all_items] + self.assertEqual(len(names), len(set(names))) + + +class TestSampleUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + db = cls.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite['nts'] + machine = create_machine( + session, ts, f'unk-sample-machine-{uuid.uuid4().hex[:8]}') + commit = create_commit( + session, ts, commit=f'unk-sample-rev-{uuid.uuid4().hex[:8]}') + run = create_run(session, ts, machine, commit) + test = create_test( + session, ts, f'test.suite/unk-bench-{uuid.uuid4().hex[:8]}') + create_sample(session, ts, run, test) + session.commit() + cls._run_uuid = run.uuid + cls._test_name = test.name + session.close() + + def test_run_samples_unknown_param_returns_400(self): + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/samples?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_test_samples_unknown_param_returns_400(self): + resp = self.client.get( + PREFIX + f'/runs/{self._run_uuid}/tests/{self._test_name}/samples?bogus=1') + self.assertEqual(resp.status_code, 400) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_test_suites.py b/tests/server/api/v5/test_test_suites.py new file mode 100644 index 000000000..b7cc624b6 --- /dev/null +++ b/tests/server/api/v5/test_test_suites.py @@ -0,0 +1,906 @@ +# Tests for the v5 test-suites endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest +from unittest.mock import patch + +from lnt.server.db.v5.models import V5Schema, V5SchemaVersion, utcnow + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, admin_headers, make_scoped_headers, + create_machine, +) + + +MINIMAL_SUITE = { + 'name': 'newsuite', + 'metrics': [ + {'name': 'compile_time', 'type': 'real'}, + ], + 'commit_fields': [], + 'machine_fields': [], +} + + +class TestListTestSuites(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_returns_200(self): + resp = self.client.get('/api/v5/test-suites/') + self.assertEqual(resp.status_code, 200) + + def test_list_contains_nts(self): + resp = self.client.get('/api/v5/test-suites/') + data = resp.get_json() + self.assertIn('items', data) + names = [item['name'] for item in data['items']] + self.assertIn('nts', names) + + def test_list_items_have_name_schema_links(self): + resp = self.client.get('/api/v5/test-suites/') + data = resp.get_json() + for item in data['items']: + self.assertIn('name', item) + self.assertIn('schema', item) + self.assertIn('links', item) + + def test_list_no_auth_required(self): + resp = self.client.get('/api/v5/test-suites/') + self.assertEqual(resp.status_code, 200) + + def test_list_unknown_params_returns_400(self): + resp = self.client.get('/api/v5/test-suites/?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + +class TestGetTestSuite(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_get_nts_returns_200(self): + resp = self.client.get('/api/v5/test-suites/nts') + self.assertEqual(resp.status_code, 200) + + def test_get_nts_has_schema_and_links(self): + resp = self.client.get('/api/v5/test-suites/nts') + data = resp.get_json() + self.assertIn('schema', data) + self.assertIn('links', data) + self.assertEqual(data['name'], 'nts') + + def test_get_nts_schema_has_name(self): + resp = self.client.get('/api/v5/test-suites/nts') + data = resp.get_json() + self.assertEqual(data['schema']['name'], 'nts') + + def test_get_nts_schema_has_commit_fields(self): + """v5 schema should have commit_fields instead of run_fields.""" + resp = self.client.get('/api/v5/test-suites/nts') + data = resp.get_json() + schema = data['schema'] + self.assertIn('commit_fields', schema) + self.assertNotIn('run_fields', schema) + + def test_get_nonexistent_returns_404(self): + resp = self.client.get('/api/v5/test-suites/nonexistent') + self.assertEqual(resp.status_code, 404) + + def test_get_unknown_params_returns_400(self): + resp = self.client.get('/api/v5/test-suites/nts?bogus=1') + self.assertEqual(resp.status_code, 400) + + +class TestCreateTestSuite(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def _create_suite(self, payload=None, headers=None): + if payload is None: + payload = dict(MINIMAL_SUITE) + if headers is None: + headers = self._manage_headers + return self.client.post( + '/api/v5/test-suites/', + json=payload, + headers=headers, + ) + + def test_create_returns_201(self): + payload = dict(MINIMAL_SUITE, name='createsuite1') + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + def test_create_returns_location_header(self): + payload = dict(MINIMAL_SUITE, name='createsuite2') + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + self.assertIn('Location', resp.headers) + self.assertIn('createsuite2', resp.headers['Location']) + + def test_created_suite_appears_in_list(self): + payload = dict(MINIMAL_SUITE, name='createsuite3') + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertIn('createsuite3', names) + + def test_schema_roundtrips(self): + payload = dict(MINIMAL_SUITE, name='createsuite4') + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + detail_resp = self.client.get('/api/v5/test-suites/createsuite4') + self.assertEqual(detail_resp.status_code, 200) + data = detail_resp.get_json() + self.assertEqual(data['schema']['name'], 'createsuite4') + # v5 schema should have commit_fields, not run_fields + self.assertIn('commit_fields', data['schema']) + self.assertNotIn('run_fields', data['schema']) + + def test_per_suite_endpoints_work(self): + payload = dict(MINIMAL_SUITE, name='createsuite5') + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + # Tests list should work for the new suite + tests_resp = self.client.get('/api/v5/createsuite5/tests') + self.assertEqual(tests_resp.status_code, 200) + + def test_duplicate_returns_409(self): + payload = dict(MINIMAL_SUITE, name='createsuite6') + resp1 = self._create_suite(payload) + self.assertEqual(resp1.status_code, 201) + + resp2 = self._create_suite(payload) + self.assertEqual(resp2.status_code, 409) + + def test_invalid_name_returns_422(self): + payload = dict(MINIMAL_SUITE, name='123invalid') + resp = self._create_suite(payload) + self.assertIn(resp.status_code, (400, 422)) + + def test_name_with_spaces_returns_422(self): + payload = dict(MINIMAL_SUITE, name='has space') + resp = self._create_suite(payload) + self.assertIn(resp.status_code, (400, 422)) + + def test_invalid_metric_type_returns_400(self): + payload = dict(MINIMAL_SUITE, name='createsuite_badmetric') + payload['metrics'] = [{'name': 'x', 'type': 'BadType'}] + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 400) + + def test_empty_body_returns_422(self): + resp = self.client.post( + '/api/v5/test-suites/', + json={}, + headers=self._manage_headers, + ) + self.assertIn(resp.status_code, (400, 422)) + + +class TestCreateTestSuiteAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_no_auth_returns_401(self): + payload = dict(MINIMAL_SUITE, name='authtest1') + resp = self.client.post('/api/v5/test-suites/', json=payload) + self.assertEqual(resp.status_code, 401) + + def test_read_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'read') + payload = dict(MINIMAL_SUITE, name='authtest2') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_submit_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'submit') + payload = dict(MINIMAL_SUITE, name='authtest3') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_triage_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'triage') + payload = dict(MINIMAL_SUITE, name='authtest4') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_manage_scope_returns_201(self): + headers = make_scoped_headers(self.app, 'manage') + payload = dict(MINIMAL_SUITE, name='authtest5') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, headers=headers) + self.assertEqual(resp.status_code, 201) + + def test_admin_scope_returns_201(self): + payload = dict(MINIMAL_SUITE, name='authtest6') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=admin_headers()) + self.assertEqual(resp.status_code, 201) + + +class TestDeleteTestSuite(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def _create_and_return_name(self, name): + payload = dict(MINIMAL_SUITE, name=name) + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + return name + + def test_delete_with_confirm_returns_204(self): + name = self._create_and_return_name('deletesuite1') + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 204) + + def test_delete_without_confirm_returns_400(self): + name = self._create_and_return_name('deletesuite2') + resp = self.client.delete( + f'/api/v5/test-suites/{name}', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 400) + + def test_delete_with_confirm_false_returns_400(self): + name = self._create_and_return_name('deletesuite3') + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=false', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 400) + + def test_delete_nonexistent_returns_404(self): + resp = self.client.delete( + '/api/v5/test-suites/nonexistent?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 404) + + def test_deleted_suite_removed_from_list(self): + name = self._create_and_return_name('deletesuite4') + self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertNotIn(name, names) + + def test_deleted_suite_per_suite_endpoints_404(self): + name = self._create_and_return_name('deletesuite5') + self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + + resp = self.client.get(f'/api/v5/{name}/machines') + self.assertEqual(resp.status_code, 404) + + def test_recreate_after_delete(self): + name = self._create_and_return_name('deletesuite6') + self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + + # Recreate with the same name + payload = dict(MINIMAL_SUITE, name=name) + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + def test_delete_suite_with_data(self): + """Create a suite, add some data, then delete it.""" + name = self._create_and_return_name('deletesuite7') + + # Submit a machine to create some data via direct DB access + db = self.app.instance.get_database("default") + ts = db.testsuite[name] + session = db.make_session() + create_machine(session, ts, name='test-machine') + session.commit() + session.close() + + # Now delete + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 204) + + +class TestDeleteTestSuiteErrorRecovery(unittest.TestCase): + """Test that the delete endpoint recovers from partial failures.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def _create_and_return_name(self, name): + payload = dict(MINIMAL_SUITE, name=name) + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + return name + + def test_metadata_failure_preserves_suite(self): + """If metadata deletion fails, tables and in-memory state survive.""" + name = self._create_and_return_name('delfail1') + + # Verify suite exists + resp = self.client.get(f'/api/v5/test-suites/{name}') + self.assertEqual(resp.status_code, 200) + + from lnt.server.db.v5 import V5DB + + # Patch _bump_schema_version to raise, simulating a failure + # during the metadata-commit step (step 1). Since the exception + # occurs inside the try block, the session is rolled back and the + # suite should remain fully intact. + with patch.object( + V5DB, '_bump_schema_version', + side_effect=RuntimeError("simulated version increment failure"), + ): + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 500) + + db = self.app.instance.get_database("default") + + # The suite should still be accessible (metadata was rolled back) + self.assertIn(name, db.testsuite) + + # The suite should still appear in the list + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertIn(name, names) + + # The suite's per-suite endpoints should still work + resp = self.client.get(f'/api/v5/{name}/tests') + self.assertEqual(resp.status_code, 200) + + # Now verify we can still successfully delete it (no corrupted state) + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 204) + + def test_table_drop_failure_still_preserves_suite(self): + """If table dropping fails inside delete_suite, the whole operation + fails and the session is rolled back, leaving the suite intact. + + In v5, drop_all() is called inside delete_suite() before the dict + entry is removed. If it raises, the exception propagates to the + endpoint which catches it, rolls back the session (undoing the + schema row deletion and version bump), and returns 500. The suite + remains in both the in-memory dict and the database. + """ + name = self._create_and_return_name('delfail2') + db = self.app.instance.get_database("default") + tsdb = db.testsuite[name] + + # Patch drop_all to fail + with patch.object( + tsdb.models.base.metadata, 'drop_all', + side_effect=RuntimeError("simulated table drop failure"), + ): + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + # The exception propagates and the endpoint returns 500 + self.assertEqual(resp.status_code, 500) + + # Suite should still be in the in-memory dict (del was never reached) + self.assertIn(name, db.testsuite) + + # Suite should still appear in the list endpoint + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertIn(name, names) + + # Verify we can still successfully delete it (no corrupted state) + resp = self.client.delete( + f'/api/v5/test-suites/{name}?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 204) + + +class TestDeleteTestSuiteAuth(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + # Create a suite to test against + payload = dict(MINIMAL_SUITE, name='delauth') + cls.client.post( + '/api/v5/test-suites/', json=payload, + headers=cls._manage_headers) + + def test_no_auth_returns_401(self): + resp = self.client.delete( + '/api/v5/test-suites/delauth?confirm=true') + self.assertEqual(resp.status_code, 401) + + def test_read_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'read') + resp = self.client.delete( + '/api/v5/test-suites/delauth?confirm=true', headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_submit_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'submit') + resp = self.client.delete( + '/api/v5/test-suites/delauth?confirm=true', headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_triage_scope_returns_403(self): + headers = make_scoped_headers(self.app, 'triage') + resp = self.client.delete( + '/api/v5/test-suites/delauth?confirm=true', headers=headers) + self.assertEqual(resp.status_code, 403) + + def test_manage_scope_returns_204(self): + # Create a fresh suite for this test + payload = dict(MINIMAL_SUITE, name='delauth_manage') + self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + + resp = self.client.delete( + '/api/v5/test-suites/delauth_manage?confirm=true', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 204) + + +class TestDiscoveryAfterCreate(unittest.TestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def test_new_suite_appears_in_discovery(self): + payload = dict(MINIMAL_SUITE, name='discoversuite') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + disc_resp = self.client.get('/api/v5/') + data = disc_resp.get_json() + names = [s['name'] for s in data['test_suites']] + self.assertIn('discoversuite', names) + + +class TestUnknownParamsOnMutations(unittest.TestCase): + """Unknown query params on POST and DELETE should be rejected.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def test_post_unknown_param_returns_400(self): + payload = dict(MINIMAL_SUITE, name='unknownparam_post') + resp = self.client.post( + '/api/v5/test-suites/?bogus=1', + json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_delete_unknown_param_returns_400(self): + # Create suite first + payload = dict(MINIMAL_SUITE, name='unknownparam_del') + self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + + resp = self.client.delete( + '/api/v5/test-suites/unknownparam_del?confirm=true&bogus=1', + headers=self._manage_headers) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + +class TestCreateRaceCondition(unittest.TestCase): + """Test the DB-level duplicate guard (race condition path).""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def test_db_row_exists_but_not_in_memory_returns_error(self): + """If a V5Schema row exists in the DB but is not in the in-memory + cache, POST should fail due to a DB-level constraint violation. + + In v5, the V5Schema primary key prevents duplicate rows. When the + in-memory cache is stale, the endpoint bypasses the fast 409 path + and the flush raises IntegrityError, which the generic handler + returns as 400. + """ + # First create a suite normally so the DB row exists + payload = dict(MINIMAL_SUITE, name='racesuite') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + # Remove it from the in-memory cache only (simulating another + # worker that hasn't reloaded yet) + db = self.app.instance.get_database("default") + saved_tsdb = db.testsuite.pop('racesuite', None) + self.assertIsNotNone(saved_tsdb) + + try: + # POST should hit the DB-level constraint and return an error. + # The IntegrityError is caught by the generic Exception handler + # in the create endpoint which returns 400. + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertIn(resp.status_code, (400, 409)) + finally: + # Restore the in-memory entry to avoid side-effects + if saved_tsdb is not None: + db.testsuite['racesuite'] = saved_tsdb + db.testsuite = dict(sorted(db.testsuite.items())) + + +class TestCreateTestSuiteErrorRecovery(unittest.TestCase): + """Test that the create endpoint recovers from partial failures.""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def _create_suite(self, payload=None, headers=None): + if payload is None: + payload = dict(MINIMAL_SUITE) + if headers is None: + headers = self._manage_headers + return self.client.post( + '/api/v5/test-suites/', + json=payload, + headers=headers, + ) + + def test_table_creation_failure_rolls_back_metadata(self): + """If create_tables fails, metadata rows are rolled back and no + suite appears in the in-memory dict or the list endpoint.""" + name = 'createfail1' + payload = dict(MINIMAL_SUITE, name=name) + + db = self.app.instance.get_database("default") + + # Patch create_suite to simulate table creation failure. + def patched_create_suite(self_db, session, schema): + """Intercept create_suite to make table creation fail.""" + # Do everything up to table creation, then fail + if schema.name in self_db.testsuite: + raise ValueError(f"suite {schema.name!r} already exists") + from lnt.server.db.v5 import V5DB + schema_dict = V5DB._schema_to_dict(schema) + row = V5Schema( + name=schema.name, + schema_json=__import__('json').dumps(schema_dict), + created_at=utcnow(), + ) + session.add(row) + V5DB._bump_schema_version(session) + session.flush() + # Now fail during "table creation" + raise RuntimeError("simulated table creation failure") + + from lnt.server.db.v5 import V5DB + with patch.object(V5DB, 'create_suite', patched_create_suite): + resp = self._create_suite(payload) + self.assertIn(resp.status_code, (400, 500)) + + # Suite must NOT be in the in-memory dict. + self.assertNotIn(name, db.testsuite) + + # Suite must NOT appear in the list endpoint. + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertNotIn(name, names) + + # Metadata rows must have been rolled back -- we can create the + # same suite successfully now. + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + def test_schema_version_failure_rolls_back_metadata(self): + """If _bump_schema_version fails, metadata and tables are + cleaned up and the suite can be retried.""" + name = 'createfail2' + payload = dict(MINIMAL_SUITE, name=name) + + db = self.app.instance.get_database("default") + + from lnt.server.db.v5 import V5DB + with patch.object( + V5DB, '_bump_schema_version', + side_effect=RuntimeError("simulated version increment failure"), + ): + resp = self._create_suite(payload) + self.assertIn(resp.status_code, (400, 500)) + + # Suite must NOT be in the in-memory dict. + self.assertNotIn(name, db.testsuite) + + # Suite must NOT appear in the list endpoint. + list_resp = self.client.get('/api/v5/test-suites/') + data = list_resp.get_json() + names = [item['name'] for item in data['items']] + self.assertNotIn(name, names) + + # The suite can be created successfully on retry. + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + def test_successful_create_still_works(self): + """Sanity check: normal creation still succeeds after the refactor.""" + name = 'createfail3' + payload = dict(MINIMAL_SUITE, name=name) + resp = self._create_suite(payload) + self.assertEqual(resp.status_code, 201) + + # Suite is in-memory and reachable via the API. + db = self.app.instance.get_database("default") + self.assertIn(name, db.testsuite) + + detail_resp = self.client.get(f'/api/v5/test-suites/{name}') + self.assertEqual(detail_resp.status_code, 200) + + # Per-suite endpoints work. + tests_resp = self.client.get(f'/api/v5/{name}/tests') + self.assertEqual(tests_resp.status_code, 200) + + +class TestRegistryVersionPropagation(unittest.TestCase): + """Test that the schema version mechanism detects changes.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._manage_headers = make_scoped_headers(cls.app, 'manage') + + def test_version_increments_on_create(self): + """Creating a suite should increment the schema version.""" + db = self.app.instance.get_database("default") + session = db.make_session() + row = session.query(V5SchemaVersion).get(1) + version_before = row.version if row else 0 + session.close() + + payload = dict(MINIMAL_SUITE, name='regversuite1') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + session = db.make_session() + row = session.query(V5SchemaVersion).get(1) + version_after = row.version + session.close() + + self.assertGreater(version_after, version_before) + + def test_version_increments_on_delete(self): + """Deleting a suite should increment the schema version.""" + # Create + payload = dict(MINIMAL_SUITE, name='regversuite2') + self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + + db = self.app.instance.get_database("default") + session = db.make_session() + row = session.query(V5SchemaVersion).get(1) + version_before = row.version + session.close() + + # Delete + self.client.delete( + '/api/v5/test-suites/regversuite2?confirm=true', + headers=self._manage_headers) + + session = db.make_session() + row = session.query(V5SchemaVersion).get(1) + version_after = row.version + session.close() + + self.assertGreater(version_after, version_before) + + def test_stale_version_triggers_reload(self): + """Simulate another worker bumping the version; verify reload.""" + db = self.app.instance.get_database("default") + suites_before = set(db.testsuite.keys()) + + # Artificially bump the DB version to simulate another worker + session = db.make_session() + row = session.query(V5SchemaVersion).get(1) + if row is not None: + row.version = row.version + 100 + session.commit() + bumped_version = row.version + session.close() + + # Middleware calls ensure_fresh() on every /api/v5/ request, so + # even non-per-suite endpoints like the list endpoint trigger a + # schema reload when the version is stale. + resp = self.client.get('/api/v5/test-suites/') + self.assertEqual(resp.status_code, 200) + + # After reload, the cached version should match the DB + self.assertEqual(db._schema_version, bumped_version) + + # Suites should still be present (reload reconstructs them) + suites_after = set(db.testsuite.keys()) + self.assertTrue(suites_before.issubset(suites_after)) + + def test_spa_shell_freshness_after_create(self): + """SPA shell route picks up a suite created while cache was stale.""" + db = self.app.instance.get_database("default") + + # Create a suite via the API + payload = dict(MINIMAL_SUITE, name='spa_fresh_create') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + # Simulate a stale worker: clear the in-memory cache + saved_version = db._schema_version + db._schema_version = 0 + db.testsuite.clear() + + # Hit the SPA shell route (v5_global); ensure_fresh should reload + resp = self.client.get('/v5/test-suites') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('spa_fresh_create', html) + # Version should be restored after reload + self.assertEqual(db._schema_version, saved_version) + + def test_spa_shell_freshness_v5_app_route(self): + """Per-suite SPA route picks up a suite created while cache was stale.""" + db = self.app.instance.get_database("default") + + # Create a suite via the API + payload = dict(MINIMAL_SUITE, name='spa_app_fresh') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + # Simulate a stale worker + db._schema_version = 0 + db.testsuite.clear() + + # Hit the per-suite SPA route (v5_app); should NOT 404 + resp = self.client.get('/v5/spa_app_fresh/') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('spa_app_fresh', html) + + def test_spa_shell_freshness_after_delete(self): + """SPA shell route drops a suite deleted while cache was stale.""" + db = self.app.instance.get_database("default") + + # Create then delete a suite via the API + payload = dict(MINIMAL_SUITE, name='spa_fresh_del') + self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.client.delete( + '/api/v5/test-suites/spa_fresh_del?confirm=true', + headers=self._manage_headers) + + # Simulate a stale worker that still has the deleted suite cached. + # Build a fake in-memory entry so testsuite dict is non-empty. + db._schema_version = 0 + + # Hit the SPA shell route; ensure_fresh should reload from DB + resp = self.client.get('/v5/test-suites') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertNotIn('spa_fresh_del', html) + + def test_schema_version_not_updated_on_reload_failure(self): + """If _load_schemas_from_db fails mid-rebuild, _schema_version stays + stale so the next ensure_fresh retries.""" + db = self.app.instance.get_database("default") + + # Create a suite so there is something to reload + payload = dict(MINIMAL_SUITE, name='reload_fail_suite') + resp = self.client.post( + '/api/v5/test-suites/', json=payload, + headers=self._manage_headers) + self.assertEqual(resp.status_code, 201) + + current_version = db._schema_version + # Stale the cache to force a reload + db._schema_version = 0 + + # Patch parse_schema to raise, simulating a corrupt schema row + with patch('lnt.server.db.v5.parse_schema', + side_effect=ValueError('simulated parse error')): + try: + session = db.make_session() + db.ensure_fresh(session) + session.close() + except ValueError: + pass + + # _schema_version should NOT have been updated (still stale) + self.assertEqual(db._schema_version, 0) + + # A subsequent ensure_fresh (without the patch) should succeed + session = db.make_session() + db.ensure_fresh(session) + session.close() + self.assertEqual(db._schema_version, current_version) + self.assertIn('reload_fail_suite', db.testsuite) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_tests.py b/tests/server/api/v5/test_tests.py new file mode 100644 index 000000000..5d8f9cb05 --- /dev/null +++ b/tests/server/api/v5/test_tests.py @@ -0,0 +1,319 @@ +# Tests for the v5 test entity endpoints. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, collect_all_pages, submit_run, +) + + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +class TestTestList(unittest.TestCase): + """Tests for GET /api/v5/{ts}/tests.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_list_returns_200(self): + resp = self.client.get(PREFIX + '/tests') + self.assertEqual(resp.status_code, 200) + + def test_list_has_pagination_envelope(self): + resp = self.client.get(PREFIX + '/tests') + data = resp.get_json() + self.assertIn('items', data) + self.assertIn('cursor', data) + + def test_list_empty(self): + """List tests returns items array (may be empty if no tests exist).""" + resp = self.client.get(PREFIX + '/tests') + data = resp.get_json() + self.assertIsInstance(data['items'], list) + + def test_list_with_data(self): + """After creating a test, it appears in the list.""" + unique = uuid.uuid4().hex[:8] + name = f'list-test-{unique}' + submit_run(self.client, f'list-machine-{unique}', f'rev-{unique}', + [{'name': name, 'execution_time': [1.0]}]) + + resp = self.client.get( + PREFIX + f'/tests?search=list-test-{unique}') + data = resp.get_json() + names = [t['name'] for t in data['items']] + self.assertIn(name, names) + + def test_invalid_cursor_returns_400(self): + """An invalid cursor string should return 400.""" + resp = self.client.get( + PREFIX + '/tests?cursor=not-a-valid-cursor!!!') + self.assertEqual(resp.status_code, 400) + + +class TestTestFilters(unittest.TestCase): + """Test filtering for GET /api/v5/{ts}/tests.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_filter_search(self): + """Filter tests by search (substring match).""" + unique = uuid.uuid4().hex[:8] + prefix = f'search-{unique}' + name = f'{prefix}-test' + submit_run(self.client, f'search-machine-{unique}', f'rev-{unique}', + [{'name': name, 'execution_time': [1.0]}]) + + resp = self.client.get( + PREFIX + f'/tests?search={prefix}') + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + for t in data['items']: + self.assertIn(prefix, t['name']) + + def test_filter_search_substring(self): + """Search matches a substring in the middle of a test name.""" + unique = uuid.uuid4().hex[:8] + middle = f'mid{unique}' + name = f'prefix-{middle}-suffix' + submit_run(self.client, f'sub-machine-{unique}', f'rev-{unique}', + [{'name': name, 'execution_time': [1.0]}]) + + resp = self.client.get( + PREFIX + f'/tests?search={middle}') + data = resp.get_json() + names = [t['name'] for t in data['items']] + self.assertIn(name, names) + + def test_filter_search_case_insensitive(self): + """Search is case-insensitive.""" + unique = uuid.uuid4().hex[:8] + name = f'CaSe-TeSt-{unique}' + submit_run(self.client, f'case-machine-{unique}', f'rev-{unique}', + [{'name': name, 'execution_time': [1.0]}]) + + # Search with all-lowercase version of the unique part + resp = self.client.get( + PREFIX + f'/tests?search=case-test-{unique}') + data = resp.get_json() + names = [t['name'] for t in data['items']] + self.assertIn(name, names) + + # Search with all-uppercase + resp = self.client.get( + PREFIX + f'/tests?search=CASE-TEST-{unique.upper()}') + data = resp.get_json() + names = [t['name'] for t in data['items']] + self.assertIn(name, names) + + def test_filter_no_match(self): + """Filter that matches nothing returns empty list.""" + resp = self.client.get( + PREFIX + '/tests?search=zzzz_no_match_xyz_9999') + data = resp.get_json() + self.assertEqual(len(data['items']), 0) + + def test_filter_sql_wildcards_escaped(self): + """Ensure % and _ in filter values are escaped (no SQL injection).""" + unique = uuid.uuid4().hex[:8] + name = f'esc_test_{unique}' + submit_run(self.client, f'esc-machine-{unique}', f'rev-{unique}', + [{'name': name, 'execution_time': [1.0]}]) + + resp = self.client.get( + PREFIX + f'/tests?search=esc_test_{unique}') + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + + +class TestTestPagination(unittest.TestCase): + """Exhaustive cursor pagination tests for GET /api/v5/{ts}/tests.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._prefix = f'pag-{uuid.uuid4().hex[:8]}' + for i in range(5): + submit_run(cls.client, f'{cls._prefix}-machine', + f'rev-{cls._prefix}-{i}', + [{'name': f'{cls._prefix}-test-{i}', + 'execution_time': [1.0]}]) + + def _collect_all_pages(self): + url = PREFIX + f'/tests?search={self._prefix}&limit=2' + return collect_all_pages(self, self.client, url) + + def test_pagination_collects_all_items(self): + """Paginating through all pages collects all 5 tests.""" + all_items = self._collect_all_pages() + self.assertEqual(len(all_items), 5) + + def test_no_duplicate_items_across_pages(self): + """No duplicate test names across pages.""" + all_items = self._collect_all_pages() + names = [item['name'] for item in all_items] + self.assertEqual(len(names), len(set(names))) + + +class TestTestUnknownParams(unittest.TestCase): + """Test that unknown query parameters are rejected with 400.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_tests_list_unknown_param_returns_400(self): + resp = self.client.get(PREFIX + '/tests?bogus=1') + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('bogus', data['error']['message']) + + def test_old_name_contains_param_returns_400(self): + """The removed name_contains parameter should be rejected.""" + resp = self.client.get(PREFIX + '/tests?name_contains=foo') + self.assertEqual(resp.status_code, 400) + + def test_old_name_prefix_param_returns_400(self): + """The removed name_prefix parameter should be rejected.""" + resp = self.client.get(PREFIX + '/tests?name_prefix=foo') + self.assertEqual(resp.status_code, 400) + + +class TestTestMachineMetricFilter(unittest.TestCase): + """Tests for machine= and metric= filters on GET /tests.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._prefix = uuid.uuid4().hex[:8] + + # Machine A has data for test_1 and test_2. + # Two runs on machine A to exercise DISTINCT deduplication. + cls.machine_a = f'mf-machA-{cls._prefix}' + cls.test_1 = f'mf-test1-{cls._prefix}' + cls.test_2 = f'mf-test2-{cls._prefix}' + + submit_run(cls.client, cls.machine_a, f'700-{cls._prefix}', + [{'name': cls.test_1, 'execution_time': [1.0]}, + {'name': cls.test_2, 'execution_time': [2.0]}]) + + submit_run(cls.client, cls.machine_a, f'702-{cls._prefix}', + [{'name': cls.test_1, 'execution_time': [1.1]}, + {'name': cls.test_2, 'execution_time': [2.1]}]) + + # Machine B has data for test_2 and test_3 + cls.machine_b = f'mf-machB-{cls._prefix}' + cls.test_3 = f'mf-test3-{cls._prefix}' + + submit_run(cls.client, cls.machine_b, f'701-{cls._prefix}', + [{'name': cls.test_2, 'execution_time': [3.0]}, + {'name': cls.test_3, 'execution_time': [4.0]}]) + + # test_4 has no execution_time samples -- submit with compile_time + # only on a separate machine so it exists but is excluded by both + # machine= and metric=execution_time filters. + cls.test_4 = f'mf-test4-{cls._prefix}' + submit_run(cls.client, f'mf-machC-{cls._prefix}', f'703-{cls._prefix}', + [{'name': cls.test_4, 'compile_time': [0.5]}]) + + def test_filter_by_machine_a(self): + resp = self.client.get( + PREFIX + f'/tests?machine={self.machine_a}' + f'&search=mf-test') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + self.assertIn(self.test_1, names) + self.assertIn(self.test_2, names) + + def test_filter_by_machine_b(self): + resp = self.client.get( + PREFIX + f'/tests?machine={self.machine_b}' + f'&search=mf-test') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + self.assertIn(self.test_2, names) + self.assertIn(self.test_3, names) + + def test_filter_by_metric(self): + resp = self.client.get( + PREFIX + '/tests?metric=execution_time' + '&search=mf-test') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + # test_4 has no execution_time samples, should be excluded + self.assertIn(self.test_1, names) + self.assertIn(self.test_2, names) + self.assertIn(self.test_3, names) + self.assertNotIn(self.test_4, names) + + def test_filter_by_machine_and_metric(self): + resp = self.client.get( + PREFIX + f'/tests?machine={self.machine_a}' + f'&metric=execution_time&search=mf-test') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + self.assertEqual(names, {self.test_1, self.test_2}) + + def test_filter_by_machine_and_search(self): + resp = self.client.get( + PREFIX + f'/tests?machine={self.machine_a}' + f'&search=mf-test1-{self._prefix}') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + self.assertEqual(names, {self.test_1}) + + def test_unknown_machine_returns_404(self): + resp = self.client.get( + PREFIX + '/tests?machine=nonexistent-machine-xyz') + self.assertEqual(resp.status_code, 404) + + def test_unknown_metric_returns_400(self): + resp = self.client.get( + PREFIX + '/tests?metric=nonexistent_metric') + self.assertEqual(resp.status_code, 400) + + def test_multiple_samples_deduplicated(self): + """Machine A has two runs with samples for test_1 — test_1 + should still appear only once in the results (DISTINCT).""" + resp = self.client.get( + PREFIX + f'/tests?machine={self.machine_a}' + f'&search=mf-test1-{self._prefix}') + self.assertEqual(resp.status_code, 200) + items = resp.get_json()['items'] + names = [t['name'] for t in items] + self.assertEqual(names, [self.test_1]) + + def test_no_filters_includes_all(self): + resp = self.client.get( + PREFIX + '/tests?search=mf-test') + self.assertEqual(resp.status_code, 200) + names = {t['name'] for t in resp.get_json()['items']} + # All 4 tests should appear (including test_4 with only compile_time) + self.assertIn(self.test_1, names) + self.assertIn(self.test_2, names) + self.assertIn(self.test_3, names) + self.assertIn(self.test_4, names) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/api/v5/test_trends.py b/tests/server/api/v5/test_trends.py new file mode 100644 index 000000000..a126fe2b0 --- /dev/null +++ b/tests/server/api/v5/test_trends.py @@ -0,0 +1,469 @@ +# Tests for the v5 trends endpoint (POST /api/v5/{ts}/trends). +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py --db-version 5.0 %t.instance \ +# RUN: -- python %s %t.instance +# END. + +import datetime +import sys +import os +import unittest +import uuid + +sys.path.insert(0, os.path.dirname(__file__)) +from v5_test_helpers import ( + create_app, create_client, + create_machine, create_commit, create_run, + create_test, create_sample, +) + +TS = 'nts' +PREFIX = f'/api/v5/{TS}' + + +def _setup_trends_data(app, unique=None): + """Create two machines, two tests, and several runs with samples. + + Uses direct DB helpers for timestamp control. All commits are assigned + ordinals so they participate in trends queries. + + Returns a dict with metadata for assertions. + """ + if unique is None: + unique = uuid.uuid4().hex[:8] + + machine_a_name = f'trends-m-a-{unique}' + machine_b_name = f'trends-m-b-{unique}' + test1_name = f'trends-t1/{unique}' + test2_name = f'trends-t2/{unique}' + + db = app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + machine_a = create_machine(session, ts, name=machine_a_name) + machine_b = create_machine(session, ts, name=machine_b_name) + test1 = create_test(session, ts, name=test1_name) + test2 = create_test(session, ts, name=test2_name) + + # Machine A: 3 commits, each with 2 tests + # Commit ordinal 10000: test1=4.0, test2=16.0 -> geomean = 8.0 + # Commit ordinal 10001: test1=9.0, test2=9.0 -> geomean = 9.0 + # Commit ordinal 10002: test1=1.0, test2=100.0 -> geomean = 10.0 + for i, (v1, v2) in enumerate([(4.0, 16.0), (9.0, 9.0), (1.0, 100.0)]): + commit = create_commit( + session, ts, commit=f'{100 + i}-{unique}') + commit.ordinal = 10000 + i + run = create_run( + session, ts, machine_a, commit, + submitted_at=datetime.datetime(2024, 6, 1 + i, 12, 0, 0, tzinfo=datetime.timezone.utc)) + create_sample(session, ts, run, test1, execution_time=v1) + create_sample(session, ts, run, test2, execution_time=v2) + + # Machine B: 1 commit with 1 test (earlier ordinal) + commit_b = create_commit( + session, ts, commit=f'200-{unique}') + commit_b.ordinal = 9000 + run_b = create_run( + session, ts, machine_b, commit_b, + submitted_at=datetime.datetime(2024, 5, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + create_sample(session, ts, run_b, test1, execution_time=25.0) + + session.commit() + session.close() + + return { + 'machine_a': machine_a_name, + 'machine_b': machine_b_name, + 'test1': test1_name, + 'test2': test2_name, + } + + +def _setup_single_commit(app, *, values, commit_prefix, submitted_at, + ordinal): + """Create a machine with two tests and one commit for edge-case testing. + + *values* is a dict mapping test suffix ('t1', 't2') to sample value. + *ordinal* is the commit's ordinal position (required for trends). + Returns the machine name. + """ + unique = uuid.uuid4().hex[:8] + machine_name = f'trends-{commit_prefix}-m-{unique}' + + db = app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + machine = create_machine(session, ts, name=machine_name) + test1 = create_test(session, ts, name=f'trends-{commit_prefix}-t1/{unique}') + test2 = create_test(session, ts, name=f'trends-{commit_prefix}-t2/{unique}') + commit = create_commit(session, ts, commit=f'{commit_prefix}-{unique}') + commit.ordinal = ordinal + run = create_run( + session, ts, machine, commit, submitted_at=submitted_at) + create_sample(session, ts, run, test1, execution_time=values['t1']) + create_sample(session, ts, run, test2, execution_time=values['t2']) + + session.commit() + session.close() + return machine_name + + +class TestTrendsErrors(unittest.TestCase): + """Tests for error responses from the trends endpoint.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + + def test_unknown_metric_returns_400(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'nonexistent_metric'}) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('error', data) + + def test_unknown_machine_returns_404(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': ['nonexistent-machine-xyz']}) + self.assertEqual(resp.status_code, 404) + data = resp.get_json() + self.assertIn('error', data) + + def test_unknown_fields_rejected(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', 'bogus_field': 'value'}) + self.assertEqual(resp.status_code, 422) + + def test_invalid_last_n_zero_returns_422(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', 'last_n': 0}) + self.assertEqual(resp.status_code, 422) + + def test_invalid_last_n_negative_returns_422(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', 'last_n': -1}) + self.assertEqual(resp.status_code, 422) + + def test_non_integer_last_n_returns_422(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', 'last_n': 'abc'}) + self.assertEqual(resp.status_code, 422) + + def test_old_after_time_param_rejected(self): + """Sending the removed after_time parameter returns 422.""" + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'after_time': '2024-01-01T00:00:00Z'}) + self.assertEqual(resp.status_code, 422) + + def test_old_before_time_param_rejected(self): + """Sending the removed before_time parameter returns 422.""" + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'before_time': '2024-01-01T00:00:00Z'}) + self.assertEqual(resp.status_code, 422) + + def test_missing_metric_returns_422(self): + resp = self.client.post( + PREFIX + '/trends', json={}) + self.assertEqual(resp.status_code, 422) + + def test_non_numeric_metric_returns_400(self): + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'hash'}) + self.assertEqual(resp.status_code, 400) + data = resp.get_json() + self.assertIn('error', data) + self.assertIn("'real'", data['error']['message']) + + +class TestTrendsValidQuery(unittest.TestCase): + """Tests for valid queries that return aggregated trends data.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + cls._data = _setup_trends_data(cls.app) + + def test_returns_200(self): + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a']]}) + self.assertEqual(resp.status_code, 200) + + def test_response_structure(self): + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a']]}) + data = resp.get_json() + self.assertIn('metric', data) + self.assertEqual(data['metric'], 'execution_time') + self.assertIn('items', data) + self.assertIsInstance(data['items'], list) + + def test_item_structure(self): + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a']]}) + data = resp.get_json() + self.assertGreater(len(data['items']), 0) + item = data['items'][0] + self.assertIn('machine', item) + self.assertIn('commit', item) + self.assertIn('ordinal', item) + self.assertIn('submitted_at', item) + self.assertIn('value', item) + self.assertIn('tag', item) + self.assertIsInstance(item['commit'], str) + # ordinal is always present (never null) for trends + self.assertIsNotNone(item['ordinal']) + self.assertIsInstance(item['ordinal'], int) + + def test_geomean_correctness(self): + """Verify geomean is computed correctly from known values.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a']]}) + data = resp.get_json() + items = data['items'] + self.assertEqual(len(items), 3) + + # Items should be sorted by ordinal + # Commit ordinal 100: geomean(4, 16) = 8.0 + # Commit ordinal 101: geomean(9, 9) = 9.0 + # Commit ordinal 102: geomean(1, 100) = 10.0 + values = [item['value'] for item in items] + self.assertAlmostEqual(values[0], 8.0, places=5) + self.assertAlmostEqual(values[1], 9.0, places=5) + self.assertAlmostEqual(values[2], 10.0, places=5) + + def test_machine_filter(self): + """Only requested machines are included.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a']]}) + data = resp.get_json() + machines = {item['machine'] for item in data['items']} + self.assertEqual(machines, {d['machine_a']}) + + def test_no_machine_filter_returns_all(self): + """Omitting machine returns data for all machines.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time'}) + data = resp.get_json() + machines = {item['machine'] for item in data['items']} + self.assertIn(d['machine_a'], machines) + self.assertIn(d['machine_b'], machines) + + def test_multiple_machines(self): + """Multiple machines in a single request.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a'], d['machine_b']]}) + data = resp.get_json() + machines = {item['machine'] for item in data['items']} + self.assertEqual(machines, {d['machine_a'], d['machine_b']}) + + def test_last_n_filter(self): + """last_n limits to the most recent N commits by ordinal.""" + d = self._data + # Ordinals: machine_b has 9000, machine_a has 10000, 10001, 10002 + # last_n=3 should return ordinals 10000, 10001, 10002 (top 3), + # excluding 9000 + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'last_n': 3, + 'machine': [d['machine_a'], d['machine_b']]}) + data = resp.get_json() + machines = {item['machine'] for item in data['items']} + self.assertIn(d['machine_a'], machines) + self.assertNotIn(d['machine_b'], machines) + # Machine A should have all 3 commits + a_items = [i for i in data['items'] if i['machine'] == d['machine_a']] + self.assertEqual(len(a_items), 3) + + def test_last_n_with_no_matching_data(self): + """last_n=1 with machine filter that has no data at top commit.""" + d = self._data + # last_n=1 returns ordinal 10002 only; machine_b has no data there + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'last_n': 1, + 'machine': [d['machine_b']]}) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(data['items']), 0) + + def test_last_n_none_returns_all_ordered(self): + """Omitting last_n returns all commits with ordinals.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a'], d['machine_b']]}) + data = resp.get_json() + # Should include all 4 items: 3 from machine_a + 1 from machine_b + self.assertEqual(len(data['items']), 4) + + def test_sorted_by_machine_then_ordinal(self): + """Items are sorted by machine name ascending, then ordinal.""" + d = self._data + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [d['machine_a'], d['machine_b']]}) + data = resp.get_json() + items = data['items'] + + # Check overall ordering: machine names should be non-decreasing + machine_names = [item['machine'] for item in items] + self.assertEqual(machine_names, sorted(machine_names)) + + # Within each machine, ordinals should be ascending + from itertools import groupby + for _, group in groupby(items, key=lambda x: x['machine']): + ordinals = [item['ordinal'] for item in group] + self.assertEqual(ordinals, sorted(ordinals)) + + def test_unordered_commits_excluded(self): + """Commits without ordinals are excluded from trends results.""" + unique = uuid.uuid4().hex[:8] + machine_name = f'trends-unord-m-{unique}' + + db = self.app.instance.get_database("default") + session = db.make_session() + ts = db.testsuite[TS] + + machine = create_machine(session, ts, name=machine_name) + test = create_test(session, ts, name=f'trends-unord-t/{unique}') + # Create commit WITHOUT setting ordinal + commit = create_commit(session, ts, commit=f'unord-{unique}') + run = create_run(session, ts, machine, commit) + create_sample(session, ts, run, test, execution_time=42.0) + session.commit() + session.close() + + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [machine_name]}) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(data['items']), 0) + + +class TestTrendsEdgeCases(unittest.TestCase): + """Tests for edge cases: zero values, etc.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + # One commit with test1=0.0 (should be excluded) and test2=25.0 + cls._machine_name = _setup_single_commit( + cls.app, + values={'t1': 0.0, 't2': 25.0}, + commit_prefix='edge', + ordinal=300, + submitted_at=datetime.datetime(2024, 7, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + + def test_geomean_excludes_zero_values(self): + """Zero values are excluded from the geomean computation.""" + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [self._machine_name]}) + data = resp.get_json() + self.assertEqual(len(data['items']), 1) + # Only test2=25.0 is included (test1=0.0 is excluded) + self.assertAlmostEqual(data['items'][0]['value'], 25.0, places=5) + + +class TestTrendsAllZeroGroup(unittest.TestCase): + """Test that a group where ALL values are zero/negative is excluded.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + # Commit with all-zero values -- should produce no result + cls._machine_name = _setup_single_commit( + cls.app, + values={'t1': 0.0, 't2': 0.0}, + commit_prefix='allzero', + ordinal=400, + submitted_at=datetime.datetime(2024, 8, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + + def test_all_zero_group_excluded(self): + """A group where every sample is zero produces no result.""" + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [self._machine_name]}) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(data['items']), 0) + + +class TestTrendsNegativeValues(unittest.TestCase): + """Test that negative values are excluded from the geomean.""" + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.app = create_app(sys.argv[1]) + cls.client = create_client(cls.app) + # One negative, one positive -- geomean should use only the positive + cls._machine_name = _setup_single_commit( + cls.app, + values={'t1': -5.0, 't2': 16.0}, + commit_prefix='neg', + ordinal=500, + submitted_at=datetime.datetime(2024, 8, 2, 12, 0, 0, tzinfo=datetime.timezone.utc)) + + def test_negative_values_excluded(self): + """Negative values are excluded; geomean uses only positive values.""" + resp = self.client.post( + PREFIX + '/trends', + json={'metric': 'execution_time', + 'machine': [self._machine_name]}) + data = resp.get_json() + self.assertEqual(resp.status_code, 200) + self.assertEqual(len(data['items']), 1) + # Only test2=16.0 contributes + self.assertAlmostEqual(data['items'][0]['value'], 16.0, places=5) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]]) diff --git a/tests/server/api/v5/v5_test_helpers.py b/tests/server/api/v5/v5_test_helpers.py new file mode 100644 index 000000000..0de2ffb27 --- /dev/null +++ b/tests/server/api/v5/v5_test_helpers.py @@ -0,0 +1,279 @@ +"""Shared helpers for v5 API tests. + +Provides: +- ``create_app`` -- Create a Flask app from an instance path +- ``create_client`` -- Return a Flask test client +- Auth helpers for creating API keys and headers +- Data creation helpers using V5TestSuiteDB methods +- API-based fixture helpers (submit_run, submit_regression, etc.) +""" + +import datetime +import hashlib +import uuid + +import lnt.server.ui.app +from lnt.server.db.v5.models import utcnow + + +# --------------------------------------------------------------------------- +# Application & client helpers +# --------------------------------------------------------------------------- + +def create_app(instance_path): + """Create a Flask app backed by the given LNT instance.""" + app = lnt.server.ui.app.App.create_standalone(instance_path) + app.testing = True + return app + + +def create_client(app): + """Return a Flask test client for *app*.""" + return app.test_client() + + +# --------------------------------------------------------------------------- +# Auth helpers +# --------------------------------------------------------------------------- + +def make_api_key(session, name, scope, raw_token): + """Insert an APIKey row and return the Bearer header dict.""" + from lnt.server.api.v5.auth import APIKey + key_hash = hashlib.sha256(raw_token.encode('utf-8')).hexdigest() + api_key = APIKey( + name=name, + key_prefix=raw_token[:8], + key_hash=key_hash, + scope=scope, + created_at=utcnow(), + is_active=True, + ) + session.add(api_key) + session.commit() + return {'Authorization': f'Bearer {raw_token}'} + + +def admin_headers(): + """Auth headers using the bootstrap api_auth_token (admin scope).""" + return {'Authorization': 'Bearer test_token'} + + +def make_scoped_headers(app, scope_name): + """Create an API key with the given scope and return Bearer headers.""" + db = app.instance.get_database("default") + session = db.make_session() + token = f'{scope_name}token_{uuid.uuid4().hex[:20]}' + headers = make_api_key(session, f'test-{scope_name}', scope_name, token) + session.close() + return headers + + +# --------------------------------------------------------------------------- +# Data creation helpers -- use V5TestSuiteDB methods +# --------------------------------------------------------------------------- + +def create_machine(session, ts, name='test-machine', **info_fields): + """Create a Machine via V5TestSuiteDB and return it.""" + schema_fields = {k: v for k, v in info_fields.items() + if k in ts._machine_field_names} + params = {k: v for k, v in info_fields.items() + if k not in ts._machine_field_names} + return ts.get_or_create_machine( + session, name, parameters=params or None, **schema_fields) + + +def create_commit(session, ts, commit='rev-1', **metadata): + """Create a Commit via V5TestSuiteDB and return it.""" + return ts.get_or_create_commit(session, commit, **metadata) + + +def create_run(session, ts, machine, commit, + submitted_at=None): + """Create a Run via V5TestSuiteDB and return it.""" + if submitted_at is None: + submitted_at = datetime.datetime(2024, 1, 1, 12, 0, 0, + tzinfo=datetime.timezone.utc) + return ts.create_run(session, machine, commit=commit, + submitted_at=submitted_at) + + +def create_test(session, ts, name='test.suite/benchmark'): + """Create a Test via V5TestSuiteDB and return the ORM object.""" + ts.get_or_create_tests(session, [name]) + return session.query(ts.Test).filter(ts.Test.name == name).one() + + +def create_sample(session, ts, run, test, **field_values): + """Create a Sample via V5TestSuiteDB and return the ORM object.""" + ts.create_samples(session, run, [{'test_id': test.id, **field_values}]) + return ( + session.query(ts.Sample) + .filter(ts.Sample.run_id == run.id, ts.Sample.test_id == test.id) + .order_by(ts.Sample.id.desc()) + .first() + ) + + +def create_regression(session, ts, title='Test Regression', + state=0, indicators=None, commit=None, + notes=None, bug=None): + """Create a Regression (optionally with indicators) and return it. + + *indicators* is a list of dicts with keys machine_id, test_id, metric. + """ + indicator_list = indicators or [] + return ts.create_regression( + session, title, indicator_list, + state=state, commit=commit, notes=notes, bug=bug) + + +# --------------------------------------------------------------------------- +# Pagination helpers +# --------------------------------------------------------------------------- + +def collect_all_pages(test_case, client, url, page_limit=20): + """Traverse all pages of a cursor-paginated endpoint. + + Returns a list of all items collected across every page. Fails the + *test_case* if more than *page_limit* pages are fetched (infinite-loop + guard). + """ + all_items = [] + resp = client.get(url) + test_case.assertEqual(resp.status_code, 200) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages = 1 + while cursor: + resp = client.get(url + f'&cursor={cursor}') + test_case.assertEqual(resp.status_code, 200) + data = resp.get_json() + all_items.extend(data['items']) + cursor = data['cursor']['next'] + pages += 1 + if pages > page_limit: + test_case.fail("Too many pages; possible infinite loop") + return all_items + + +# --------------------------------------------------------------------------- +# API-based fixture helpers +# --------------------------------------------------------------------------- + +def set_ordinal(client, commit, ordinal, testsuite='nts'): + """Assign an ordinal to a commit via PATCH /commits/{value}.""" + resp = client.patch( + f'/api/v5/{testsuite}/commits/{commit}', + json={'ordinal': ordinal}, + headers=admin_headers(), + ) + assert resp.status_code == 200, \ + "set_ordinal(%s, %d) failed: %s" % (commit, ordinal, resp.data) + + +def submit_run(client, machine_name, commit, tests, + machine_info=None, testsuite='nts', run_uuid=None): + """Submit a run via POST and return response JSON (includes run_uuid).""" + machine = {'name': machine_name} + if machine_info: + machine.update(machine_info) + payload = { + 'format_version': '5', + 'machine': machine, + 'commit': commit, + 'tests': tests, + } + if run_uuid is not None: + payload['uuid'] = run_uuid + resp = client.post(f'/api/v5/{testsuite}/runs', json=payload, + headers=admin_headers()) + assert resp.status_code == 201, ( + f"Run submission failed: {resp.get_json()}") + return resp.get_json() + + +def submit_regression(client, indicators=None, state='active', + title=None, commit=None, notes=None, bug=None, + testsuite='nts'): + """Create a regression via POST and return response JSON. + + *indicators* is a list of {machine, test, metric} dicts. + """ + body = {'state': state} + if indicators: + body['indicators'] = indicators + if title: + body['title'] = title + if commit: + body['commit'] = commit + if notes: + body['notes'] = notes + if bug: + body['bug'] = bug + resp = client.post(f'/api/v5/{testsuite}/regressions', + json=body, headers=admin_headers()) + assert resp.status_code == 201, ( + f"Regression creation failed: {resp.get_json()}") + return resp.get_json() + + +def submit_indicator_add(client, regression_uuid, indicators, + testsuite='nts'): + """Add indicators to a regression via POST and return response JSON.""" + resp = client.post( + f'/api/v5/{testsuite}/regressions/{regression_uuid}/indicators', + json={'indicators': indicators}, + headers=admin_headers()) + assert resp.status_code == 200, ( + f"Indicator add failed: {resp.get_json()}") + return resp.get_json() + + +def submit_indicator_remove(client, regression_uuid, indicator_uuids, + testsuite='nts'): + """Remove indicators from a regression via DELETE and return response JSON.""" + resp = client.delete( + f'/api/v5/{testsuite}/regressions/{regression_uuid}/indicators', + json={'indicator_uuids': indicator_uuids}, + headers=admin_headers()) + assert resp.status_code == 200, ( + f"Indicator remove failed: {resp.get_json()}") + return resp.get_json() + + +def make_profile_base64(): + """Create a base64-encoded profile blob for use in test submissions. + + Returns a base64 string containing a valid profile with two functions + ('main' with 2 instructions, 'helper' with 1 instruction) and two + counters ('cycles', 'branch-misses'). + """ + import base64 + from lnt.testing.profile.profilev1impl import ProfileV1 + from lnt.testing.profile.profilev2impl import ProfileV2 + + v1_data = { + 'disassembly-format': 'raw', + 'counters': {'cycles': 1000, 'branch-misses': 50}, + 'functions': { + 'main': { + 'counters': {'cycles': 80.0, 'branch-misses': 10.0}, + 'data': [ + [{'cycles': 50.0, 'branch-misses': 5.0}, 0x1000, + 'push rbp'], + [{'cycles': 30.0, 'branch-misses': 5.0}, 0x1004, + 'mov rsp, rbp'], + ], + }, + 'helper': { + 'counters': {'cycles': 20.0, 'branch-misses': 3.0}, + 'data': [ + [{'cycles': 20.0, 'branch-misses': 3.0}, 0x2000, 'ret'], + ], + }, + }, + } + v1 = ProfileV1(v1_data) + v2 = ProfileV2.upgrade(v1) + return base64.b64encode(v2.serialize()).decode('ascii') diff --git a/tests/server/db/CreateV4TestSuiteInstance.py b/tests/server/db/CreateV4TestSuiteInstance.py index cda3e2de1..5db7c1cb6 100644 --- a/tests/server/db/CreateV4TestSuiteInstance.py +++ b/tests/server/db/CreateV4TestSuiteInstance.py @@ -7,6 +7,7 @@ import datetime import sys +import uuid import lnt.server.instance from lnt.server.db.fieldchange import RegressionState @@ -56,11 +57,13 @@ session.add(sample) field_change = ts_db.FieldChange(order, order2, machine, test, list(sample.get_primary_fields())[0].id) +field_change.uuid = str(uuid.uuid4()) session.add(field_change) field_change2 = ts_db.FieldChange(order2, order3, machine, test, list(sample.get_primary_fields())[1].id) +field_change2.uuid = str(uuid.uuid4()) session.add(field_change2) TEST_TITLE = "Some regression title" diff --git a/tests/server/db/v5/__init__.py b/tests/server/db/v5/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/server/db/v5/test_crud.py b/tests/server/db/v5/test_crud.py new file mode 100644 index 000000000..273b7bcc3 --- /dev/null +++ b/tests/server/db/v5/test_crud.py @@ -0,0 +1,791 @@ +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: python %s +# END. + +import os +import sys +import unittest + +import sqlalchemy +import sqlalchemy.exc +import sqlalchemy.orm + +from lnt.server.db.v5.schema import parse_schema +from lnt.server.db.v5.models import create_suite_models +from lnt.server.db.v5 import V5TestSuiteDB, VALID_REGRESSION_STATES + + +def _make_engine(): + db_uri = os.environ.get('LNT_TEST_DB_URI') + db_name = os.environ.get('LNT_TEST_DB_NAME') + if not db_uri or not db_name: + raise unittest.SkipTest( + "LNT_TEST_DB_URI / LNT_TEST_DB_NAME not set") + return sqlalchemy.create_engine(f"{db_uri}/{db_name}") + + +def _test_schema(): + return parse_schema({ + "name": "ts", + "metrics": [ + {"name": "execution_time", "type": "real"}, + ], + "commit_fields": [ + {"name": "author", "searchable": True}, + ], + "machine_fields": [ + {"name": "hardware", "searchable": True}, + ], + }) + + +class _CRUDTestBase(unittest.TestCase): + """Shared setup for CRUD method tests.""" + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + +class TestUpdateCommit(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_set_ordinal(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uc-ord-1") + self.assertIsNone(c.ordinal) + + self.tsdb.update_commit(session, c, ordinal=100) + session.commit() + + fetched = self.tsdb.get_commit(session, commit="uc-ord-1") + self.assertEqual(fetched.ordinal, 100) + session.close() + + def test_clear_ordinal(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uc-ord-2") + self.tsdb.update_commit(session, c, ordinal=200) + session.commit() + self.assertEqual(c.ordinal, 200) + + self.tsdb.update_commit(session, c, clear_ordinal=True) + session.commit() + + fetched = self.tsdb.get_commit(session, commit="uc-ord-2") + self.assertIsNone(fetched.ordinal) + session.close() + + def test_set_metadata(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uc-meta-1") + self.assertIsNone(c.author) + + self.tsdb.update_commit(session, c, author="Alice") + session.commit() + + fetched = self.tsdb.get_commit(session, commit="uc-meta-1") + self.assertEqual(fetched.author, "Alice") + session.close() + + def test_overwrite_metadata(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uc-meta-2", author="Bob") + session.commit() + self.assertEqual(c.author, "Bob") + + self.tsdb.update_commit(session, c, author="Charlie") + session.commit() + + fetched = self.tsdb.get_commit(session, commit="uc-meta-2") + self.assertEqual(fetched.author, "Charlie") + session.close() + + def test_set_ordinal_and_metadata_together(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uc-both-1") + self.tsdb.update_commit(session, c, ordinal=300, author="Dave") + session.commit() + + fetched = self.tsdb.get_commit(session, commit="uc-both-1") + self.assertEqual(fetched.ordinal, 300) + self.assertEqual(fetched.author, "Dave") + session.close() + + +class TestRegressionCRUD(_CRUDTestBase): + + def test_create_regression_with_indicators(self): + """Create a regression with machine/test/metric indicators.""" + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "reg-m") + test_id = self.tsdb.get_or_create_tests(session, ["reg-test"])["reg-test"] + session.flush() + + reg = self.tsdb.create_regression( + session, "Perf regression", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + bug="BUG-123", state=0) + session.commit() + + self.assertIsNotNone(reg.uuid) + self.assertEqual(reg.title, "Perf regression") + self.assertEqual(reg.bug, "BUG-123") + self.assertEqual(reg.state, 0) + self.assertIsNone(reg.notes) + self.assertIsNone(reg.commit_id) + + indicators = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg.id) + .all() + ) + self.assertEqual(len(indicators), 1) + self.assertEqual(indicators[0].machine_id, machine.id) + self.assertEqual(indicators[0].test_id, test_id) + self.assertEqual(indicators[0].metric, "execution_time") + self.assertIsNotNone(indicators[0].uuid) + session.close() + + def test_create_regression_with_notes_and_commit(self): + session = self.Session() + commit = self.tsdb.get_or_create_commit(session, "reg-commit-1") + session.flush() + + reg = self.tsdb.create_regression( + session, "Noted regression", [], + notes="Caused by vectorizer change", + commit=commit, + state=1) + session.commit() + + self.assertEqual(reg.notes, "Caused by vectorizer change") + self.assertEqual(reg.commit_id, commit.id) + session.close() + + def test_create_regression_with_empty_indicators(self): + session = self.Session() + reg = self.tsdb.create_regression( + session, "Empty regression", [], state=0) + session.commit() + self.assertIsNotNone(reg.id) + indicators = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg.id) + .all() + ) + self.assertEqual(len(indicators), 0) + session.close() + + def test_update_regression_notes(self): + session = self.Session() + reg = self.tsdb.create_regression( + session, "title", [], state=0) + session.commit() + + self.tsdb.update_regression( + session, reg, notes="New notes") + session.commit() + + fetched = self.tsdb.get_regression(session, id=reg.id) + self.assertEqual(fetched.notes, "New notes") + session.close() + + def test_update_regression_commit(self): + session = self.Session() + commit = self.tsdb.get_or_create_commit(session, "upd-reg-c") + reg = self.tsdb.create_regression( + session, "title", [], state=0) + session.commit() + + self.tsdb.update_regression( + session, reg, commit=commit) + session.commit() + self.assertEqual(reg.commit_id, commit.id) + + self.tsdb.update_regression( + session, reg, commit=None) + session.commit() + self.assertIsNone(reg.commit_id) + session.close() + + def test_update_regression_state_and_title(self): + session = self.Session() + reg = self.tsdb.create_regression( + session, "original", [], state=0) + session.commit() + + self.tsdb.update_regression( + session, reg, title="Updated", state=1) + session.commit() + + fetched = self.tsdb.get_regression(session, uuid=reg.uuid) + self.assertEqual(fetched.title, "Updated") + self.assertEqual(fetched.state, 1) + session.close() + + def test_delete_regression_cascades_to_indicators(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "del-reg-m") + test_id = self.tsdb.get_or_create_tests(session, ["del-reg-test"])["del-reg-test"] + session.flush() + + reg = self.tsdb.create_regression( + session, "to delete", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + session.commit() + reg_id = reg.id + + self.tsdb.delete_regression(session, reg_id) + session.commit() + + self.assertIsNone(self.tsdb.get_regression(session, id=reg_id)) + indicators = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg_id) + .all() + ) + self.assertEqual(len(indicators), 0) + session.close() + + def test_list_regressions_by_state(self): + session = self.Session() + self.tsdb.create_regression( + session, "active-one", [], state=1) + self.tsdb.create_regression( + session, "detected-one", [], state=0) + session.commit() + + active = self.tsdb.list_regressions(session, state=1) + self.assertGreater(len(active), 0) + self.assertTrue( + all(r.state == 1 for r in active)) + session.close() + + def test_update_regression_clear_nullable_fields(self): + """Verify _UNSET pattern allows clearing nullable fields to None.""" + cases = [ + ("notes", "some notes"), + ("bug", "BUG-1"), + ] + for field, initial in cases: + with self.subTest(field=field): + session = self.Session() + reg = self.tsdb.create_regression( + session, "title", [], **{field: initial}, state=0) + session.commit() + self.assertEqual(getattr(reg, field), initial) + + self.tsdb.update_regression(session, reg, **{field: None}) + session.commit() + self.assertIsNone(getattr(reg, field)) + session.close() + + def test_update_regression_clear_title(self): + """Verify _UNSET pattern allows clearing title to None.""" + session = self.Session() + reg = self.tsdb.create_regression( + session, "a title", [], state=0) + session.commit() + + self.tsdb.update_regression(session, reg, title=None) + session.commit() + self.assertIsNone(reg.title) + session.close() + + def test_old_state_values_rejected(self): + """States 5 and 6 (old staged/detected_fixed) must be rejected.""" + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.create_regression( + session, "old state", [], state=5) + with self.assertRaises(ValueError): + self.tsdb.create_regression( + session, "old state", [], state=6) + session.close() + + +class TestDeleteCommit(unittest.TestCase): + """Deletion cascades to runs/samples but is blocked by Regressions.""" + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_delete_commit_cascades_to_runs_and_samples(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "del-commit-m") + commit = self.tsdb.get_or_create_commit(session, "del-commit-c1") + test_id = self.tsdb.get_or_create_tests(session, ["del-commit-test"])["del-commit-test"] + run = self.tsdb.create_run( + session, machine, commit=commit) + self.tsdb.create_samples(session, run, [{ + "test_id": test_id, + "execution_time": 1.0, + }]) + session.flush() + + commit_id = commit.id + run_id = run.id + + self.tsdb.delete_commit(session, commit_id) + session.commit() + + # Commit, run, and samples are gone + self.assertIsNone( + session.query(self.tsdb.Commit).get(commit_id)) + self.assertIsNone( + session.query(self.tsdb.Run).get(run_id)) + samples = ( + session.query(self.tsdb.Sample) + .filter_by(run_id=run_id) + .all() + ) + self.assertEqual(len(samples), 0) + session.close() + + def test_delete_commit_blocked_by_regression_commit_ref(self): + """Cannot delete a commit referenced by a Regression's commit_id.""" + session = self.Session() + commit = self.tsdb.get_or_create_commit(session, "del-commit-reg-c") + session.flush() + + self.tsdb.create_regression( + session, "blocking reg", [], + commit=commit, state=0) + session.flush() + + with self.assertRaises(ValueError): + self.tsdb.delete_commit(session, commit.id) + + session.close() + + def test_delete_nonexistent_commit(self): + session = self.Session() + self.tsdb.delete_commit(session, 999999) + session.close() + + +class TestGetTest(_CRUDTestBase): + + def test_get_test_by_name(self): + session = self.Session() + self.tsdb.get_or_create_tests(session, ["get-test-1"]) + session.commit() + + fetched = self.tsdb.get_test(session, name="get-test-1") + self.assertIsNotNone(fetched) + self.assertEqual(fetched.name, "get-test-1") + session.close() + + def test_get_test_by_id(self): + session = self.Session() + t_id = self.tsdb.get_or_create_tests(session, ["get-test-2"])["get-test-2"] + session.commit() + + fetched = self.tsdb.get_test(session, id=t_id) + self.assertIsNotNone(fetched) + self.assertEqual(fetched.name, "get-test-2") + session.close() + + def test_get_test_not_found(self): + session = self.Session() + self.assertIsNone(self.tsdb.get_test(session, name="nonexistent")) + session.close() + + def test_get_test_no_args_raises(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.get_test(session) + session.close() + + +class TestListSamples(_CRUDTestBase): + + def test_list_samples_by_run(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ls-m") + commit = self.tsdb.get_or_create_commit(session, "ls-c") + test_id = self.tsdb.get_or_create_tests(session, ["ls-test"])["ls-test"] + run = self.tsdb.create_run(session, machine, commit=commit) + self.tsdb.create_samples(session, run, [ + {"test_id": test_id, "execution_time": 1.0}, + ]) + session.commit() + + results = self.tsdb.list_samples(session, run_id=run.id) + self.assertEqual(len(results), 1) + self.assertAlmostEqual(results[0].execution_time, 1.0) + session.close() + + def test_list_samples_by_test(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ls-m2") + commit = self.tsdb.get_or_create_commit(session, "ls-c2") + _ids = self.tsdb.get_or_create_tests(session, ["ls-test-a", "ls-test-b"]) + test_a_id = _ids["ls-test-a"] + test_b_id = _ids["ls-test-b"] + run = self.tsdb.create_run(session, machine, commit=commit) + self.tsdb.create_samples(session, run, [ + {"test_id": test_a_id, "execution_time": 1.0}, + {"test_id": test_b_id, "execution_time": 2.0}, + ]) + session.commit() + + results = self.tsdb.list_samples(session, test_id=test_a_id) + test_ids = [s.test_id for s in results] + self.assertTrue(all(tid == test_a_id for tid in test_ids)) + session.close() + + def test_list_samples_empty(self): + session = self.Session() + results = self.tsdb.list_samples(session, run_id=999999) + self.assertEqual(len(results), 0) + session.close() + + +class TestUpdateMachine(_CRUDTestBase): + + def test_update_machine_name(self): + session = self.Session() + m = self.tsdb.get_or_create_machine(session, "upd-m-1") + session.commit() + + self.tsdb.update_machine(session, m, name="upd-m-1-renamed") + session.commit() + + self.assertEqual(m.name, "upd-m-1-renamed") + fetched = self.tsdb.get_machine(session, name="upd-m-1-renamed") + self.assertIsNotNone(fetched) + session.close() + + def test_update_machine_fields(self): + session = self.Session() + m = self.tsdb.get_or_create_machine(session, "upd-m-2", hardware="x86") + session.commit() + self.assertEqual(m.hardware, "x86") + + self.tsdb.update_machine(session, m, hardware="arm64") + session.commit() + + fetched = self.tsdb.get_machine(session, name="upd-m-2") + self.assertEqual(fetched.hardware, "arm64") + session.close() + + def test_update_machine_parameters(self): + session = self.Session() + m = self.tsdb.get_or_create_machine( + session, "upd-m-3", parameters={"old": "value"}) + session.commit() + + self.tsdb.update_machine( + session, m, parameters={"new": "value"}) + session.commit() + + fetched = self.tsdb.get_machine(session, name="upd-m-3") + self.assertEqual(fetched.parameters, {"new": "value"}) + session.close() + + +class TestRegressionIndicatorManagement(_CRUDTestBase): + + def test_add_regression_indicator(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-add-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-add-test"])["ri-add-test"] + reg = self.tsdb.create_regression( + session, "add-ind", [], state=0) + session.flush() + + ri = self.tsdb.add_regression_indicator( + session, reg, machine.id, test_id, "execution_time") + session.commit() + + self.assertIsNotNone(ri.id) + self.assertIsNotNone(ri.uuid) + self.assertEqual(ri.machine_id, machine.id) + self.assertEqual(ri.test_id, test_id) + self.assertEqual(ri.metric, "execution_time") + session.close() + + def test_add_duplicate_indicator_rejected(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-dup-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-dup-test"])["ri-dup-test"] + reg = self.tsdb.create_regression( + session, "dup-ind", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + session.commit() + + with self.assertRaises(sqlalchemy.exc.IntegrityError): + self.tsdb.add_regression_indicator( + session, reg, machine.id, test_id, "execution_time") + session.rollback() + session.close() + + def test_same_triple_on_different_regressions_ok(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-multi-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-multi-test"])["ri-multi-test"] + reg1 = self.tsdb.create_regression( + session, "reg1", [], state=0) + reg2 = self.tsdb.create_regression( + session, "reg2", [], state=0) + session.flush() + + ri1 = self.tsdb.add_regression_indicator( + session, reg1, machine.id, test_id, "execution_time") + ri2 = self.tsdb.add_regression_indicator( + session, reg2, machine.id, test_id, "execution_time") + session.commit() + + self.assertIsNotNone(ri1.id) + self.assertIsNotNone(ri2.id) + session.close() + + def test_remove_regression_indicator_by_uuid(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-rem-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-rem-test"])["ri-rem-test"] + reg = self.tsdb.create_regression( + session, "rem-ind", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + session.commit() + + indicator = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg.id) + .first() + ) + removed = self.tsdb.remove_regression_indicator( + session, reg.id, indicator.uuid) + session.commit() + self.assertTrue(removed) + + remaining = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg.id) + .all() + ) + self.assertEqual(len(remaining), 0) + session.close() + + def test_remove_nonexistent_indicator(self): + session = self.Session() + removed = self.tsdb.remove_regression_indicator( + session, 999, "nonexistent-uuid") + self.assertFalse(removed) + session.close() + + def test_remove_indicator_wrong_regression(self): + """Indicator exists but belongs to a different regression.""" + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-wrong-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-wrong-test"])["ri-wrong-test"] + reg1 = self.tsdb.create_regression( + session, "reg1", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + reg2 = self.tsdb.create_regression( + session, "reg2", [], state=0) + session.commit() + + indicator = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg1.id) + .first() + ) + # Try to remove reg1's indicator using reg2's id + removed = self.tsdb.remove_regression_indicator( + session, reg2.id, indicator.uuid) + self.assertFalse(removed) + session.close() + + def test_get_regression_indicator_by_uuid(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-get-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-get-test"])["ri-get-test"] + reg = self.tsdb.create_regression( + session, "get-ind", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + session.commit() + + indicator = ( + session.query(self.tsdb.RegressionIndicator) + .filter_by(regression_id=reg.id) + .first() + ) + fetched = self.tsdb.get_regression_indicator( + session, uuid=indicator.uuid) + self.assertEqual(fetched.id, indicator.id) + session.close() + + def test_get_regression_indicator_requires_id_or_uuid(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.get_regression_indicator(session) + session.close() + + def test_batch_add_indicators_silently_ignores_duplicates(self): + session = self.Session() + machine = self.tsdb.get_or_create_machine(session, "ri-batch-m") + test_id = self.tsdb.get_or_create_tests(session, ["ri-batch-test"])["ri-batch-test"] + reg = self.tsdb.create_regression( + session, "batch", + [{"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}], + state=0) + session.commit() + + test2_id = self.tsdb.get_or_create_tests(session, ["ri-batch-test2"])["ri-batch-test2"] + session.flush() + created = self.tsdb.add_regression_indicators_batch( + session, reg, + [ + {"machine_id": machine.id, "test_id": test_id, + "metric": "execution_time"}, # duplicate + {"machine_id": machine.id, "test_id": test2_id, + "metric": "execution_time"}, # new + ]) + session.commit() + + self.assertEqual(len(created), 1) + self.assertEqual(created[0].test_id, test2_id) + session.close() + + +class TestRegressionStateValidation(_CRUDTestBase): + + def test_create_with_invalid_state(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.create_regression(session, "bad state", [], state=99) + session.close() + + def test_update_with_invalid_state(self): + session = self.Session() + reg = self.tsdb.create_regression( + session, "valid", [], state=0) + session.commit() + + with self.assertRaises(ValueError): + self.tsdb.update_regression(session, reg, state=-1) + session.close() + + def test_all_valid_states_accepted(self): + session = self.Session() + for state_val in sorted(VALID_REGRESSION_STATES): + reg = self.tsdb.create_regression( + session, f"state-{state_val}", [], state=state_val) + self.assertEqual(reg.state, state_val) + session.commit() + session.close() + + +class TestUnknownFieldRejection(_CRUDTestBase): + """Unknown field/metric names must raise ValueError.""" + + def test_get_or_create_commit_unknown_field(self): + session = self.Session() + with self.assertRaises(ValueError) as cm: + self.tsdb.get_or_create_commit( + session, "bad-commit", bogus_field="x") + self.assertIn("bogus_field", str(cm.exception)) + session.close() + + def test_update_commit_unknown_field(self): + session = self.Session() + c = self.tsdb.get_or_create_commit(session, "uf-commit") + with self.assertRaises(ValueError) as cm: + self.tsdb.update_commit(session, c, nonexistent="x") + self.assertIn("nonexistent", str(cm.exception)) + session.close() + + def test_get_or_create_machine_unknown_field(self): + session = self.Session() + with self.assertRaises(ValueError) as cm: + self.tsdb.get_or_create_machine( + session, "bad-machine", bad_field="x") + self.assertIn("bad_field", str(cm.exception)) + session.close() + + def test_update_machine_unknown_field(self): + session = self.Session() + m = self.tsdb.get_or_create_machine(session, "uf-machine") + with self.assertRaises(ValueError) as cm: + self.tsdb.update_machine(session, m, no_such="x") + self.assertIn("no_such", str(cm.exception)) + session.close() + + def test_create_samples_unknown_metric(self): + session = self.Session() + m = self.tsdb.get_or_create_machine(session, "uf-sample-m") + c = self.tsdb.get_or_create_commit(session, "uf-sample-c") + run = self.tsdb.create_run(session, m, commit=c) + t_id = self.tsdb.get_or_create_tests(session, ["uf-test"])["uf-test"] + with self.assertRaises(ValueError) as cm: + self.tsdb.create_samples(session, run, [ + {"test_id": t_id, "executin_time": 1.0}, + ]) + self.assertIn("executin_time", str(cm.exception)) + session.close() + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/db/v5/test_import.py b/tests/server/db/v5/test_import.py new file mode 100644 index 000000000..3507a9c34 --- /dev/null +++ b/tests/server/db/v5/test_import.py @@ -0,0 +1,1043 @@ +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: python %s +# END. + +import os +import sys +import unittest + +import json + +import sqlalchemy +import sqlalchemy.orm + +from lnt.server.db.v5.schema import parse_schema +from lnt.server.db.v5.models import ( + V5Schema, + V5SchemaVersion, + create_global_tables, + create_suite_models, +) +from lnt.server.db.v5 import V5DB, V5TestSuiteDB + + +def _make_engine(): + db_uri = os.environ.get('LNT_TEST_DB_URI') + db_name = os.environ.get('LNT_TEST_DB_NAME') + if not db_uri or not db_name: + raise unittest.SkipTest( + "LNT_TEST_DB_URI / LNT_TEST_DB_NAME not set") + return sqlalchemy.create_engine(f"{db_uri}/{db_name}") + + +def _test_schema(): + return parse_schema({ + "name": "imp", + "metrics": [ + {"name": "compile_time", "type": "real"}, + {"name": "execution_time", "type": "real"}, + ], + "commit_fields": [ + {"name": "git_sha", "searchable": True}, + {"name": "author", "searchable": True}, + ], + "machine_fields": [ + {"name": "hardware", "searchable": True}, + {"name": "os", "searchable": True}, + ], + }) + + +class _ImportTestBase(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + # Build a lightweight V5TestSuiteDB (we don't need the full V5DB) + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + +class TestImportRun(_ImportTestBase): + + def test_import_with_commit(self): + session = self.Session() + data = { + "format_version": "5", + "machine": { + "name": "import-machine-1", + "hardware": "x86_64", + "os": "linux", + }, + "commit": "abc123", + "commit_fields": { + "git_sha": "abc123def456789", + "author": "Jane Doe", + }, + "run_parameters": { + "build_config": "Release", + }, + "tests": [ + { + "name": "test.suite/benchmark1", + "compile_time": 1.23, + "execution_time": 0.45, + }, + { + "name": "test.suite/benchmark2", + "execution_time": 0.67, + }, + ], + } + run = self.tsdb.import_run(session, data) + session.commit() + + self.assertIsNotNone(run.id) + self.assertIsNotNone(run.uuid) + self.assertIsNotNone(run.commit_id) + self.assertEqual(run.run_parameters, {"build_config": "Release"}) + + # Verify commit was created + commit = self.tsdb.get_commit(session, id=run.commit_id) + self.assertEqual(commit.commit, "abc123") + self.assertEqual(commit.git_sha, "abc123def456789") + self.assertEqual(commit.author, "Jane Doe") + self.assertIsNone(commit.ordinal) + + # Verify machine + machine = self.tsdb.get_machine(session, id=run.machine_id) + self.assertEqual(machine.name, "import-machine-1") + self.assertEqual(machine.hardware, "x86_64") + self.assertEqual(machine.os, "linux") + + # Verify samples + samples = ( + session.query(self.suite_models.Sample) + .filter_by(run_id=run.id) + .all() + ) + self.assertEqual(len(samples), 2) + session.close() + + def test_import_without_commit(self): + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "import-machine-2"}, + "tests": [ + {"name": "test.suite/standalone", "execution_time": 0.1}, + ], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_missing_format_version(self): + """format_version is required -- omitting it must raise.""" + session = self.Session() + data = { + "machine": {"name": "import-machine-no-fmt"}, + "commit": "no-fmt-commit", + "tests": [{"name": "test/fmt", "execution_time": 1.0}], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_wrong_format_version(self): + """format_version must be '5' -- anything else must raise.""" + session = self.Session() + data = { + "format_version": "4", + "machine": {"name": "import-machine-wrong-fmt"}, + "commit": "wrong-fmt-commit", + "tests": [{"name": "test/fmt2", "execution_time": 1.0}], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_same_commit_twice(self): + """Second import at same commit should reuse the commit (first-write-wins).""" + session = self.Session() + data1 = { + "format_version": "5", + "machine": {"name": "import-machine-3"}, + "commit": "same-commit", + "commit_fields": {"author": "First"}, + "tests": [{"name": "test/a", "compile_time": 1.0}], + } + run1 = self.tsdb.import_run(session, data1) + session.commit() + + data2 = { + "format_version": "5", + "machine": {"name": "import-machine-3"}, + "commit": "same-commit", + "commit_fields": {"author": "Second"}, + "tests": [{"name": "test/a", "compile_time": 2.0}], + } + run2 = self.tsdb.import_run(session, data2) + session.commit() + + # Same commit reused + self.assertEqual(run1.commit_id, run2.commit_id) + + # First-write-wins: author should still be "First" + commit = self.tsdb.get_commit(session, id=run1.commit_id) + self.assertEqual(commit.author, "First") + session.close() + + def test_import_missing_machine_name(self): + session = self.Session() + data = { + "format_version": "5", + "machine": {}, + "tests": [{"name": "test/x", "compile_time": 1.0}], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_missing_test_name(self): + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "import-machine-4"}, + "commit": "missing-test-name-commit", + "tests": [{"compile_time": 1.0}], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_machine_extra_params(self): + """Extra machine keys not in schema go into parameters JSONB.""" + session = self.Session() + data = { + "format_version": "5", + "machine": { + "name": "import-machine-5", + "hardware": "arm64", + "extra_key": "extra_value", + }, + "commit": "extra-params-commit", + "tests": [{"name": "test/extra", "compile_time": 1.0}], + } + run = self.tsdb.import_run(session, data) + session.commit() + + machine = self.tsdb.get_machine(session, id=run.machine_id) + self.assertEqual(machine.hardware, "arm64") + self.assertEqual(machine.parameters.get("extra_key"), "extra_value") + session.close() + + def test_import_empty_tests_list(self): + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "import-machine-empty-tests"}, + "commit": "empty-tests-commit", + "tests": [], + } + run = self.tsdb.import_run(session, data) + session.commit() + + self.assertIsNotNone(run.id) + samples = ( + session.query(self.suite_models.Sample) + .filter_by(run_id=run.id) + .all() + ) + self.assertEqual(len(samples), 0) + session.close() + + def test_import_unknown_top_level_keys_ignored(self): + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "import-machine-unk-keys"}, + "commit": "unknown-keys-commit", + "tests": [{"name": "test/unk", "compile_time": 1.0}], + "unknown_key": "should be ignored", + "another_unknown": 42, + } + run = self.tsdb.import_run(session, data) + session.commit() + + self.assertIsNotNone(run.id) + session.close() + + def test_import_array_metric_creates_multiple_samples(self): + """Array metric values unpack into multiple Sample rows.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "array-machine"}, + "commit": "array-commit", + "tests": [{ + "name": "test/array", + "compile_time": [1.0, 2.0, 3.0], + "execution_time": [0.1, 0.2, 0.3], + }], + } + run = self.tsdb.import_run(session, data) + session.commit() + + samples = self.tsdb.list_samples(session, run_id=run.id) + self.assertEqual(len(samples), 3) + compile_times = sorted(s.compile_time for s in samples) + exec_times = sorted(s.execution_time for s in samples) + self.assertEqual(compile_times, [1.0, 2.0, 3.0]) + self.assertEqual(exec_times, [0.1, 0.2, 0.3]) + session.close() + + def test_import_mixed_scalar_and_array_metrics(self): + """Scalar metrics are repeated across array-expanded samples.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "mixed-machine"}, + "commit": "mixed-commit", + "tests": [{ + "name": "test/mixed", + "execution_time": [1.0, 2.0], + "compile_time": 0.5, + }], + } + run = self.tsdb.import_run(session, data) + session.commit() + + samples = self.tsdb.list_samples(session, run_id=run.id) + self.assertEqual(len(samples), 2) + for s in samples: + self.assertEqual(s.compile_time, 0.5) + exec_times = sorted(s.execution_time for s in samples) + self.assertEqual(exec_times, [1.0, 2.0]) + session.close() + + def test_import_array_inconsistent_lengths_rejected(self): + """Array metrics with different lengths raise ValueError.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "incon-machine"}, + "commit": "incon-commit", + "tests": [{ + "name": "test/incon", + "execution_time": [1.0, 2.0], + "compile_time": [1.0, 2.0, 3.0], + }], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_empty_array_rejected(self): + """Empty metric arrays raise ValueError.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "empty-arr-machine"}, + "commit": "empty-arr-commit", + "tests": [{ + "name": "test/empty-arr", + "execution_time": [], + }], + } + with self.assertRaises(ValueError): + self.tsdb.import_run(session, data) + session.rollback() + session.close() + + def test_import_unknown_metric_rejected(self): + """A typo'd metric name in test data raises ValueError.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "unk-metric-machine"}, + "commit": "unk-metric-commit", + "tests": [{ + "name": "test/unk-metric", + "executin_time": 1.0, # typo: should be execution_time + }], + } + with self.assertRaises(ValueError) as cm: + self.tsdb.import_run(session, data) + self.assertIn("executin_time", str(cm.exception)) + session.rollback() + session.close() + + def test_import_with_client_uuid(self): + """A client-provided UUID is used as the run's UUID.""" + import uuid as uuid_module + session = self.Session() + client_uuid = str(uuid_module.uuid4()) + data = { + "format_version": "5", + "uuid": client_uuid, + "machine": {"name": "uuid-machine"}, + "commit": "uuid-commit-1", + "tests": [{"name": "test/uuid", "execution_time": 1.0}], + } + run = self.tsdb.import_run(session, data) + session.commit() + self.assertEqual(run.uuid, client_uuid) + session.close() + + def test_import_without_uuid_generates_one(self): + """Omitting uuid from the data dict generates a server-side UUID.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "uuid-machine"}, + "commit": "uuid-commit-2", + "tests": [{"name": "test/uuid-gen", "execution_time": 1.0}], + } + run = self.tsdb.import_run(session, data) + session.commit() + self.assertIsNotNone(run.uuid) + self.assertEqual(len(run.uuid), 36) + session.close() + + +class TestGetOrCreateTests(_ImportTestBase): + + def test_batch_creates_new_tests(self): + """All names are new — batch creates them all.""" + session = self.Session() + names = ["batch/new-a", "batch/new-b", "batch/new-c"] + result = self.tsdb.get_or_create_tests(session, names) + session.commit() + + self.assertEqual(set(result.keys()), set(names)) + for name in names: + self.assertIsInstance(result[name], int) + # Verify they're in the DB. + for name in names: + t = self.tsdb.get_test(session, name=name) + self.assertIsNotNone(t) + self.assertEqual(t.id, result[name]) + session.close() + + def test_batch_finds_existing_tests(self): + """All names already exist — batch finds them without inserting.""" + session = self.Session() + pre = self.tsdb.get_or_create_tests( + session, ["batch/existing-1", "batch/existing-2"]) + session.commit() + + result = self.tsdb.get_or_create_tests( + session, ["batch/existing-1", "batch/existing-2"]) + self.assertEqual(result["batch/existing-1"], pre["batch/existing-1"]) + self.assertEqual(result["batch/existing-2"], pre["batch/existing-2"]) + session.close() + + def test_batch_mixed_existing_and_new(self): + """Mix of existing and new names.""" + session = self.Session() + pre = self.tsdb.get_or_create_tests(session, ["batch/mix-exist"]) + session.commit() + + result = self.tsdb.get_or_create_tests( + session, ["batch/mix-exist", "batch/mix-new-1", "batch/mix-new-2"]) + session.commit() + + self.assertEqual(result["batch/mix-exist"], pre["batch/mix-exist"]) + self.assertIn("batch/mix-new-1", result) + self.assertIn("batch/mix-new-2", result) + # Verify new ones are in DB. + self.assertIsNotNone(self.tsdb.get_test(session, name="batch/mix-new-1")) + session.close() + + def test_batch_deduplicates_input(self): + """Duplicate names in input are handled.""" + session = self.Session() + result = self.tsdb.get_or_create_tests( + session, ["batch/dup-a", "batch/dup-b", "batch/dup-a", "batch/dup-b", "batch/dup-a"]) + session.commit() + + self.assertEqual(len(result), 2) + self.assertIn("batch/dup-a", result) + self.assertIn("batch/dup-b", result) + session.close() + + def test_batch_empty_input(self): + """Empty input returns empty dict.""" + session = self.Session() + result = self.tsdb.get_or_create_tests(session, []) + self.assertEqual(result, {}) + session.close() + + def test_batch_single_name(self): + """Single-name input works.""" + session = self.Session() + result = self.tsdb.get_or_create_tests(session, ["batch/single"]) + session.commit() + + self.assertEqual(len(result), 1) + self.assertIn("batch/single", result) + session.close() + + def test_batch_special_characters(self): + """Names with special characters are handled correctly.""" + session = self.Session() + names = [ + "batch/with/slashes/deep", + "batch/unicode-\u00e9\u00e8\u00fc", + "batch/percent%underscore_", + "batch/with spaces and (parens)", + ] + result = self.tsdb.get_or_create_tests(session, names) + session.commit() + + self.assertEqual(set(result.keys()), set(names)) + for name in names: + t = self.tsdb.get_test(session, name=name) + self.assertIsNotNone(t) + self.assertEqual(t.name, name) + session.close() + + def test_batch_max_length_name(self): + """Name at the 256-char String column boundary.""" + session = self.Session() + name_256 = "x" * 256 + result = self.tsdb.get_or_create_tests(session, [name_256]) + session.commit() + + self.assertIn(name_256, result) + t = self.tsdb.get_test(session, name=name_256) + self.assertIsNotNone(t) + self.assertEqual(t.id, result[name_256]) + session.close() + + def test_import_run_with_many_tests(self): + """Submit a run with 100 tests — exercises modified _parse_tests_data.""" + session = self.Session() + tests = [{"name": f"batch/many-{i}", "execution_time": float(i)} + for i in range(100)] + data = { + "format_version": "5", + "machine": {"name": "batch-many-machine"}, + "commit": "batch-many-commit", + "tests": tests, + } + run = self.tsdb.import_run(session, data) + session.commit() + + # Verify all samples were created. + samples = self.tsdb.list_samples(session, run_id=run.id, limit=200) + self.assertEqual(len(samples), 100) + session.close() + + def test_import_run_reuses_existing_tests(self): + """Two runs with overlapping tests use the same test IDs.""" + session = self.Session() + tests_1 = [{"name": "batch/reuse-a", "execution_time": 1.0}, + {"name": "batch/reuse-b", "execution_time": 2.0}] + tests_2 = [{"name": "batch/reuse-b", "execution_time": 3.0}, + {"name": "batch/reuse-c", "execution_time": 4.0}] + data_1 = { + "format_version": "5", + "machine": {"name": "batch-reuse-machine"}, + "commit": "batch-reuse-commit-1", + "tests": tests_1, + } + data_2 = { + "format_version": "5", + "machine": {"name": "batch-reuse-machine"}, + "commit": "batch-reuse-commit-2", + "tests": tests_2, + } + self.tsdb.import_run(session, data_1) + session.commit() + self.tsdb.import_run(session, data_2) + session.commit() + + # "batch/reuse-b" should have the same test ID in both runs. + t_b = self.tsdb.get_test(session, name="batch/reuse-b") + self.assertIsNotNone(t_b) + # Verify via samples that both runs reference this test. + samples_b = self.tsdb.list_samples(session, test_id=t_b.id) + self.assertEqual(len(samples_b), 2) + session.close() + + def test_import_heterogeneous_metrics(self): + """Different tests with different metric subsets store NULLs correctly.""" + session = self.Session() + data = { + "format_version": "5", + "machine": {"name": "hetero-machine"}, + "commit": "hetero-commit", + "tests": [ + {"name": "hetero/both", "compile_time": 1.0, + "execution_time": 2.0}, + {"name": "hetero/exec-only", "execution_time": 3.0}, + ], + } + run = self.tsdb.import_run(session, data) + session.commit() + + samples = self.tsdb.list_samples(session, run_id=run.id) + self.assertEqual(len(samples), 2) + + by_test = {} + for s in samples: + t = self.tsdb.get_test(session, id=s.test_id) + by_test[t.name] = s + + both = by_test["hetero/both"] + self.assertAlmostEqual(both.compile_time, 1.0) + self.assertAlmostEqual(both.execution_time, 2.0) + + exec_only = by_test["hetero/exec-only"] + self.assertIsNone(exec_only.compile_time) + self.assertAlmostEqual(exec_only.execution_time, 3.0) + session.close() + + +class TestGetOrCreateCommit(_ImportTestBase): + + def test_empty_commit_string_rejected(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.get_or_create_commit(session, "") + session.close() + + def test_long_commit_string(self): + session = self.Session() + long_str = "a" * 256 + c = self.tsdb.get_or_create_commit(session, long_str) + session.commit() + self.assertEqual(c.commit, long_str) + session.close() + + def test_special_chars_in_commit(self): + session = self.Session() + for s in ["with/slash", "with%percent", "unicode-\u00e9\u00e8", "with spaces"]: + c = self.tsdb.get_or_create_commit(session, s) + session.commit() + self.assertEqual(c.commit, s) + session.close() + + +class TestSearchMethods(_ImportTestBase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + session = cls.Session() + # Seed some data + cls.tsdb.get_or_create_commit(session, "abc-100", git_sha="sha-abc", author="Alice") + cls.tsdb.get_or_create_commit(session, "abc-200", git_sha="sha-abd", author="Bob") + cls.tsdb.get_or_create_commit(session, "xyz-100", git_sha="sha-xyz", author="Alice Smith") + cls.tsdb.get_or_create_machine(session, "x86-machine-1", hardware="x86_64", os="linux") + cls.tsdb.get_or_create_machine(session, "arm-machine-1", hardware="aarch64", os="darwin") + session.commit() + session.close() + + def test_search_commits_by_commit_string(self): + session = self.Session() + results = self.tsdb.list_commits(session, search="abc") + names = [c.commit for c in results] + self.assertIn("abc-100", names) + self.assertIn("abc-200", names) + self.assertNotIn("xyz-100", names) + session.close() + + def test_search_commits_by_substring(self): + """Search matches a substring in the middle of a commit string.""" + session = self.Session() + # "c-10" appears in the middle of "abc-100" + results = self.tsdb.list_commits(session, search="c-10") + names = [c.commit for c in results] + self.assertIn("abc-100", names) + session.close() + + def test_search_commits_case_insensitive(self): + """Search is case-insensitive for commit strings and fields.""" + session = self.Session() + self.tsdb.get_or_create_commit( + session, "MiXeD-CaSe-Commit", author="TestAuthor") + session.commit() + + results = self.tsdb.list_commits(session, search="mixed-case-commit") + names = [c.commit for c in results] + self.assertIn("MiXeD-CaSe-Commit", names) + + results = self.tsdb.list_commits(session, search="MIXED-CASE-COMMIT") + names = [c.commit for c in results] + self.assertIn("MiXeD-CaSe-Commit", names) + session.close() + + def test_search_commits_by_searchable_field(self): + """Search should match across searchable commit_fields (author).""" + session = self.Session() + results = self.tsdb.list_commits(session, search="Alice") + names = [c.commit for c in results] + # "Alice" matches commit abc-100 (author=Alice) and xyz-100 (author=Alice Smith) + self.assertIn("abc-100", names) + self.assertIn("xyz-100", names) + session.close() + + def test_search_commits_by_searchable_field_substring(self): + """Search matches a substring within searchable commit fields.""" + session = self.Session() + # "lice" is a substring of "Alice" and "Alice Smith" + results = self.tsdb.list_commits(session, search="lice") + names = [c.commit for c in results] + self.assertIn("abc-100", names) + self.assertIn("xyz-100", names) + session.close() + + def test_search_machines_by_name(self): + session = self.Session() + results = self.tsdb.list_machines(session, search="x86") + names = [m.name for m in results] + self.assertIn("x86-machine-1", names) + self.assertNotIn("arm-machine-1", names) + session.close() + + def test_search_machines_by_name_substring(self): + """Search matches a substring in the middle of a machine name.""" + session = self.Session() + # "machine-1" is a substring shared by both machines + results = self.tsdb.list_machines(session, search="machine-1") + names = [m.name for m in results] + self.assertIn("x86-machine-1", names) + self.assertIn("arm-machine-1", names) + session.close() + + def test_search_machines_case_insensitive(self): + """Search is case-insensitive for machine names and fields.""" + session = self.Session() + self.tsdb.get_or_create_machine( + session, "CaSe-Machine-1", hardware="TestHW", os="TestOS") + session.commit() + + results = self.tsdb.list_machines(session, search="case-machine") + names = [m.name for m in results] + self.assertIn("CaSe-Machine-1", names) + + results = self.tsdb.list_machines(session, search="CASE-MACHINE") + names = [m.name for m in results] + self.assertIn("CaSe-Machine-1", names) + session.close() + + def test_search_machines_by_searchable_field(self): + """Search should match across searchable machine_fields (hardware).""" + session = self.Session() + results = self.tsdb.list_machines(session, search="aarch64") + names = [m.name for m in results] + self.assertIn("arm-machine-1", names) + session.close() + + def test_empty_search_returns_all(self): + session = self.Session() + all_commits = self.tsdb.list_commits(session) + searched = self.tsdb.list_commits(session, search=None) + self.assertEqual(len(all_commits), len(searched)) + session.close() + + def test_search_with_sql_special_percent(self): + """Search with '%' should be treated literally, not as a wildcard.""" + session = self.Session() + # Create a commit whose string literally starts with "100%" + self.tsdb.get_or_create_commit(session, "100%done") + self.tsdb.get_or_create_commit(session, "100_safe") + session.commit() + + # Search for "100%" should match "100%done" but NOT "100_safe" + results = self.tsdb.list_commits(session, search="100%") + names = [c.commit for c in results] + self.assertIn("100%done", names) + self.assertNotIn("100_safe", names) + session.close() + + def test_search_with_sql_special_underscore(self): + """Search with '_' should be treated literally, not as a single-char wildcard.""" + session = self.Session() + # "100_" should not match "100X" where X is any char + self.tsdb.get_or_create_commit(session, "100_safe") + session.commit() + + results = self.tsdb.list_commits(session, search="100_") + names = [c.commit for c in results] + self.assertIn("100_safe", names) + # Should not match "100%done" (starts with 100 but 4th char is %, not _) + self.assertNotIn("100%done", names) + session.close() + + def test_search_with_sql_special_backslash(self): + """Search with backslash should be treated literally.""" + session = self.Session() + self.tsdb.get_or_create_commit(session, "path\\to\\thing") + session.commit() + + results = self.tsdb.list_commits(session, search="path\\") + names = [c.commit for c in results] + self.assertIn("path\\to\\thing", names) + session.close() + + def test_machine_search_with_sql_special_chars(self): + """Machine search should also escape SQL special characters.""" + session = self.Session() + self.tsdb.get_or_create_machine(session, "machine_with%special") + session.commit() + + results = self.tsdb.list_machines(session, search="machine_with%") + names = [m.name for m in results] + self.assertIn("machine_with%special", names) + session.close() + + def test_default_limit_on_list_commits(self): + session = self.Session() + # We can't easily create 1001 commits in a test, but we can verify + # that passing no limit still returns results (implying a limit is set) + # and that an explicit limit overrides the default. + results = self.tsdb.list_commits(session, limit=2) + self.assertLessEqual(len(results), 2) + session.close() + + def test_default_limit_on_list_machines(self): + session = self.Session() + results = self.tsdb.list_machines(session, limit=1) + self.assertLessEqual(len(results), 1) + session.close() + + def test_default_limit_on_list_runs(self): + session = self.Session() + results = self.tsdb.list_runs(session, limit=1) + self.assertLessEqual(len(results), 1) + session.close() + + +class TestSchemaStorageInDB(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + # Create global tables + create_global_tables(cls.engine) + + @classmethod + def tearDownClass(cls): + # Clean up global tables + from lnt.server.db.v5.models import _global_base + _global_base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def setUp(self): + # Clean any existing schema rows + session = self.Session() + session.query(V5Schema).delete() + row = session.query(V5SchemaVersion).get(1) + if row: + row.version = 0 + else: + session.add(V5SchemaVersion(id=1, version=0)) + session.commit() + session.close() + + def _make_v5db(self): + """Create a V5DB wired to the test Postgres database.""" + db_uri = os.environ.get('LNT_TEST_DB_URI') + db_name = os.environ.get('LNT_TEST_DB_NAME') + path = f"{db_uri}/{db_name}" + + class _FakeConfig: + schemasDir = "/nonexistent" + return V5DB(path, _FakeConfig()) + + def test_create_suite(self): + v5db = self._make_v5db() + schema = parse_schema({ + "name": "create_test", + "metrics": [{"name": "time", "type": "real"}], + }) + session = v5db.make_session() + tsdb = v5db.create_suite(session, schema) + session.commit() + session.close() + + self.assertIsNotNone(tsdb) + self.assertEqual(tsdb.name, "create_test") + self.assertIn("create_test", v5db.testsuite) + + # Verify row in v5_schema + session = v5db.make_session() + row = session.query(V5Schema).get("create_test") + self.assertIsNotNone(row) + data = json.loads(row.schema_json) + self.assertEqual(data["name"], "create_test") + session.close() + + # Clean up + session = v5db.make_session() + v5db.delete_suite(session, "create_test") + session.commit() + session.close() + v5db.close() + + def test_get_suite(self): + v5db = self._make_v5db() + self.assertIsNone(v5db.get_suite("nonexistent")) + + schema = parse_schema({ + "name": "get_test", + "metrics": [{"name": "time", "type": "real"}], + }) + session = v5db.make_session() + v5db.create_suite(session, schema) + session.commit() + session.close() + + result = v5db.get_suite("get_test") + self.assertIsNotNone(result) + self.assertEqual(result.name, "get_test") + + # Clean up + session = v5db.make_session() + v5db.delete_suite(session, "get_test") + session.commit() + session.close() + v5db.close() + + def test_delete_suite(self): + v5db = self._make_v5db() + schema = parse_schema({ + "name": "del_test", + "metrics": [{"name": "time", "type": "real"}], + }) + session = v5db.make_session() + v5db.create_suite(session, schema) + session.commit() + session.close() + + self.assertIn("del_test", v5db.testsuite) + + session = v5db.make_session() + v5db.delete_suite(session, "del_test") + session.commit() + session.close() + + self.assertNotIn("del_test", v5db.testsuite) + self.assertIsNone(v5db.get_suite("del_test")) + + # Verify row gone from v5_schema + session = v5db.make_session() + row = session.query(V5Schema).get("del_test") + self.assertIsNone(row) + session.close() + v5db.close() + + def test_delete_nonexistent_suite_raises(self): + v5db = self._make_v5db() + session = v5db.make_session() + with self.assertRaises(ValueError): + v5db.delete_suite(session, "nonexistent") + session.close() + v5db.close() + + def test_create_duplicate_suite_raises(self): + v5db = self._make_v5db() + schema = parse_schema({ + "name": "dup_test", + "metrics": [{"name": "time", "type": "real"}], + }) + session = v5db.make_session() + v5db.create_suite(session, schema) + session.commit() + + with self.assertRaises(ValueError): + v5db.create_suite(session, schema) + session.close() + + # Clean up + session = v5db.make_session() + v5db.delete_suite(session, "dup_test") + session.commit() + session.close() + v5db.close() + + def test_schema_version_bumped(self): + """create_suite and delete_suite bump the version counter.""" + v5db = self._make_v5db() + session = v5db.make_session() + + ver_before = session.query(V5SchemaVersion).get(1).version + + schema = parse_schema({ + "name": "ver_test", + "metrics": [{"name": "time", "type": "real"}], + }) + v5db.create_suite(session, schema) + session.commit() + + ver_after_create = session.query(V5SchemaVersion).get(1).version + self.assertEqual(ver_after_create, ver_before + 1) + + v5db.delete_suite(session, "ver_test") + session.commit() + + ver_after_delete = session.query(V5SchemaVersion).get(1).version + self.assertEqual(ver_after_delete, ver_before + 2) + session.close() + v5db.close() + + def test_staleness_detection(self): + """A second V5DB instance detects stale cache and reloads.""" + v5db1 = self._make_v5db() + schema = parse_schema({ + "name": "stale_test", + "metrics": [{"name": "time", "type": "real"}], + }) + session = v5db1.make_session() + v5db1.create_suite(session, schema) + session.commit() + session.close() + + # Create a second V5DB instance (simulates another worker) + v5db2 = self._make_v5db() + # v5db2 should see the suite because it loaded from DB on init + self.assertIn("stale_test", v5db2.testsuite) + + # Clean up + session = v5db1.make_session() + v5db1.delete_suite(session, "stale_test") + session.commit() + session.close() + + # v5db2 should detect staleness via ensure_fresh() + session2 = v5db2.make_session() + v5db2.ensure_fresh(session2) + session2.close() + result = v5db2.get_suite("stale_test") + self.assertIsNone(result) + + v5db1.close() + v5db2.close() + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/db/v5/test_models.py b/tests/server/db/v5/test_models.py new file mode 100644 index 000000000..89af24891 --- /dev/null +++ b/tests/server/db/v5/test_models.py @@ -0,0 +1,791 @@ +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: python %s +# END. + +import datetime +import os +import sys +import unittest + +import sqlalchemy +import sqlalchemy.exc + +from lnt.server.db.v5.schema import parse_schema +from lnt.server.db.v5.models import _global_base, create_suite_models, utcnow +from lnt.server.db.v5 import V5DB, initialize_v5_database + + +def _db_path(): + db_uri = os.environ.get('LNT_TEST_DB_URI') + db_name = os.environ.get('LNT_TEST_DB_NAME') + if not db_uri or not db_name: + raise unittest.SkipTest( + "LNT_TEST_DB_URI / LNT_TEST_DB_NAME not set " + "(run via with_postgres.sh)") + return f"{db_uri}/{db_name}" + + +def _make_engine(): + return sqlalchemy.create_engine(_db_path()) + + +def _test_schema(): + return parse_schema({ + "name": "t", + "metrics": [ + {"name": "compile_time", "type": "real"}, + {"name": "execution_time", "type": "real"}, + {"name": "compile_status", "type": "status"}, + ], + "commit_fields": [ + {"name": "git_sha", "searchable": True}, + {"name": "author", "searchable": True}, + {"name": "message", "type": "text"}, + ], + "machine_fields": [ + {"name": "hardware", "searchable": True}, + {"name": "os", "searchable": True}, + ], + }) + + +class TestModelCreation(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_commit_table_exists(self): + insp = sqlalchemy.inspect(self.engine) + self.assertIn("t_Commit", insp.get_table_names()) + + def test_commit_has_dynamic_columns(self): + """Dynamic commit_fields should appear as columns.""" + insp = sqlalchemy.inspect(self.engine) + cols = {c['name'] for c in insp.get_columns("t_Commit")} + self.assertIn("git_sha", cols) + self.assertIn("author", cols) + self.assertIn("message", cols) + + def test_machine_table_has_parameters(self): + insp = sqlalchemy.inspect(self.engine) + cols = {c['name'] for c in insp.get_columns("t_Machine")} + self.assertIn("parameters", cols) + self.assertIn("hardware", cols) + self.assertIn("os", cols) + + def test_sample_table_has_metric_columns(self): + """Schema-defined metrics should appear as dynamic columns.""" + insp = sqlalchemy.inspect(self.engine) + cols = {c['name'] for c in insp.get_columns("t_Sample")} + self.assertIn("compile_time", cols) + self.assertIn("execution_time", cols) + self.assertIn("compile_status", cols) + + def test_all_tables_created(self): + """All 7 per-suite tables should exist.""" + insp = sqlalchemy.inspect(self.engine) + tables = set(insp.get_table_names()) + expected = { + "t_Commit", "t_Machine", "t_Run", "t_Test", + "t_Sample", "t_Regression", + "t_RegressionIndicator", + } + self.assertTrue(expected.issubset(tables), f"Missing: {expected - tables}") + + def test_utcnow_returns_utc_aware_datetime(self): + """utcnow() must return a timezone-aware UTC datetime.""" + result = utcnow() + self.assertEqual(result.tzinfo, datetime.timezone.utc) + + +class TestCommitCRUD(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.drop_all(cls.engine) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_create_commit(self): + session = self.Session() + c = self.models.Commit() + c.commit = "abc123" + c.git_sha = "abc123def456" + c.author = "Jane" + session.add(c) + session.commit() + self.assertIsNotNone(c.id) + self.assertIsNone(c.ordinal) # ordinal always NULL on creation + session.close() + + def test_unique_commit_string(self): + """Duplicate commit strings should raise IntegrityError.""" + session = self.Session() + c1 = self.models.Commit() + c1.commit = "unique_test_1" + session.add(c1) + session.commit() + + c2 = self.models.Commit() + c2.commit = "unique_test_1" + session.add(c2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + def test_ordinal_unique(self): + session = self.Session() + c1 = self.models.Commit() + c1.commit = "ord_test_1" + c1.ordinal = 42 + session.add(c1) + session.commit() + + c2 = self.models.Commit() + c2.commit = "ord_test_2" + c2.ordinal = 42 + session.add(c2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + def test_ordinal_nullable(self): + """Ordinal can be NULL (multiple commits with NULL ordinal OK).""" + session = self.Session() + for i in range(3): + c = self.models.Commit() + c.commit = f"null_ord_{i}" + session.add(c) + session.commit() + + nulls = ( + session.query(self.models.Commit) + .filter(self.models.Commit.commit.like("null_ord_%")) + .all() + ) + self.assertEqual(len(nulls), 3) + for c in nulls: + self.assertIsNone(c.ordinal) + session.close() + + +class TestMachineCRUD(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.drop_all(cls.engine) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_create_machine(self): + session = self.Session() + m = self.models.Machine() + m.name = "test-machine-1" + m.parameters = {"key": "value"} + m.hardware = "x86_64" + m.os = "linux" + session.add(m) + session.commit() + self.assertIsNotNone(m.id) + session.close() + + def test_machine_name_unique(self): + session = self.Session() + m1 = self.models.Machine() + m1.name = "unique-machine" + m1.parameters = {} + session.add(m1) + session.commit() + + m2 = self.models.Machine() + m2.name = "unique-machine" + m2.parameters = {} + session.add(m2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + def test_parameters_default_empty(self): + """Machine parameters should default to empty dict on the server side.""" + session = self.Session() + m = self.models.Machine() + m.name = "default-params-machine" + session.add(m) + session.commit() + + fetched = session.query(self.models.Machine).filter_by( + name="default-params-machine").one() + self.assertEqual(fetched.parameters, {}) + session.close() + + def test_jsonb_nested_parameters(self): + session = self.Session() + m = self.models.Machine() + m.name = "nested-params-machine" + m.parameters = { + "config": {"threads": 4, "flags": ["-O2", "-march=native"]}, + "tags": ["ci", "nightly"], + } + session.add(m) + session.commit() + + fetched = session.query(self.models.Machine).filter_by( + name="nested-params-machine").one() + self.assertEqual(fetched.parameters["config"]["threads"], 4) + self.assertEqual(fetched.parameters["tags"], ["ci", "nightly"]) + session.close() + + +class _ModelTestBase(unittest.TestCase): + """Shared setup/teardown and helpers for model-level tests.""" + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.drop_all(cls.engine) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def _make_machine(self, session, name): + m = self.models.Machine() + m.name = name + m.parameters = {} + session.add(m) + session.flush() + return m + + def _make_test(self, session, name): + t = self.models.Test() + t.name = name + session.add(t) + session.flush() + return t + + def _make_commit(self, session, commit_str): + c = self.models.Commit() + c.commit = commit_str + session.add(c) + session.flush() + return c + + +class TestRunCRUD(_ModelTestBase): + + def test_create_run_with_commit(self): + session = self.Session() + machine = self._make_machine(session, "run-m-1") + commit = self._make_commit(session, "run-c-1") + run = self.models.Run() + run.uuid = "aaaaaaaa-1111-2222-3333-444444444444" + run.machine_id = machine.id + run.commit_id = commit.id + run.submitted_at = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + run.run_parameters = {"build": "Release"} + session.add(run) + session.commit() + self.assertIsNotNone(run.id) + self.assertEqual(run.commit_id, commit.id) + session.close() + + def test_create_run_without_commit_fails(self): + """Creating a run with NULL commit_id should raise IntegrityError.""" + session = self.Session() + machine = self._make_machine(session, "run-m-2") + run = self.models.Run() + run.uuid = "bbbbbbbb-1111-2222-3333-444444444444" + run.machine_id = machine.id + run.commit_id = None + run.submitted_at = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + run.run_parameters = {} + session.add(run) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + def test_uuid_unique(self): + session = self.Session() + machine = self._make_machine(session, "run-m-3") + commit = self._make_commit(session, "run-c-3") + r1 = self.models.Run() + r1.uuid = "cccccccc-1111-2222-3333-444444444444" + r1.machine_id = machine.id + r1.commit_id = commit.id + r1.submitted_at = utcnow() + r1.run_parameters = {} + session.add(r1) + session.commit() + + r2 = self.models.Run() + r2.uuid = "cccccccc-1111-2222-3333-444444444444" # same + r2.machine_id = machine.id + r2.commit_id = commit.id + r2.submitted_at = utcnow() + r2.run_parameters = {} + session.add(r2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + def test_run_parameters_jsonb(self): + session = self.Session() + machine = self._make_machine(session, "run-m-4") + commit = self._make_commit(session, "run-c-4") + run = self.models.Run() + run.uuid = "dddddddd-1111-2222-3333-444444444444" + run.machine_id = machine.id + run.commit_id = commit.id + run.submitted_at = utcnow() + run.run_parameters = { + "nested": {"key": [1, 2, 3]}, + "null_value": None, + } + session.add(run) + session.commit() + + fetched = session.query(self.models.Run).filter_by( + uuid="dddddddd-1111-2222-3333-444444444444").one() + self.assertEqual(fetched.run_parameters["nested"]["key"], [1, 2, 3]) + self.assertIsNone(fetched.run_parameters["null_value"]) + session.close() + + +class TestSampleCreation(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.drop_all(cls.engine) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_create_sample_with_metrics(self): + session = self.Session() + + m = self.models.Machine() + m.name = "sample-machine" + m.parameters = {} + session.add(m) + session.flush() + + c = self.models.Commit() + c.commit = "sample-commit" + session.add(c) + session.flush() + + t = self.models.Test() + t.name = "test.suite/benchmark" + session.add(t) + session.flush() + + r = self.models.Run() + r.uuid = "sample-run-uuid-00000000000000000"[:36] + r.machine_id = m.id + r.commit_id = c.id + r.submitted_at = utcnow() + r.run_parameters = {} + session.add(r) + session.flush() + + s = self.models.Sample() + s.run_id = r.id + s.test_id = t.id + s.compile_time = 1.5 + s.execution_time = 0.3 + s.compile_status = 0 + session.add(s) + session.commit() + + fetched = session.query(self.models.Sample).filter_by(run_id=r.id).one() + self.assertAlmostEqual(fetched.compile_time, 1.5) + self.assertAlmostEqual(fetched.execution_time, 0.3) + self.assertEqual(fetched.compile_status, 0) + session.close() + + def test_test_name_unique(self): + session = self.Session() + t1 = self.models.Test() + t1.name = "unique-test" + session.add(t1) + session.commit() + + t2 = self.models.Test() + t2.name = "unique-test" + session.add(t2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.commit() + session.rollback() + session.close() + + +class TestRegressionAndIndicatorModels(_ModelTestBase): + + def test_create_regression_with_commit_and_notes(self): + """Create a Regression with commit_id and notes.""" + session = self.Session() + c = self._make_commit(session, "reg-model-c1") + + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-00000000000000000"[:36] + reg.title = "Test Regression" + reg.bug = "BUG-1" + reg.notes = "Some notes about the regression" + reg.state = 0 + reg.commit_id = c.id + session.add(reg) + session.commit() + + self.assertIsNotNone(reg.id) + self.assertEqual(reg.notes, "Some notes about the regression") + self.assertEqual(reg.commit_id, c.id) + session.close() + + def test_regression_commit_id_nullable(self): + """Regression without a commit_id should persist with NULL.""" + session = self.Session() + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-null-commit0000000"[:36] + reg.title = "No commit regression" + reg.state = 0 + session.add(reg) + session.commit() + + self.assertIsNotNone(reg.id) + self.assertIsNone(reg.commit_id) + session.close() + + def test_regression_indicator_has_uuid(self): + """RegressionIndicator should have a uuid field.""" + session = self.Session() + m = self._make_machine(session, "ri-model-m1") + t = self._make_test(session, "ri-model-t1") + + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-ri-uuid0000000000"[:36] + reg.title = "RI UUID test" + reg.state = 0 + session.add(reg) + session.flush() + + ri = self.models.RegressionIndicator() + ri.uuid = "ri-model-uuid-000000000000000000"[:36] + ri.regression_id = reg.id + ri.machine_id = m.id + ri.test_id = t.id + ri.metric = "execution_time" + session.add(ri) + session.commit() + + self.assertIsNotNone(ri.id) + self.assertEqual(ri.uuid, "ri-model-uuid-000000000000000000"[:36]) + session.close() + + def test_regression_indicator_unique_constraint(self): + """Duplicate (regression_id, machine_id, test_id, metric) should fail.""" + session = self.Session() + m = self._make_machine(session, "ri-model-m-uniq") + t = self._make_test(session, "ri-model-t-uniq") + + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-uniq00000000000000"[:36] + reg.title = "Unique constraint test" + reg.state = 0 + session.add(reg) + session.flush() + + ri1 = self.models.RegressionIndicator() + ri1.uuid = "ri-model-uuid-uniq1-00000000000000"[:36] + ri1.regression_id = reg.id + ri1.machine_id = m.id + ri1.test_id = t.id + ri1.metric = "execution_time" + session.add(ri1) + session.flush() + + ri2 = self.models.RegressionIndicator() + ri2.uuid = "ri-model-uuid-uniq2-00000000000000"[:36] + ri2.regression_id = reg.id + ri2.machine_id = m.id + ri2.test_id = t.id + ri2.metric = "execution_time" + session.add(ri2) + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.flush() + session.rollback() + session.close() + + def test_same_triple_on_different_regressions_ok(self): + """Same (machine, test, metric) on different regressions should succeed.""" + session = self.Session() + m = self._make_machine(session, "ri-model-m-multi") + t = self._make_test(session, "ri-model-t-multi") + + reg1 = self.models.Regression() + reg1.uuid = "reg-model-uuid-multi1-000000000000"[:36] + reg1.title = "Reg 1" + reg1.state = 0 + session.add(reg1) + + reg2 = self.models.Regression() + reg2.uuid = "reg-model-uuid-multi2-000000000000"[:36] + reg2.title = "Reg 2" + reg2.state = 0 + session.add(reg2) + session.flush() + + ri1 = self.models.RegressionIndicator() + ri1.uuid = "ri-model-uuid-multi1-000000000000"[:36] + ri1.regression_id = reg1.id + ri1.machine_id = m.id + ri1.test_id = t.id + ri1.metric = "execution_time" + session.add(ri1) + + ri2 = self.models.RegressionIndicator() + ri2.uuid = "ri-model-uuid-multi2-000000000000"[:36] + ri2.regression_id = reg2.id + ri2.machine_id = m.id + ri2.test_id = t.id + ri2.metric = "execution_time" + session.add(ri2) + session.commit() + + self.assertIsNotNone(ri1.id) + self.assertIsNotNone(ri2.id) + session.close() + + def test_delete_regression_cascades_to_indicators(self): + """Deleting a Regression should cascade-delete its indicators.""" + session = self.Session() + m = self._make_machine(session, "ri-model-m-cascade") + t = self._make_test(session, "ri-model-t-cascade") + + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-cascade00000000000"[:36] + reg.title = "Cascade test" + reg.state = 0 + session.add(reg) + session.flush() + + ri = self.models.RegressionIndicator() + ri.uuid = "ri-model-uuid-cascade00000000000"[:36] + ri.regression_id = reg.id + ri.machine_id = m.id + ri.test_id = t.id + ri.metric = "execution_time" + session.add(ri) + session.flush() + + reg_id = reg.id + session.delete(reg) + session.commit() + + remaining = ( + session.query(self.models.RegressionIndicator) + .filter_by(regression_id=reg_id) + .all() + ) + self.assertEqual(len(remaining), 0) + session.close() + + def test_commit_referenced_by_regression_cannot_be_deleted(self): + """Deleting a Commit referenced by Regression.commit_id should fail.""" + session = self.Session() + c = self._make_commit(session, "reg-model-c-fk") + + reg = self.models.Regression() + reg.uuid = "reg-model-uuid-fk-commit0000000"[:36] + reg.title = "FK test" + reg.state = 0 + reg.commit_id = c.id + session.add(reg) + session.commit() + + with self.assertRaises(sqlalchemy.exc.IntegrityError): + session.delete(c) + session.flush() + session.rollback() + session.close() + + +class TestCascadingDeletes(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.models = create_suite_models(cls.schema) + cls.models.base.metadata.drop_all(cls.engine) + cls.models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + @classmethod + def tearDownClass(cls): + cls.models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_delete_run_keeps_commit(self): + """Deleting a run should NOT delete the commit.""" + session = self.Session() + + m = self.models.Machine() + m.name = "cascade-machine" + m.parameters = {} + session.add(m) + + c = self.models.Commit() + c.commit = "cascade-commit" + session.add(c) + session.flush() + + r = self.models.Run() + r.uuid = "cascade-run-uuid00000000000000000"[:36] + r.machine_id = m.id + r.commit_id = c.id + r.submitted_at = utcnow() + r.run_parameters = {} + session.add(r) + session.flush() + + run_id = r.id + commit_id = c.id + + session.delete(r) + session.commit() + + # Run is gone + self.assertIsNone( + session.query(self.models.Run).get(run_id)) + # Commit survives + self.assertIsNotNone( + session.query(self.models.Commit).get(commit_id)) + session.close() + + def test_delete_machine_cascades_to_runs(self): + """Deleting a machine should cascade-delete its runs.""" + session = self.Session() + + m = self.models.Machine() + m.name = "cascade-machine-2" + m.parameters = {} + session.add(m) + + c = self.models.Commit() + c.commit = "cascade-machine-commit" + session.add(c) + session.flush() + + r = self.models.Run() + r.uuid = "cascade-m-run-uuid0000000000000000"[:36] + r.machine_id = m.id + r.commit_id = c.id + r.submitted_at = utcnow() + r.run_parameters = {} + session.add(r) + session.flush() + run_id = r.id + + session.delete(m) + session.commit() + + self.assertIsNone( + session.query(self.models.Run).get(run_id)) + session.close() + + +class TestInitializeV5Database(unittest.TestCase): + """Tests for initialize_v5_database and V5DB read-only init.""" + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + + @classmethod + def tearDownClass(cls): + _global_base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def setUp(self): + _global_base.metadata.drop_all(self.engine) + + def test_initialize_v5_database_idempotent(self): + """Calling initialize_v5_database twice should not raise.""" + initialize_v5_database(_db_path()) + initialize_v5_database(_db_path()) + + insp = sqlalchemy.inspect(self.engine) + tables = insp.get_table_names() + self.assertIn("v5_schema", tables) + self.assertIn("v5_schema_version", tables) + self.assertIn("api_key", tables) + + def test_v5db_init_on_initialized_database(self): + """V5DB.__init__ should succeed read-only on an initialized DB.""" + initialize_v5_database(_db_path()) + + class _FakeConfig: + schemasDir = "/nonexistent" + + db = V5DB(_db_path(), _FakeConfig()) + self.assertEqual(db.testsuite, {}) + self.assertEqual(db._schema_version, 0) + db.engine.dispose() + + def test_v5db_init_without_initialization_gives_clear_error(self): + """V5DB.__init__ on an uninitialized DB should raise RuntimeError.""" + + class _FakeConfig: + schemasDir = "/nonexistent" + + with self.assertRaises(RuntimeError) as ctx: + V5DB(_db_path(), _FakeConfig()) + self.assertIn("not initialized", str(ctx.exception)) + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/db/v5/test_profile_parser.py b/tests/server/db/v5/test_profile_parser.py new file mode 100644 index 000000000..7725cc70e --- /dev/null +++ b/tests/server/db/v5/test_profile_parser.py @@ -0,0 +1,385 @@ +# Profile binary format parser tests. +# +# These are pure Python unit tests (no database needed). They use the +# v4 profilev2impl serializer to create valid blobs and verify that the +# v5 parser reads them correctly. +# +# RUN: python %s +# END. + +import io +import unittest + +from lnt.server.db.v5.profile import ( + ProfileData, + ProfileParseError, + read_uleb128, +) + + +# --------------------------------------------------------------------------- +# Helpers: create profile blobs using the v4 serializer +# --------------------------------------------------------------------------- + +def _make_v1_profile_data( + disassembly_format: str = "raw", + counters: dict | None = None, + functions: dict | None = None, +) -> dict: + """Build a v1-format profile dict (used as input to ProfileV2.upgrade).""" + if counters is None: + counters = {"cycles": 1000} + if functions is None: + functions = { + "main": { + "counters": {"cycles": 50.0}, + "data": [ + [{"cycles": 30.0}, 0x1000, "push rbp"], + [{"cycles": 20.0}, 0x1004, "mov rsp, rbp"], + ], + } + } + return { + "disassembly-format": disassembly_format, + "counters": counters, + "functions": { + name: { + "counters": fdata["counters"], + "data": fdata["data"], + } + for name, fdata in functions.items() + }, + } + + +def _v1_to_v2_bytes(v1_data: dict) -> bytes: + """Convert a v1 profile dict to v2 binary format using the v4 serializer.""" + from lnt.testing.profile.profilev1impl import ProfileV1 + from lnt.testing.profile.profilev2impl import ProfileV2 + + v1 = ProfileV1(v1_data) + v2 = ProfileV2.upgrade(v1) + return v2.serialize() + + +def _make_basic_profile_bytes(**kwargs) -> bytes: + """Create a valid v2 profile blob with optional overrides.""" + data = _make_v1_profile_data(**kwargs) + return _v1_to_v2_bytes(data) + + +# --------------------------------------------------------------------------- +# ULEB128 tests +# --------------------------------------------------------------------------- + +class TestULEB128(unittest.TestCase): + def _encode(self, n: int) -> bytes: + """Encode n as ULEB128.""" + buf = bytearray() + while True: + b = n & 0x7F + n >>= 7 + if n != 0: + b |= 0x80 + buf.append(b) + if n == 0: + break + return bytes(buf) + + def test_zero(self): + self.assertEqual(read_uleb128(io.BytesIO(self._encode(0))), 0) + + def test_single_byte_max(self): + self.assertEqual(read_uleb128(io.BytesIO(self._encode(127))), 127) + + def test_two_byte_min(self): + self.assertEqual(read_uleb128(io.BytesIO(self._encode(128))), 128) + + def test_two_byte_max(self): + self.assertEqual(read_uleb128(io.BytesIO(self._encode(16383))), 16383) + + def test_three_byte_min(self): + self.assertEqual(read_uleb128(io.BytesIO(self._encode(16384))), 16384) + + def test_large_value(self): + val = 2**32 - 1 + self.assertEqual(read_uleb128(io.BytesIO(self._encode(val))), val) + + def test_truncated_raises(self): + with self.assertRaises(ProfileParseError): + read_uleb128(io.BytesIO(b"")) + + +# --------------------------------------------------------------------------- +# Round-trip tests (v4 serializer -> v5 parser) +# --------------------------------------------------------------------------- + +class TestProfileRoundTrip(unittest.TestCase): + def test_basic_profile(self): + blob = _make_basic_profile_bytes() + p = ProfileData.deserialize(blob) + + self.assertEqual(p.get_disassembly_format(), "raw") + self.assertEqual(p.get_top_level_counters(), {"cycles": 1000}) + + funcs = p.get_functions() + self.assertIn("main", funcs) + self.assertEqual(funcs["main"].length, 2) + self.assertAlmostEqual(funcs["main"].counters["cycles"], 50.0, places=1) + + def test_instructions(self): + blob = _make_basic_profile_bytes() + p = ProfileData.deserialize(blob) + + instructions = p.get_code_for_function("main") + self.assertEqual(len(instructions), 2) + + self.assertEqual(instructions[0].address, 0x1000) + self.assertAlmostEqual(instructions[0].counters["cycles"], 30.0, places=1) + self.assertEqual(instructions[0].text, "push rbp") + + self.assertEqual(instructions[1].address, 0x1004) + self.assertAlmostEqual(instructions[1].counters["cycles"], 20.0, places=1) + self.assertEqual(instructions[1].text, "mov rsp, rbp") + + def test_multiple_functions(self): + data = _make_v1_profile_data( + counters={"cycles": 5000, "branch-misses": 200}, + functions={ + "foo": { + "counters": {"cycles": 30.0, "branch-misses": 5.0}, + "data": [ + [{"cycles": 30.0, "branch-misses": 5.0}, 0x2000, "ret"], + ], + }, + "bar": { + "counters": {"cycles": 20.0, "branch-misses": 3.0}, + "data": [ + [{"cycles": 10.0, "branch-misses": 1.0}, 0x3000, "nop"], + [{"cycles": 10.0, "branch-misses": 2.0}, 0x3004, "ret"], + ], + }, + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + + counters = p.get_top_level_counters() + self.assertEqual(counters["cycles"], 5000) + self.assertEqual(counters["branch-misses"], 200) + + funcs = p.get_functions() + self.assertEqual(len(funcs), 2) + self.assertIn("foo", funcs) + self.assertIn("bar", funcs) + self.assertEqual(funcs["foo"].length, 1) + self.assertEqual(funcs["bar"].length, 2) + + foo_insns = p.get_code_for_function("foo") + self.assertEqual(len(foo_insns), 1) + self.assertEqual(foo_insns[0].address, 0x2000) + self.assertEqual(foo_insns[0].text, "ret") + + bar_insns = p.get_code_for_function("bar") + self.assertEqual(len(bar_insns), 2) + self.assertEqual(bar_insns[0].address, 0x3000) + self.assertEqual(bar_insns[1].address, 0x3004) + + def test_many_counters(self): + ctr_names = {f"counter_{i}": i * 100 for i in range(6)} + fn_ctrs = {f"counter_{i}": float(i) for i in range(6)} + insn_ctrs = {f"counter_{i}": float(i) * 0.5 for i in range(6)} + data = _make_v1_profile_data( + counters=ctr_names, + functions={ + "f": { + "counters": fn_ctrs, + "data": [[insn_ctrs, 0x100, "nop"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + + self.assertEqual(len(p.get_top_level_counters()), 6) + insns = p.get_code_for_function("f") + self.assertEqual(len(insns[0].counters), 6) + + def test_single_instruction_function(self): + data = _make_v1_profile_data( + functions={ + "tiny": { + "counters": {"cycles": 100.0}, + "data": [[{"cycles": 100.0}, 0x0, "ret"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("tiny") + self.assertEqual(len(insns), 1) + + def test_zero_counter_values(self): + data = _make_v1_profile_data( + counters={"cycles": 0}, + functions={ + "f": { + "counters": {"cycles": 0.0}, + "data": [[{"cycles": 0.0}, 0x100, "nop"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + self.assertEqual(p.get_top_level_counters()["cycles"], 0) + insns = p.get_code_for_function("f") + self.assertAlmostEqual(insns[0].counters["cycles"], 0.0) + + def test_large_addresses(self): + data = _make_v1_profile_data( + functions={ + "f": { + "counters": {"cycles": 10.0}, + "data": [ + [{"cycles": 5.0}, 0xFFFF0000, "nop"], + [{"cycles": 5.0}, 0xFFFF1000, "ret"], + ], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("f") + self.assertEqual(insns[0].address, 0xFFFF0000) + self.assertEqual(insns[1].address, 0xFFFF1000) + + def test_lazy_decompression(self): + """Metadata access does not trigger BZ2 decompression.""" + blob = _make_basic_profile_bytes() + p = ProfileData.deserialize(blob) + + # These should work without decompressing + p.get_disassembly_format() + p.get_top_level_counters() + p.get_functions() + + # Compressed sections should still be None (not decompressed) + self.assertIsNone(p._line_counters) + + def test_unknown_function_raises_keyerror(self): + blob = _make_basic_profile_bytes() + p = ProfileData.deserialize(blob) + with self.assertRaises(KeyError): + p.get_code_for_function("nonexistent") + + def test_empty_function(self): + """A function with 0 instructions should return an empty list.""" + data = _make_v1_profile_data( + functions={ + "empty_fn": { + "counters": {"cycles": 0.0}, + "data": [], + }, + "nonempty": { + "counters": {"cycles": 10.0}, + "data": [[{"cycles": 10.0}, 0x100, "nop"]], + }, + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("empty_fn") + self.assertEqual(insns, []) + # Non-empty function should still work + self.assertEqual(len(p.get_code_for_function("nonempty")), 1) + + +# --------------------------------------------------------------------------- +# Error case tests (hand-crafted bytes) +# --------------------------------------------------------------------------- + +class TestProfileErrors(unittest.TestCase): + def test_empty_blob(self): + with self.assertRaises(ProfileParseError): + ProfileData.deserialize(b"") + + def test_wrong_version(self): + # Version 3 encoded as ULEB128 + with self.assertRaises(ProfileParseError) as ctx: + ProfileData.deserialize(b"\x03") + self.assertIn("version 3", str(ctx.exception)) + + def test_version_zero(self): + with self.assertRaises(ProfileParseError): + ProfileData.deserialize(b"\x00") + + def test_truncated_header(self): + # Valid version byte, but truncated section headers + with self.assertRaises(ProfileParseError): + ProfileData.deserialize(b"\x02\x00") + + def test_corrupt_bz2(self): + """Valid headers but corrupt compressed data should raise on access.""" + blob = _make_basic_profile_bytes() + p = ProfileData.deserialize(blob) + + # Corrupt the raw BZ2 data + p._raw_line_counters = b"not valid bz2 data" + p._line_counters = None # Reset cache + + with self.assertRaises(ProfileParseError) as ctx: + p.get_code_for_function("main") + self.assertIn("decompress", str(ctx.exception)) + + +# --------------------------------------------------------------------------- +# Float encoding edge cases +# --------------------------------------------------------------------------- + +class TestFloatEncoding(unittest.TestCase): + def test_zero_float(self): + """0.0 is special-cased to ULEB128(0).""" + data = _make_v1_profile_data( + functions={ + "f": { + "counters": {"cycles": 0.0}, + "data": [[{"cycles": 0.0}, 0x100, "nop"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("f") + self.assertEqual(insns[0].counters["cycles"], 0.0) + + def test_small_positive_float(self): + data = _make_v1_profile_data( + functions={ + "f": { + "counters": {"cycles": 0.001}, + "data": [[{"cycles": 0.001}, 0x100, "nop"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("f") + self.assertAlmostEqual(insns[0].counters["cycles"], 0.001, places=3) + + def test_large_positive_float(self): + data = _make_v1_profile_data( + functions={ + "f": { + "counters": {"cycles": 99.99}, + "data": [[{"cycles": 99.99}, 0x100, "nop"]], + } + }, + ) + blob = _v1_to_v2_bytes(data) + p = ProfileData.deserialize(blob) + insns = p.get_code_for_function("f") + self.assertAlmostEqual(insns[0].counters["cycles"], 99.99, places=1) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/server/db/v5/test_schema.py b/tests/server/db/v5/test_schema.py new file mode 100644 index 000000000..2021491b9 --- /dev/null +++ b/tests/server/db/v5/test_schema.py @@ -0,0 +1,336 @@ +# RUN: python %s +# END. + +import sys +import unittest + +from lnt.server.db.v5.schema import ( + CommitField, + MachineField, + Metric, + SchemaError, + parse_schema, +) + + +class TestParseSchemaBasic(unittest.TestCase): + + def test_minimal_schema(self): + schema = parse_schema({"name": "minimal"}) + self.assertEqual(schema.name, "minimal") + self.assertEqual(schema.metrics, []) + self.assertEqual(schema.commit_fields, []) + self.assertEqual(schema.machine_fields, []) + + def test_full_schema(self): + data = { + "name": "nts", + "metrics": [ + {"name": "compile_time", "type": "Real", "display_name": "Compile Time", + "unit": "seconds", "unit_abbrev": "s"}, + {"name": "execution_time", "type": "real", "bigger_is_better": False}, + {"name": "compile_status", "type": "status"}, + {"name": "hash", "type": "Hash"}, + ], + "commit_fields": [ + {"name": "git_sha", "searchable": True}, + {"name": "author", "searchable": True}, + {"name": "commit_message", "type": "text"}, + {"name": "commit_timestamp", "type": "datetime"}, + {"name": "priority", "type": "integer"}, + ], + "machine_fields": [ + {"name": "hardware", "searchable": True}, + {"name": "os", "searchable": True}, + ], + } + schema = parse_schema(data) + self.assertEqual(schema.name, "nts") + + self.assertEqual(len(schema.metrics), 4) + self.assertEqual(schema.metrics[0].name, "compile_time") + self.assertEqual(schema.metrics[0].type, "real") + self.assertEqual(schema.metrics[0].display_name, "Compile Time") + self.assertEqual(schema.metrics[0].unit, "seconds") + self.assertEqual(schema.metrics[1].type, "real") + self.assertEqual(schema.metrics[2].type, "status") + self.assertEqual(schema.metrics[3].type, "hash") + + self.assertEqual(len(schema.commit_fields), 5) + self.assertTrue(schema.commit_fields[0].searchable) + self.assertEqual(schema.commit_fields[2].type, "text") + self.assertEqual(schema.commit_fields[3].type, "datetime") + self.assertEqual(schema.commit_fields[4].type, "integer") + + self.assertEqual(len(schema.machine_fields), 2) + self.assertTrue(schema.machine_fields[0].searchable) + + def test_searchable_commit_fields(self): + data = { + "name": "test", + "commit_fields": [ + {"name": "a", "searchable": True}, + {"name": "b", "searchable": False}, + {"name": "c"}, + ], + } + schema = parse_schema(data) + self.assertEqual(len(schema.searchable_commit_fields), 1) + self.assertEqual(schema.searchable_commit_fields[0].name, "a") + + def test_searchable_fields_cached(self): + """searchable_*_fields should return the same list object on repeated access.""" + data = { + "name": "test", + "commit_fields": [{"name": "a", "searchable": True}], + "machine_fields": [{"name": "hw", "searchable": True}], + } + schema = parse_schema(data) + self.assertIs(schema.searchable_commit_fields, schema.searchable_commit_fields) + self.assertIs(schema.searchable_machine_fields, schema.searchable_machine_fields) + + +class TestSchemaValidation(unittest.TestCase): + + def test_missing_name(self): + with self.assertRaises(SchemaError): + parse_schema({}) + + def test_empty_name(self): + with self.assertRaises(SchemaError): + parse_schema({"name": ""}) + + def test_reserved_commit_field_id(self): + data = { + "name": "test", + "commit_fields": [{"name": "id"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_commit_field_commit(self): + data = { + "name": "test", + "commit_fields": [{"name": "commit"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_commit_field_ordinal(self): + data = { + "name": "test", + "commit_fields": [{"name": "ordinal"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_commit_field_tag(self): + """'tag' is reserved and cannot be used as a commit_field name.""" + with self.assertRaises(SchemaError): + parse_schema({ + "name": "test", + "commit_fields": [{"name": "tag"}], + }) + + def test_reserved_machine_field_id(self): + data = { + "name": "test", + "machine_fields": [{"name": "id"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_machine_field_name(self): + data = { + "name": "test", + "machine_fields": [{"name": "name"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_machine_field_parameters(self): + data = { + "name": "test", + "machine_fields": [{"name": "parameters"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_unknown_commit_field_type(self): + data = { + "name": "test", + "commit_fields": [{"name": "foo", "type": "blob"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_unknown_metric_type(self): + data = { + "name": "test", + "metrics": [{"name": "foo", "type": "complex"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_duplicate_commit_field(self): + data = { + "name": "test", + "commit_fields": [{"name": "a"}, {"name": "a"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_duplicate_machine_field(self): + data = { + "name": "test", + "machine_fields": [{"name": "a"}, {"name": "a"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_duplicate_metric(self): + data = { + "name": "test", + "metrics": [{"name": "a"}, {"name": "a"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_commit_field_missing_name(self): + data = { + "name": "test", + "commit_fields": [{"type": "text"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_metric_missing_name(self): + data = { + "name": "test", + "metrics": [{"type": "real"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_default_commit_field_type(self): + """Omitting type on commit_field should default to 'default'.""" + data = { + "name": "test", + "commit_fields": [{"name": "foo"}], + } + schema = parse_schema(data) + self.assertEqual(schema.commit_fields[0].type, "default") + + def test_default_metric_type(self): + """Omitting type on metric should default to 'real'.""" + data = { + "name": "test", + "metrics": [{"name": "foo"}], + } + schema = parse_schema(data) + self.assertEqual(schema.metrics[0].type, "real") + + def test_bigger_is_better(self): + data = { + "name": "test", + "metrics": [{"name": "score", "type": "real", "bigger_is_better": True}], + } + schema = parse_schema(data) + self.assertTrue(schema.metrics[0].bigger_is_better) + + def test_reserved_metric_name_id(self): + """Metric named 'id' should be rejected (reserved Sample column).""" + data = { + "name": "test", + "metrics": [{"name": "id", "type": "real"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_metric_name_run_id(self): + """Metric named 'run_id' should be rejected (reserved Sample column).""" + data = { + "name": "test", + "metrics": [{"name": "run_id", "type": "real"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_reserved_metric_name_test_id(self): + """Metric named 'test_id' should be rejected (reserved Sample column).""" + data = { + "name": "test", + "metrics": [{"name": "test_id", "type": "real"}], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + +class TestDisplayFlag(unittest.TestCase): + """Tests for the display:true commit_field validation (design D4).""" + + def test_single_display_true(self): + data = { + "name": "test", + "commit_fields": [ + {"name": "sha", "display": True}, + {"name": "author"}, + ], + } + schema = parse_schema(data) + self.assertTrue(schema.commit_fields[0].display) + self.assertFalse(schema.commit_fields[1].display) + + def test_no_display_true(self): + data = { + "name": "test", + "commit_fields": [ + {"name": "sha"}, + {"name": "author"}, + ], + } + schema = parse_schema(data) + self.assertFalse(schema.commit_fields[0].display) + self.assertFalse(schema.commit_fields[1].display) + + def test_multiple_display_true_rejected(self): + data = { + "name": "test", + "commit_fields": [ + {"name": "sha", "display": True}, + {"name": "label", "display": True}, + ], + } + with self.assertRaises(SchemaError): + parse_schema(data) + + def test_display_default_false(self): + data = { + "name": "test", + "commit_fields": [{"name": "foo"}], + } + schema = parse_schema(data) + self.assertFalse(schema.commit_fields[0].display) + + +class TestDataclassImmutability(unittest.TestCase): + """Schema dataclasses should be frozen.""" + + def test_commit_field_frozen(self): + cf = CommitField(name="x") + with self.assertRaises(AttributeError): + cf.name = "y" + + def test_machine_field_frozen(self): + mf = MachineField(name="x") + with self.assertRaises(AttributeError): + mf.name = "y" + + def test_metric_frozen(self): + m = Metric(name="x") + with self.assertRaises(AttributeError): + m.name = "y" + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/db/v5/test_time_series.py b/tests/server/db/v5/test_time_series.py new file mode 100644 index 000000000..e71e0f1c9 --- /dev/null +++ b/tests/server/db/v5/test_time_series.py @@ -0,0 +1,388 @@ +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: python %s +# END. + +import datetime +import os +import sys +import unittest + +import sqlalchemy +import sqlalchemy.orm + +from lnt.server.db.v5.schema import parse_schema +from lnt.server.db.v5.models import create_suite_models +from lnt.server.db.v5 import V5TestSuiteDB + + +def _make_engine(): + db_uri = os.environ.get('LNT_TEST_DB_URI') + db_name = os.environ.get('LNT_TEST_DB_NAME') + if not db_uri or not db_name: + raise unittest.SkipTest( + "LNT_TEST_DB_URI / LNT_TEST_DB_NAME not set") + return sqlalchemy.create_engine(f"{db_uri}/{db_name}") + + +def _test_schema(): + return parse_schema({ + "name": "ts", + "metrics": [ + {"name": "execution_time", "type": "real"}, + ], + "commit_fields": [ + {"name": "author", "searchable": True}, + ], + "machine_fields": [ + {"name": "hardware", "searchable": True}, + ], + }) + + +class TestTimeSeries(unittest.TestCase): + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + # Seed test data + session = cls.Session(expire_on_commit=False) + + cls.machine = cls.tsdb.get_or_create_machine( + session, "ts-machine", hardware="x86_64") + + cls.test = cls.tsdb.get_or_create_tests(session, ["ts-test/bench"]) + cls.test = session.query(cls.tsdb.Test).filter_by( + name="ts-test/bench").one() + + # Create 5 commits with ordinals + cls.commits = [] + for i in range(5): + c = cls.tsdb.get_or_create_commit( + session, f"commit-{i}", author=f"Author-{i}") + c.ordinal = (i + 1) * 10 # 10, 20, 30, 40, 50 + cls.commits.append(c) + session.flush() + + # Create a commit WITHOUT ordinal + cls.unordered_commit = cls.tsdb.get_or_create_commit( + session, "unordered-commit") + session.flush() + + # Create runs and samples + base_time = datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + cls.runs = [] + for i, c in enumerate(cls.commits): + run = cls.tsdb.create_run( + session, cls.machine, commit=c, + submitted_at=base_time + datetime.timedelta(hours=i)) + cls.tsdb.create_samples(session, run, [{ + "test_id": cls.test.id, + "execution_time": float(i + 1), + }]) + cls.runs.append(run) + + # Run at unordered commit + cls.unordered_run = cls.tsdb.create_run( + session, cls.machine, commit=cls.unordered_commit, + submitted_at=base_time + datetime.timedelta(hours=10)) + cls.tsdb.create_samples(session, cls.unordered_run, [{ + "test_id": cls.test.id, + "execution_time": 99.0, + }]) + + session.commit() + session.close() + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_basic_query(self): + session = self.Session() + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time") + # Should include all 5 ordered + 1 unordered commits (6 total) + # (commitless run excluded because it has no commit join) + self.assertEqual(len(results), 6) + session.close() + + def test_sort_by_ordinal(self): + """Sorting by ordinal excludes unordered commits.""" + session = self.Session() + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time", + sort="ordinal") + # Only 5 commits with ordinals + self.assertEqual(len(results), 5) + ordinals = [r["ordinal"] for r in results] + self.assertEqual(ordinals, [10, 20, 30, 40, 50]) + session.close() + + def test_commit_range(self): + session = self.Session() + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time", + commit_range=(20, 40)) + ordinals = [r["ordinal"] for r in results] + self.assertEqual(len(results), 3) + for o in ordinals: + self.assertGreaterEqual(o, 20) + self.assertLessEqual(o, 40) + session.close() + + def test_time_range(self): + session = self.Session() + start = datetime.datetime(2024, 1, 1, 13, 0, 0, tzinfo=datetime.timezone.utc) # after first run + end = datetime.datetime(2024, 1, 1, 15, 0, 0, tzinfo=datetime.timezone.utc) # up to 3rd run + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time", + time_range=(start, end)) + self.assertGreater(len(results), 0) + for r in results: + self.assertGreaterEqual(r["submitted_at"], start) + self.assertLessEqual(r["submitted_at"], end) + session.close() + + def test_create_run_without_commit_raises(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.create_run( + session, self.machine, commit=None, + submitted_at=datetime.datetime(2024, 1, 1, 12, 0, 0, tzinfo=datetime.timezone.utc)) + session.close() + + def test_unknown_metric_raises(self): + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.query_time_series( + session, self.machine, self.test, "nonexistent_metric") + session.close() + + def test_limit(self): + session = self.Session() + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time", + limit=2) + self.assertEqual(len(results), 2) + session.close() + + def test_result_structure(self): + session = self.Session() + results = self.tsdb.query_time_series( + session, self.machine, self.test, "execution_time", + sort="ordinal", limit=1) + self.assertEqual(len(results), 1) + r = results[0] + self.assertIn("commit", r) + self.assertIn("ordinal", r) + self.assertIn("value", r) + self.assertIn("run_id", r) + self.assertIn("submitted_at", r) + session.close() + + +class TestQueryTrends(unittest.TestCase): + """Tests for V5TestSuiteDB.query_trends() geomean aggregation.""" + + @classmethod + def setUpClass(cls): + cls.engine = _make_engine() + cls.schema = _test_schema() + cls.suite_models = create_suite_models(cls.schema) + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.suite_models.base.metadata.create_all(cls.engine) + cls.Session = sqlalchemy.orm.sessionmaker(cls.engine) + + class _FakeV5DB: + pass + cls.tsdb = V5TestSuiteDB(_FakeV5DB(), cls.schema, cls.suite_models) + + # Seed data: 2 machines, 3 commits, multiple tests per commit + session = cls.Session(expire_on_commit=False) + + cls.machine_a = cls.tsdb.get_or_create_machine( + session, "trends-machine-a", hardware="x86_64") + cls.machine_b = cls.tsdb.get_or_create_machine( + session, "trends-machine-b", hardware="arm64") + + cls.tsdb.get_or_create_tests( + session, ["trends/bench1", "trends/bench2"]) + cls.test1 = session.query(cls.tsdb.Test).filter_by( + name="trends/bench1").one() + cls.test2 = session.query(cls.tsdb.Test).filter_by( + name="trends/bench2").one() + + cls.commits = [] + base_time = datetime.datetime(2024, 3, 1, 12, 0, 0, tzinfo=datetime.timezone.utc) + for i in range(3): + c = cls.tsdb.get_or_create_commit(session, f"trends-commit-{i}") + c.ordinal = (i + 1) * 10 # 10, 20, 30 + cls.commits.append(c) + session.flush() + + # Create runs and samples for machine_a + for i, c in enumerate(cls.commits): + run = cls.tsdb.create_run( + session, cls.machine_a, commit=c, + submitted_at=base_time + datetime.timedelta(hours=i)) + # Two tests per run with known positive values + cls.tsdb.create_samples(session, run, [ + {"test_id": cls.test1.id, "execution_time": 2.0}, + {"test_id": cls.test2.id, "execution_time": 8.0}, + ]) + + # Create runs and samples for machine_b (only first 2 commits) + for i, c in enumerate(cls.commits[:2]): + run = cls.tsdb.create_run( + session, cls.machine_b, commit=c, + submitted_at=base_time + datetime.timedelta(hours=i + 10)) + cls.tsdb.create_samples(session, run, [ + {"test_id": cls.test1.id, "execution_time": 4.0}, + ]) + + session.commit() + session.close() + + @classmethod + def tearDownClass(cls): + cls.suite_models.base.metadata.drop_all(cls.engine) + cls.engine.dispose() + + def test_basic_query_trends(self): + """query_trends returns geomean-aggregated data.""" + session = self.Session() + results = self.tsdb.query_trends(session, "execution_time") + self.assertGreater(len(results), 0) + # Check structure + r = results[0] + self.assertIn("machine_name", r) + self.assertIn("commit", r) + self.assertIn("ordinal", r) + self.assertIn("value", r) + self.assertIn("submitted_at", r) + session.close() + + def test_query_trends_geomean_value(self): + """Verify the geomean is computed correctly for machine_a.""" + session = self.Session() + results = self.tsdb.query_trends( + session, "execution_time", + machine_ids=[self.machine_a.id]) + # Machine A has 3 commits, each with values [2.0, 8.0] + # geomean(2, 8) = exp(avg(ln(2), ln(8))) = exp((ln2+ln8)/2) + # = exp(ln(2*8)/2) = exp(ln(16)/2) = sqrt(16) = 4.0 + for r in results: + self.assertEqual(r["machine_name"], "trends-machine-a") + self.assertAlmostEqual(r["value"], 4.0, places=5) + self.assertEqual(len(results), 3) + session.close() + + def test_query_trends_filter_by_machine(self): + """Filter by machine_ids returns only that machine's data.""" + session = self.Session() + results = self.tsdb.query_trends( + session, "execution_time", + machine_ids=[self.machine_b.id]) + for r in results: + self.assertEqual(r["machine_name"], "trends-machine-b") + # Machine B only has 2 commits + self.assertEqual(len(results), 2) + session.close() + + def test_query_trends_last_n(self): + """last_n limits to the most recent N commits by ordinal.""" + session = self.Session() + # Ordinals: 10, 20, 30. last_n=2 -> ordinals 20 and 30. + # Machine A has data at 10, 20, 30 -> 2 rows returned. + # Machine B has data at 10, 20 -> only ordinal 20 matches -> 1 row. + results = self.tsdb.query_trends( + session, "execution_time", last_n=2) + ordinals = {r["ordinal"] for r in results} + self.assertEqual(ordinals, {20, 30}) + a_rows = [r for r in results if r["machine_name"] == "trends-machine-a"] + b_rows = [r for r in results if r["machine_name"] == "trends-machine-b"] + self.assertEqual(len(a_rows), 2) + self.assertEqual(len(b_rows), 1) + session.close() + + def test_query_trends_last_n_one(self): + """last_n=1 returns only the single most recent commit.""" + session = self.Session() + results = self.tsdb.query_trends( + session, "execution_time", last_n=1) + ordinals = {r["ordinal"] for r in results} + self.assertEqual(ordinals, {30}) + session.close() + + def test_query_trends_last_n_exceeds_available(self): + """last_n larger than available commits returns all data.""" + session = self.Session() + all_results = self.tsdb.query_trends(session, "execution_time") + limited_results = self.tsdb.query_trends( + session, "execution_time", last_n=100) + self.assertEqual(len(limited_results), len(all_results)) + session.close() + + def test_query_trends_last_n_none_returns_all(self): + """Omitting last_n returns all data (no filtering by count).""" + session = self.Session() + results = self.tsdb.query_trends(session, "execution_time") + # 3 commits for machine_a + 2 for machine_b = 5 + self.assertEqual(len(results), 5) + session.close() + + def test_query_trends_excludes_unordered_commits(self): + """Commits without ordinals are excluded from trends results.""" + session = self.Session() + # Create a commit without ordinal + unordered = self.tsdb.get_or_create_commit(session, "unordered-commit") + # ordinal remains None + run = self.tsdb.create_run( + session, self.machine_a, commit=unordered, + submitted_at=datetime.datetime(2024, 3, 2, 12, 0, 0, + tzinfo=datetime.timezone.utc)) + self.tsdb.create_samples(session, run, [ + {"test_id": self.test1.id, "execution_time": 99.0}, + ]) + session.flush() + + results = self.tsdb.query_trends(session, "execution_time") + for r in results: + self.assertIsNotNone(r["ordinal"]) + # The unordered commit should not appear + commits = {r["commit"] for r in results} + self.assertNotIn("unordered-commit", commits) + session.close() + + def test_query_trends_unknown_metric_raises(self): + """Unknown metric name raises ValueError.""" + session = self.Session() + with self.assertRaises(ValueError): + self.tsdb.query_trends(session, "nonexistent_metric") + session.close() + + def test_query_trends_ordered_by_ordinal(self): + """Results are ordered by ordinal.""" + session = self.Session() + results = self.tsdb.query_trends( + session, "execution_time", + machine_ids=[self.machine_a.id]) + ordinals = [r["ordinal"] for r in results] + self.assertEqual(ordinals, sorted(ordinals)) + session.close() + + +if __name__ == '__main__': + unittest.main(argv=[sys.argv[0]], exit=True) diff --git a/tests/server/ui/change_processing.py b/tests/server/ui/change_processing.py index 7213b7027..3a8d66f0a 100644 --- a/tests/server/ui/change_processing.py +++ b/tests/server/ui/change_processing.py @@ -7,6 +7,7 @@ import subprocess import sys import unittest +import uuid from sqlalchemy import or_ from sqlalchemy.orm import joinedload @@ -87,6 +88,7 @@ def setUp(self): machine, test, a_field.id) + field_change.uuid = str(uuid.uuid4()) field_change.run = run session.add(field_change) @@ -95,19 +97,21 @@ def setUp(self): machine2, test, a_field.id) + fc_mach2.uuid = str(uuid.uuid4()) fc_mach2.run = run2 session.add(fc_mach2) field_change2 = self.field_change2 = ts_db.FieldChange(order1235, order1236, machine, test, a_field.id) - + field_change2.uuid = str(uuid.uuid4()) field_change2.run = run session.add(field_change2) field_change3 = self.field_change3 = ts_db.FieldChange(order1237, order1238, machine, test, a_field.id) + field_change3.uuid = str(uuid.uuid4()) session.add(field_change3) regression = self.regression = ts_db.Regression("Regression of 1 benchmarks:", "PR1234", @@ -170,6 +174,7 @@ def test_change_grouping_criteria(self): self.machine2, self.test2, self.a_field.id) + field_change7.uuid = str(uuid.uuid4()) session.add(field_change7) active_indicators = session.query(ts_db.FieldChange) \ @@ -192,6 +197,7 @@ def test_change_grouping_criteria(self): self.machine2, self.test, self.a_field.id) + field_change4.uuid = str(uuid.uuid4()) # Check a regression matches if all fields match. ret, _ = identify_related_changes(session, ts_db, field_change4, active_indicators) @@ -202,6 +208,7 @@ def test_change_grouping_criteria(self): self.machine, self.test2, self.a_field.id) + field_change5.uuid = str(uuid.uuid4()) # Check a regression matches if all fields match. ret, _ = identify_related_changes(session, ts_db, field_change5, active_indicators) @@ -211,6 +218,7 @@ def test_change_grouping_criteria(self): self.machine, self.test, self.a_field2.id) + field_change6.uuid = str(uuid.uuid4()) # Check a regression matches if all fields match. ret, _ = identify_related_changes(session, ts_db, field_change6, active_indicators) @@ -265,6 +273,59 @@ def test_run_deletion(self): self.assertEqual(len(ri_ids_new), 0) + def test_fieldchanges_have_uuids(self): + """Verify that all FieldChanges created in setUp have UUIDs set. + + This is a regression test for a bug where FieldChanges created through + the v4 UI path (manual regression creation) did not get UUIDs assigned, + making them invisible to the v5 API. + """ + session = self.session + ts_db = self.ts_db + + # All FieldChanges in the database should have a UUID. + all_fcs = session.query(ts_db.FieldChange).all() + self.assertGreater(len(all_fcs), 0, + "Expected at least one FieldChange in the DB") + for fc in all_fcs: + self.assertIsNotNone(fc.uuid, + "FieldChange id=%s is missing a UUID" % fc.id) + self.assertNotEqual(fc.uuid, '', + "FieldChange id=%s has an empty UUID" % fc.id) + + def test_manual_fieldchange_creation_gets_uuid(self): + """Simulate the v4 manual regression creation path and verify UUIDs. + + This mirrors what regression_views.v4_make_regression does when + creating a FieldChange that doesn't already exist. + """ + session = self.session + ts_db = self.ts_db + + # Create a new FieldChange the same way regression_views.py does. + new_fc = ts_db.FieldChange( + start_order=self.order1234, + end_order=self.order1238, + machine=self.machine, + test=self.test2, + field_id=self.a_field.id) + new_fc.uuid = str(uuid.uuid4()) + session.add(new_fc) + session.flush() + + # Verify the UUID is set and valid. + self.assertIsNotNone(new_fc.uuid) + self.assertNotEqual(new_fc.uuid, '') + + # Verify it's a valid UUID string. + parsed = uuid.UUID(new_fc.uuid) + self.assertEqual(str(parsed), new_fc.uuid) + + # Verify it persists after commit. + session.commit() + reloaded = session.query(ts_db.FieldChange).get(new_fc.id) + self.assertEqual(reloaded.uuid, new_fc.uuid) + if __name__ == '__main__': unittest.main() diff --git a/tests/server/ui/v5/test_spa_shell.py b/tests/server/ui/v5/test_spa_shell.py new file mode 100644 index 000000000..0c7af38a8 --- /dev/null +++ b/tests/server/ui/v5/test_spa_shell.py @@ -0,0 +1,176 @@ +# Tests for the v5 SPA shell. +# Verifies that all SPA routes serve correctly, including catch-all routing, +# trailing slashes, template content, and error cases. +# +# RUN: rm -rf %t.instance %t.pg.log +# RUN: %{utils}/with_postgres.sh %t.pg.log \ +# RUN: %{utils}/with_temporary_instance.py %t.instance --db-version 5.0 \ +# RUN: -- python %s %t.instance +# END. + +import sys +import unittest + +import lnt.server.ui.app + +INSTANCE_PATH = sys.argv.pop(1) + + +class TestSPAShell(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.app = lnt.server.ui.app.App.create_standalone(INSTANCE_PATH) + cls.app.testing = True + cls.client = cls.app.test_client() + + # --- Suite-agnostic routes --- + + def test_dashboard_route(self): + resp = self.client.get('/v5/') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite=""', html) + + def test_dashboard_no_trailing_slash(self): + """GET /v5 (no trailing slash) redirects or serves the SPA.""" + resp = self.client.get('/v5') + self.assertIn(resp.status_code, (200, 301, 302, 308)) + + def test_graph_route(self): + resp = self.client.get('/v5/graph') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite=""', html) + + def test_compare_route(self): + resp = self.client.get('/v5/compare') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite=""', html) + + def test_admin_route(self): + resp = self.client.get('/v5/admin') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite=""', html) + + def test_admin_route_trailing_slash(self): + """/v5/admin/ (trailing slash) must hit the admin route, not the catch-all.""" + resp = self.client.get('/v5/admin/') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite=""', html) + + def test_test_suites_route(self): + resp = self.client.get('/v5/test-suites') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_test_suites_trailing_slash(self): + """/v5/test-suites/ (trailing slash) should work.""" + resp = self.client.get('/v5/test-suites/') + self.assertIn(resp.status_code, (200, 301, 302, 308)) + + # --- Suite-scoped routes --- + + def test_suite_scoped_route(self): + resp = self.client.get('/v5/nts/') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite="nts"', html) + + def test_machines_route(self): + resp = self.client.get('/v5/nts/machines') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_machine_detail_route(self): + resp = self.client.get('/v5/nts/machines/some-machine') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_runs_route(self): + resp = self.client.get('/v5/nts/runs/some-uuid') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_commits_route(self): + resp = self.client.get('/v5/nts/commits/some-value') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_regressions_route(self): + resp = self.client.get('/v5/nts/regressions') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_field_changes_route(self): + resp = self.client.get('/v5/nts/field-changes') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_admin_subpath_under_testsuite(self): + resp = self.client.get('/v5/nts/admin') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_deeply_nested_route(self): + """Catch-all should handle deep paths.""" + resp = self.client.get('/v5/nts/regressions/some-uuid') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + + def test_compare_suite_scoped(self): + resp = self.client.get('/v5/nts/compare') + self.assertEqual(resp.status_code, 200) + html = resp.get_data(as_text=True) + self.assertIn('id="v5-app"', html) + self.assertIn('data-testsuite="nts"', html) + + # --- Template content --- + + def test_template_has_testsuites_data(self): + resp = self.client.get('/v5/nts/') + html = resp.get_data(as_text=True) + self.assertIn('data-testsuites=', html) + self.assertIn('nts', html) + + def test_template_loads_v5_assets(self): + resp = self.client.get('/v5/') + html = resp.get_data(as_text=True) + self.assertIn('v5/v5.js', html) + self.assertIn('v5/v5.css', html) + + def test_template_has_lnt_url_base(self): + resp = self.client.get('/v5/') + html = resp.get_data(as_text=True) + self.assertIn('var lnt_url_base=', html) + + # --- Error cases --- + + def test_nonexistent_testsuite(self): + resp = self.client.get('/v5/nonexistent/') + self.assertEqual(resp.status_code, 404) + + def test_compare_nonexistent_testsuite(self): + resp = self.client.get('/v5/nonexistent/compare') + self.assertEqual(resp.status_code, 404) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/utils/with_temporary_instance.py b/tests/utils/with_temporary_instance.py index ab9581bca..a7b42676a 100755 --- a/tests/utils/with_temporary_instance.py +++ b/tests/utils/with_temporary_instance.py @@ -14,6 +14,60 @@ import sys +def _setup_v5_instance(dest_dir): + """Create a test suite in a freshly-created v5 LNT instance. + + After ``lnt create --db-version 5.0`` has built the directory structure, + config, and v5 global tables, this function: + 1. Boots the app (reads existing v5 schema). + 2. Creates an NTS-equivalent test suite. + """ + import lnt.server.ui.app + app = lnt.server.ui.app.App.create_standalone(dest_dir) + + from lnt.server.db.v5.schema import parse_schema + + nts_schema = parse_schema({ + 'name': 'nts', + 'metrics': [ + {'name': 'compile_time', 'type': 'real', + 'display_name': 'Compile Time', 'unit': 'seconds', + 'unit_abbrev': 's'}, + {'name': 'compile_status', 'type': 'status'}, + {'name': 'execution_time', 'type': 'real', + 'display_name': 'Execution Time', 'unit': 'seconds', + 'unit_abbrev': 's'}, + {'name': 'execution_status', 'type': 'status'}, + {'name': 'score', 'type': 'real', 'bigger_is_better': True, + 'display_name': 'Score'}, + {'name': 'mem_bytes', 'type': 'real', + 'display_name': 'Memory Usage', 'unit': 'bytes', + 'unit_abbrev': 'b'}, + {'name': 'hash', 'type': 'hash'}, + {'name': 'hash_status', 'type': 'status'}, + {'name': 'code_size', 'type': 'real', + 'display_name': 'Code Size', 'unit': 'bytes', + 'unit_abbrev': 'b'}, + ], + 'commit_fields': [ + {'name': 'llvm_project_revision', 'searchable': True, + 'display': True}, + ], + 'machine_fields': [ + {'name': 'hardware', 'searchable': True}, + {'name': 'os', 'searchable': True}, + ], + }) + + db = app.instance.get_database("default") + session = db.make_session() + try: + db.create_suite(session, nts_schema) + session.commit() + finally: + session.close() + + def main(): parser = argparse.ArgumentParser( description="Create a temporary LNT instance backed by PostgreSQL and optionally import " @@ -27,6 +81,8 @@ def main(): parser.add_argument('data_dirs', metavar='DATA_DIR', nargs='*', help='directories containing JSON report files to import, ' 'or individual JSON report files') + parser.add_argument('--db-version', default='0.4', choices=['0.4', '5.0'], + help='database version to use (default: 0.4)') # Split at '--' to separate instance arguments from the command to exec. argv = sys.argv[1:] @@ -52,6 +108,7 @@ def main(): '--default-db', db_name, '--api-auth-token', 'test_token', '--url', 'http://localhost/perf', + '--db-version', args.db_version, ]) # 2. Symlink schema YAML files into the instance. @@ -64,19 +121,25 @@ def main(): os.path.join(schemas_dst, schema), ) - # 3. Import JSON report files from each DATA_DIR (or individual file). - for data_path in data_dirs: - if os.path.isdir(data_path): - json_files = sorted(glob.glob(os.path.join(data_path, '*.json'))) - else: - json_files = [data_path] - for json_file in json_files: - with open(json_file) as f: - data = json.load(f) - suite = data.get('schema', 'nts') - subprocess.check_call(['lnt', 'import', '-s', suite, '--merge', 'append', dest_dir, json_file]) - - # 4. Exec the wrapped command. + # 3. For v5, patch the config and create the test suite programmatically. + if args.db_version == '5.0': + _setup_v5_instance(dest_dir) + + # 4. Import JSON report files from each DATA_DIR (or individual file). + # Skip for v5 -- the v4 import pipeline won't work. + if args.db_version == '0.4': + for data_path in data_dirs: + if os.path.isdir(data_path): + json_files = sorted(glob.glob(os.path.join(data_path, '*.json'))) + else: + json_files = [data_path] + for json_file in json_files: + with open(json_file) as f: + data = json.load(f) + suite = data.get('schema', 'nts') + subprocess.check_call(['lnt', 'import', '-s', suite, '--merge', 'append', dest_dir, json_file]) + + # 5. Exec the wrapped command. os.execvp(command[0], command)