From 8d70855ea624cc7e0b50de6a24a28bdf011b9d84 Mon Sep 17 00:00:00 2001 From: Richard Palethorpe Date: Tue, 26 May 2026 21:06:10 +0100 Subject: [PATCH] test: add Go + React UI coverage gates and fill test gaps (#9989) - Strict monotonic Go coverage gate (make test-coverage-check, 45% baseline) run in CI; fixes ginkgo dropping all-but-one coverprofile across multiple recursive roots, builds with -tags auth, and folds in the in-process tests/e2e suite via --coverpkg. - React UI e2e coverage (make test-ui-coverage: vite-plugin-istanbul + nyc, nix-provided Chromium) plus e2e specs for 6 previously-untested pages, and a UI coverage gate (make test-ui-coverage-check) with a small tolerance since e2e line coverage jitters ~0.5pp run-to-run. - pre-commit hook: lint + coverage on Go changes, Playwright e2e + UI coverage gate on react-ui changes; install with make install-hooks. - New Go handler tests (settings, branding), hermetic base64 download test. - fix(ui): model editor reads vram_display (snake_case), so the VRAM estimate renders again; covered by a regression test. Assisted-by: Claude:claude-opus-4-7 Signed-off-by: Richard Palethorpe --- .agents/building-and-testing.md | 29 ++ .githooks/pre-commit | 60 +++ .github/workflows/test.yml | 17 +- .github/workflows/tests-ui-e2e.yml | 28 +- .gitignore | 7 + CONTRIBUTING.md | 1 + Makefile | 116 +++++- .../localai/branding_endpoints_test.go | 233 ++++++++++++ core/http/endpoints/localai/settings_test.go | 111 ++++++ core/http/react-ui/.nycrc.json | 8 + core/http/react-ui/bun.lock | 360 ++++++++++++++---- core/http/react-ui/coverage-baseline.txt | 1 + core/http/react-ui/e2e/agents.spec.js | 24 ++ .../http/react-ui/e2e/audio-transform.spec.js | 2 +- core/http/react-ui/e2e/backend-logs.spec.js | 2 +- .../react-ui/e2e/backends-management.spec.js | 28 ++ core/http/react-ui/e2e/chat-errors.spec.js | 2 +- .../e2e/chat-polling-selection.spec.js | 2 +- core/http/react-ui/e2e/collections.spec.js | 22 ++ core/http/react-ui/e2e/coverage-fixtures.js | 40 ++ .../e2e/import-form-ux-batch-a.spec.js | 2 +- .../e2e/import-form-ux-batch-b.spec.js | 2 +- .../e2e/import-form-ux-batch-d.spec.js | 2 +- .../e2e/import-form-ux-batch-e.spec.js | 2 +- .../e2e/import-form-ux-batch-f.spec.js | 2 +- core/http/react-ui/e2e/login.spec.js | 38 ++ .../react-ui/e2e/manage-logs-link.spec.js | 2 +- core/http/react-ui/e2e/media-history.spec.js | 2 +- core/http/react-ui/e2e/model-config.spec.js | 38 +- core/http/react-ui/e2e/models-gallery.spec.js | 2 +- core/http/react-ui/e2e/navigation.spec.js | 2 +- .../nodes-per-node-backend-actions.spec.js | 2 +- core/http/react-ui/e2e/p2p.spec.js | 26 ++ .../e2e/settings-backend-logging.spec.js | 2 +- core/http/react-ui/e2e/skills.spec.js | 20 + core/http/react-ui/e2e/traces-errors.spec.js | 2 +- core/http/react-ui/e2e/traces.spec.js | 2 +- core/http/react-ui/package.json | 10 +- core/http/react-ui/playwright.config.js | 11 +- .../react-ui/src/hooks/useVramEstimate.js | 6 +- core/http/react-ui/vite.config.js | 24 +- coverage-baseline.txt | 1 + flake.nix | 8 + pkg/utils/base64_internal_test.go | 57 +++ pkg/utils/base64_test.go | 9 +- scripts/coverage-check.sh | 61 +++ scripts/ensure-playwright-browser.sh | 46 +++ scripts/run-coverage.sh | 101 +++++ scripts/ui-coverage-check.sh | 61 +++ 49 files changed, 1510 insertions(+), 126 deletions(-) create mode 100755 .githooks/pre-commit create mode 100644 core/http/endpoints/localai/branding_endpoints_test.go create mode 100644 core/http/endpoints/localai/settings_test.go create mode 100644 core/http/react-ui/.nycrc.json create mode 100644 core/http/react-ui/coverage-baseline.txt create mode 100644 core/http/react-ui/e2e/agents.spec.js create mode 100644 core/http/react-ui/e2e/backends-management.spec.js create mode 100644 core/http/react-ui/e2e/collections.spec.js create mode 100644 core/http/react-ui/e2e/coverage-fixtures.js create mode 100644 core/http/react-ui/e2e/login.spec.js create mode 100644 core/http/react-ui/e2e/p2p.spec.js create mode 100644 core/http/react-ui/e2e/skills.spec.js create mode 100644 coverage-baseline.txt create mode 100644 pkg/utils/base64_internal_test.go create mode 100755 scripts/coverage-check.sh create mode 100755 scripts/ensure-playwright-browser.sh create mode 100755 scripts/run-coverage.sh create mode 100755 scripts/ui-coverage-check.sh diff --git a/.agents/building-and-testing.md b/.agents/building-and-testing.md index 6377b4ad8..04df3426e 100644 --- a/.agents/building-and-testing.md +++ b/.agents/building-and-testing.md @@ -15,3 +15,32 @@ Let's say the user wants to build a particular backend for a given platform. For - Unless the user specifies that they want you to run the command, then just print it because not all agent frontends handle long running jobs well and the output may overflow your context - The user may say they want to build AMD or ROCM instead of hipblas, or Intel instead of SYCL or NVIDIA insted of l4t or cublas. Ask for confirmation if there is ambiguity. - Sometimes the user may need extra parameters to be added to `docker build` (e.g. `--platform` for cross-platform builds or `--progress` to view the full logs), in which case you can generate the `docker build` command directly. + +## Test coverage gate + +The core Go suites (`./pkg`, `./core`, plus the in-process integration suite `./tests/e2e`) are covered by a **strict, monotonic coverage ratchet**: + +- `make test-coverage` — runs the suites with `covermode=atomic` instrumentation and writes a merged profile to `coverage/coverage.out`. Uses the same prerequisites as `make test`. + - **`--coverpkg` (`COVERAGE_COVERPKG = core/...,pkg/...`):** coverage is attributed to the core+pkg packages, not just the package under test. This is what lets the in-process `tests/e2e` suite (which drives the real HTTP server over loopback via `application.New`) credit the `core/http/endpoints/...` handlers it exercises — folding it in roughly doubled endpoint coverage (e.g. `endpoints/openai` 13.6% → 52%). The denominator is therefore *all* of `core`+`pkg` (minus generated proto, dropped via `COVERAGE_EXCLUDE_RE`), so the number isn't comparable to a plain per-package figure. + - **Integration suites (`COVERAGE_E2E_ROOTS = ./tests/e2e`)** run non-recursively (excludes `tests/e2e/distributed`, which needs containers) with `--label-filter=!real-models` (those need a downloaded model) against the mock backend built by `prepare-test`. `tests/integration` is deliberately excluded — it needs `make backends/local-store`, which the coverage CI job doesn't build. + - **Flake note:** folding integration tests into a *strict* gate means a hard e2e failure (or a spec that silently stops running) can fail the coverage gate, not just the test. `--flake-attempts` absorbs transient retryable failures; covermode=atomic keeps line coverage deterministic otherwise. + - **Why one ginkgo run per root (`scripts/run-coverage.sh`):** passing several recursive roots to a *single* ginkgo invocation (e.g. `ginkgo -r ./pkg ./core`) only merges **one** root's coverprofile into `--output-dir`/`--coverprofile` — the others are silently dropped. Verified with ginkgo 2.29.0: `-r ./pkg ./core` yields only `./pkg` coverage, while `-r ./core` alone yields all 34 core packages. So the script runs each root separately and concatenates the (disjoint) profiles. Don't "simplify" it back to a single multi-root invocation — that's how `core/` (including all of `core/http`, ~7.4k statements) silently vanished from the number before. + - **Build tags (`COVERAGE_TAGS`, passed via `GINKGO_TAGS`):** defaults to `debug auth`. The `auth` tag is required to compile the real (sqlite-backed) auth implementation and its ~150 `//go:build auth` tests — without it those files aren't built, the tests don't run, and the gate scores auth against a stub (~3.7% instead of ~38%). If you add new tag-gated tests, extend `COVERAGE_TAGS` or they won't count (and likely won't run in CI at all). +- `make test-coverage-check` — runs `test-coverage`, then `scripts/coverage-check.sh` fails the build if total coverage is **below** the committed baseline in `coverage-baseline.txt`. The Linux job in `.github/workflows/test.yml` runs this instead of `make test`. +- `make test-coverage-baseline` — regenerates and overwrites `coverage-baseline.txt` from the current run. +- `make install-hooks` — sets `core.hooksPath` to the versioned `.githooks/`, whose `pre-commit` runs checks scoped to what's staged: Go changes → `make lint` + `make test-coverage-check`; `core/http/react-ui/` changes → `make test-ui-coverage-check` (Playwright e2e + UI coverage gate). A commit touching neither is skipped; bypass with `git commit --no-verify`. The hook resolves golangci-lint's new-from base to `upstream/master` → `origin/master` → `master`, so it works from a fork clone where `origin/master` is stale (passed to `make lint` via `LINT_NEW_FROM`). + +### React UI coverage + +The React UI (`core/http/react-ui/`) has **no component/unit tests** — its only tests are the Playwright e2e specs in `e2e/`, which run against the real app served by `tests/e2e-ui/ui-test-server` (the dist is `//go:embed`ed, so the server is rebuilt per coverage run). Those specs do genuinely exercise the UI (clicks, `fill`, `setInputFiles`, `getByRole`/`getByText`, visibility/value assertions). + +- `make test-ui-coverage` — builds an istanbul-instrumented bundle (`COVERAGE=true`, via `vite-plugin-istanbul` with `forceBuildInstrument: true` — the plugin skips production builds otherwise), re-embeds it into `ui-test-server` (the dist is `//go:embed`ed), runs the Playwright specs, and writes an `nyc` report to `core/http/react-ui/coverage/`. The specs import `{ test, expect }` from `e2e/coverage-fixtures.js` (re-exports Playwright's, plus harvests `window.__coverage__` into `.nyc_output/` after each test). Instrumentation is off unless `COVERAGE=true`, so dev/prod builds and plain `make test-ui-e2e` are unaffected (the fixture no-ops when `window.__coverage__` is absent). +- **Browser:** the flake dev shell ships `chromium` and exports `PLAYWRIGHT_CHROMIUM_PATH`; `playwright.config.js` uses it via `launchOptions.executablePath`, and the Makefile skips `playwright install` when it's set. This avoids Playwright's downloaded browser, which can't resolve system libs (`libglib-2.0`, …) on NixOS. In CI (no `PLAYWRIGHT_CHROMIUM_PATH`) the Makefile falls back to `playwright install --with-deps chromium`. +- The app is a React SPA, so coverage accumulates across in-app navigation within a test; a full `page.goto`/reload resets it. +- `.nycrc.json` uses `all: true`, so **every `src/**` file is in the report**, including 0%-coverage ones — that's how you spot features with no test at all (sort the HTML report or `coverage-summary.json` by line% ascending). +- **UI coverage gate:** `make test-ui-coverage-check` runs the suite then `scripts/ui-coverage-check.sh`, failing if total line coverage drops more than `UI_COVERAGE_TOLERANCE` (default **1.0pp**) below `core/http/react-ui/coverage-baseline.txt`. `make test-ui-coverage-baseline` regenerates the baseline. **Why a tolerance (unlike the strict Go gate):** UI e2e line coverage is *non-deterministic* — async/debounced paths (e.g. the VRAM estimate's 500ms debounce) make identical specs vary ~0.5pp run-to-run, so a zero-tolerance gate would flake. Keep the tolerance just above the observed jitter. Run in CI (`tests-ui-e2e.yml`) and pre-commit on `core/http/react-ui/` changes. + +Rules: +- The gate is **strict — there is no tolerance**. Any decrease fails, regardless of how many lines a PR adds or deletes. `covermode=atomic` makes line coverage deterministic, so there's no run-to-run jitter to excuse. +- When a change legitimately **raises** coverage, run `make test-coverage-baseline` and **commit** the updated `coverage-baseline.txt` so the ratchet moves up. Never lower the baseline by hand. +- If you can't get coverage back to baseline, the fix is to **add tests**, not to edit the baseline. diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 000000000..c09f68772 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,60 @@ +#!/usr/bin/env sh +# +# LocalAI pre-commit hook. Install it (once per clone) with: +# +# make install-hooks +# +# Runs only the checks relevant to what's staged: +# - Go files -> make lint + make test-coverage-check +# - core/http/react-ui -> make test-ui-coverage-check (Playwright e2e + gate) +# A commit touching neither is skipped entirely (docs/YAML/etc. can't change +# lint findings, Go coverage, or the UI). +# +# To bypass for a single commit (e.g. a WIP checkpoint): git commit --no-verify +set -eu + +repo_root="$(git rev-parse --show-toplevel)" +cd "$repo_root" + +staged="$(git diff --cached --name-only --diff-filter=ACMRD)" + +go_changed=0 +ui_changed=0 +if echo "$staged" | grep -qE '\.go$'; then go_changed=1; fi +if echo "$staged" | grep -qE '^core/http/react-ui/'; then ui_changed=1; fi + +if [ "$go_changed" -eq 0 ] && [ "$ui_changed" -eq 0 ]; then + echo "pre-commit: no Go or React UI changes staged — skipping." + exit 0 +fi + +if [ "$go_changed" -eq 1 ]; then + # Resolve the ref golangci-lint's new-from-merge-base should compare + # against. .golangci.yml pins origin/master, which is correct in CI + # (origin == the canonical repo) but wrong from a fork clone, where + # origin/master lags behind and lint would report the whole upstream + # backlog. Prefer upstream/master, then origin/master, then master. + lint_base="" + for ref in upstream/master origin/master master; do + if git rev-parse --verify --quiet "${ref}^{commit}" >/dev/null 2>&1; then + lint_base="$ref" + break + fi + done + + echo "pre-commit ▶ golangci-lint (make lint${lint_base:+, new-from $lint_base})" + make lint LINT_NEW_FROM="$lint_base" + + echo "pre-commit ▶ coverage gate (make test-coverage-check) — builds and runs the" + echo " pkg/core suites plus tests/e2e; can take a few minutes." + make test-coverage-check +fi + +if [ "$ui_changed" -eq 1 ]; then + echo "pre-commit ▶ React UI e2e + coverage gate (make test-ui-coverage-check) —" + echo " rebuilds the UI + ui-test-server, runs the Playwright specs, and" + echo " fails if line coverage regressed; can take a couple of minutes." + make test-ui-coverage-check +fi + +echo "pre-commit ✓ all relevant checks passed" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d516307f2..e727261c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -53,9 +53,22 @@ jobs: node-version: '22' - name: Build React UI run: make react-ui - - name: Test + # Runs the core suite with coverage and fails if total coverage dropped + # below the committed baseline (coverage-baseline.txt). The gate is + # strict — any decrease fails. Raise the baseline with + # `make test-coverage-baseline` and commit it when coverage rises. + - name: Test (with coverage gate) run: | - PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test + PATH="$PATH:/root/go/bin" make --jobs 5 --output-sync=target test-coverage-check + - name: Upload coverage report + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: coverage-linux + path: | + coverage/coverage.out + coverage/coverage.html + if-no-files-found: ignore - name: Setup tmate session if tests fail if: ${{ failure() }} uses: mxschmitt/action-tmate@v3.23 diff --git a/.github/workflows/tests-ui-e2e.yml b/.github/workflows/tests-ui-e2e.yml index 5336eadee..99bb61e57 100644 --- a/.github/workflows/tests-ui-e2e.yml +++ b/.github/workflows/tests-ui-e2e.yml @@ -37,6 +37,10 @@ jobs: uses: actions/setup-node@v6 with: node-version: '22' + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.11' - name: Proto Dependencies run: | curl -L -s https://github.com/protocolbuffers/protobuf/releases/download/v26.1/protoc-26.1-linux-x86_64.zip -o protoc.zip && \ @@ -48,16 +52,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y build-essential libopus-dev - - name: Build UI test server - run: PATH="$PATH:$HOME/go/bin" make build-ui-test-server - - name: Install Playwright - working-directory: core/http/react-ui - run: | - npm install - npx playwright install --with-deps chromium - - name: Run Playwright tests - working-directory: core/http/react-ui - run: npx playwright test + # Builds an instrumented UI bundle, runs the Playwright specs, and fails + # if line coverage regressed beyond the jitter tolerance (the gate is + # in `make test-ui-coverage-check`). PLAYWRIGHT_CHROMIUM_PATH is unset + # here, so scripts/ensure-playwright-browser.sh installs Chromium via apt. + - name: Run UI e2e + coverage gate + run: PATH="$PATH:$HOME/go/bin" make test-ui-coverage-check - name: Upload Playwright report if: ${{ failure() }} uses: actions/upload-artifact@v7 @@ -65,6 +65,14 @@ jobs: name: playwright-report path: core/http/react-ui/playwright-report/ retention-days: 7 + - name: Upload UI coverage report + if: ${{ always() }} + uses: actions/upload-artifact@v7 + with: + name: ui-coverage + path: core/http/react-ui/coverage/ + if-no-files-found: ignore + retention-days: 7 - name: Setup tmate session if tests fail if: ${{ failure() }} uses: mxschmitt/action-tmate@v3.23 diff --git a/.gitignore b/.gitignore index ec105ac85..cc7d25fa6 100644 --- a/.gitignore +++ b/.gitignore @@ -70,10 +70,17 @@ docs/static/gallery.html # per-developer customization files for the development container .devcontainer/customization/* +# Coverage profiles (the committed baseline is coverage-baseline.txt) +/coverage/ + # React UI build artifacts (keep placeholder dist/index.html) core/http/react-ui/node_modules/ core/http/react-ui/dist +# React UI coverage (vite-plugin-istanbul + nyc, via `make test-ui-coverage`) +core/http/react-ui/.nyc_output/ +core/http/react-ui/coverage/ + # Extracted backend binaries for container-based testing local-backends/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 775367b0b..df1c78909 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -198,6 +198,7 @@ For AI-assisted development, see [`AGENTS.md`](AGENTS.md) (or the equivalent [`C - Prefer modern Go idioms — for example, use `any` instead of `interface{}`. - Use [`golangci-lint`](https://golangci-lint.run) to catch common issues before submitting a PR. +- Run `make install-hooks` once per clone to enable the pre-commit hook: Go changes run `make lint` + the coverage gate (`make test-coverage-check`); `core/http/react-ui/` changes run the Playwright e2e suite (`make test-ui`). Bypass a single commit with `git commit --no-verify`. - Use [`github.com/mudler/xlog`](https://github.com/mudler/xlog) for logging (same API as `slog`). Do not use `fmt.Println` or the standard `log` package for operational logging. - Use tab indentation for Go files (as defined in `.editorconfig`). diff --git a/Makefile b/Makefile index 3eebc1871..9b8331fbf 100644 --- a/Makefile +++ b/Makefile @@ -71,8 +71,39 @@ endif TEST_PATHS?=./api/... ./pkg/... ./core/... ./backend/go/cloud-proxy/... ./backend/go/local-store/... +## Coverage output and the committed baseline that CI compares against. +## The gate is strict: total coverage must never decrease (no tolerance). +## covermode=atomic makes line coverage deterministic regardless of test +## ordering or flake retries, so there is no run-to-run jitter to absorb. +COVERAGE_DIR?=$(abspath ./coverage) +COVERAGE_PROFILE?=$(COVERAGE_DIR)/coverage.out +COVERAGE_BASELINE?=coverage-baseline.txt +## Coverage is collected one recursive root at a time and merged (see +## scripts/run-coverage.sh): passing several recursive roots to a single +## ginkgo invocation only keeps one root's coverprofile. Mirrors TEST_PATHS +## minus ./api (which doesn't exist). +COVERAGE_ROOTS?=./pkg ./core +## Build tags for the coverage build. `auth` is required to compile the real +## auth implementation and its ~150 `//go:build auth` tests (otherwise they're +## invisible and the gate scores auth against a stub). `debug` matches `test`. +COVERAGE_TAGS?=debug auth +## Coverage is attributed to these packages via --coverpkg, so the in-process +## integration suites (COVERAGE_E2E_ROOTS) credit the core/http handlers they +## drive over HTTP — not just their own test package. +COVERAGE_COVERPKG?=github.com/mudler/LocalAI/core/...,github.com/mudler/LocalAI/pkg/... +## In-process integration suites folded into coverage. Run non-recursively +## (excludes tests/e2e/distributed, which needs containers) with the mock +## backend built by prepare-test. real-models specs need a downloaded model, +## so they're filtered out. NOTE: tests/integration is intentionally NOT here — +## it needs the local-store backend built (`make backends/local-store`), which +## the coverage CI job doesn't do. +COVERAGE_E2E_ROOTS?=./tests/e2e +COVERAGE_E2E_LABELS?=!real-models +## Drop generated protobuf from the denominator (it has no tests by design). +COVERAGE_EXCLUDE_RE?=grpc/proto/.*[.]pb[.]go -.PHONY: all test build vendor lint lint-all + +.PHONY: all test test-coverage test-coverage-baseline test-coverage-check test-ui test-ui-coverage-baseline test-ui-coverage-check install-hooks build vendor lint lint-all all: help @@ -170,6 +201,36 @@ test: prepare-test OPUS_SHIM_LIBRARY=$(abspath ./pkg/opus/shim/libopusshim.so) \ $(GOCMD) run github.com/onsi/ginkgo/v2/ginkgo --flake-attempts $(TEST_FLAKES) --fail-fast -v -r $(TEST_PATHS) +## Runs the core suite ($(TEST_PATHS)) with statement-coverage instrumentation +## and writes a merged profile to $(COVERAGE_PROFILE). Deliberately omits +## --fail-fast so a single failure doesn't truncate the coverage number, and +## uses covermode=atomic so the result is deterministic. Prints the total. +test-coverage: prepare-test + @echo 'Running tests with coverage' + GINKGO_TAGS="$(COVERAGE_TAGS)" \ + COVERAGE_COVERPKG="$(COVERAGE_COVERPKG)" \ + COVERAGE_E2E_ROOTS="$(COVERAGE_E2E_ROOTS)" \ + COVERAGE_E2E_LABELS="$(COVERAGE_E2E_LABELS)" \ + COVERAGE_EXCLUDE_RE='$(COVERAGE_EXCLUDE_RE)' \ + OPUS_SHIM_LIBRARY=$(abspath ./pkg/opus/shim/libopusshim.so) \ + scripts/run-coverage.sh $(COVERAGE_DIR) $(COVERAGE_PROFILE) $(TEST_FLAKES) $(COVERAGE_ROOTS) + @$(GOCMD) tool cover -html=$(COVERAGE_PROFILE) -o $(COVERAGE_DIR)/coverage.html + @$(GOCMD) tool cover -func=$(COVERAGE_PROFILE) | tail -n1 + +## Writes the current total coverage to $(COVERAGE_BASELINE). Run this (and +## commit the result) whenever a change legitimately raises coverage so the +## ratchet moves up. Never lower it by hand. +test-coverage-baseline: test-coverage + @$(GOCMD) tool cover -func=$(COVERAGE_PROFILE) | awk '/^total:/{gsub(/%/,"",$$NF); print $$NF}' > $(COVERAGE_BASELINE) + @echo "Saved coverage baseline: $$(cat $(COVERAGE_BASELINE))%" + +## CI gate: fails if total coverage dropped more than COVERAGE_TOLERANCE +## (default 0.5pp) below the committed baseline. A small tolerance absorbs the +## run-to-run jitter from the in-process tests/e2e suite folded in via +## --coverpkg (timing-dependent which handler lines execute). +test-coverage-check: test-coverage + @scripts/coverage-check.sh $(COVERAGE_PROFILE) $(COVERAGE_BASELINE) + ######################################################## ## Lint ######################################################## @@ -185,12 +246,17 @@ test: prepare-test ## everything else automatically, so new packages are scanned by default. LINT_EXCLUDE_DIRS_RE=/(backend/go/(piper|silero-vad|llm)|cmd/launcher)(/|$$) +## Set LINT_NEW_FROM to a git ref to override .golangci.yml's +## new-from-merge-base (origin/master). Useful from a fork clone where +## origin/master is stale relative to the canonical repo — the pre-commit +## hook passes the resolved upstream ref here so local lint matches CI. +LINT_NEW_FROM?= lint: @command -v golangci-lint >/dev/null 2>&1 || { \ echo 'golangci-lint not installed. Install: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'; \ exit 1; \ } - golangci-lint run $$(go list -e -f '{{.Dir}}' ./... | grep -vE '$(LINT_EXCLUDE_DIRS_RE)') + golangci-lint run $(if $(LINT_NEW_FROM),--new-from-merge-base=$(LINT_NEW_FROM),) $$(go list -e -f '{{.Dir}}' ./... | grep -vE '$(LINT_EXCLUDE_DIRS_RE)') ## Like `lint` but reports every issue, including the pre-existing baseline ## that `lint` ignores via .golangci.yml's new-from-merge-base. Use this to @@ -202,6 +268,17 @@ lint-all: } golangci-lint run --new=false --new-from-merge-base= --new-from-rev= $$(go list -e -f '{{.Dir}}' ./... | grep -vE '$(LINT_EXCLUDE_DIRS_RE)') +######################################################## +## Git hooks +######################################################## +## Points git at the versioned .githooks/ directory so the pre-commit hook +## (lint + coverage gate) runs locally. Run once per clone. Undo with: +## `git config --unset core.hooksPath`. Skip a single commit with +## `git commit --no-verify`. +install-hooks: + git config core.hooksPath .githooks + @echo 'Installed git hooks: core.hooksPath -> .githooks (pre-commit runs lint + test-coverage-check on Go changes)' + ######################################################## ## E2E AIO tests (uses standard image with pre-configured models) ######################################################## @@ -1232,6 +1309,41 @@ build-ui-test-server: build-mock-backend react-ui protogen-go test-ui-e2e: build-ui-test-server cd core/http/react-ui && npm install && npx playwright install --with-deps chromium && npx playwright test +## Fast Playwright e2e run used by the pre-commit hook on React UI changes. +## Force-rebuilds the (non-instrumented) dist so the suite tests the working +## tree — not a stale dist the `react-ui` skip-guard would leave — re-embeds +## it into ui-test-server, and runs the specs. Uses the nix-provided browser +## when PLAYWRIGHT_CHROMIUM_PATH is set (flake dev shell), else falls back to +## downloading it as `test-ui-e2e` does. +test-ui: build-mock-backend protogen-go + cd core/http/react-ui && bun install && bun run build + $(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui + cd core/http/react-ui && sh $(CURDIR)/scripts/ensure-playwright-browser.sh && bunx playwright test + +## React UI code coverage from the Playwright e2e suite. Builds an +## istanbul-instrumented bundle (COVERAGE=true), re-embeds it into the +## ui-test-server (the dist is //go:embed'ed at compile time), runs the +## Playwright specs — which harvest window.__coverage__ via the coverage +## fixture — and writes an nyc report to core/http/react-ui/coverage/. +## Removes the instrumented dist afterwards so normal builds aren't served +## instrumented assets. +test-ui-coverage: build-mock-backend protogen-go + trap 'rm -rf "$(CURDIR)/core/http/react-ui/dist"' EXIT; \ + ( cd core/http/react-ui && bun install && bun run build:coverage ) && \ + $(GOCMD) build -o tests/e2e-ui/ui-test-server ./tests/e2e-ui && \ + ( cd core/http/react-ui && rm -rf .nyc_output coverage && \ + sh $(CURDIR)/scripts/ensure-playwright-browser.sh && \ + bunx playwright test && bun run coverage:report ) + +## UI coverage baseline (committed) and the strict gate that compares against +## it — the React mirror of test-coverage-baseline / test-coverage-check. +test-ui-coverage-baseline: test-ui-coverage + @node -e 'const fs=require("fs");process.stdout.write(String(JSON.parse(fs.readFileSync("core/http/react-ui/coverage/coverage-summary.json")).total.lines.pct))' > core/http/react-ui/coverage-baseline.txt + @echo "Saved UI coverage baseline: $$(cat core/http/react-ui/coverage-baseline.txt)% lines" + +test-ui-coverage-check: test-ui-coverage + sh $(CURDIR)/scripts/ui-coverage-check.sh core/http/react-ui/coverage/coverage-summary.json core/http/react-ui/coverage-baseline.txt + test-ui-e2e-docker: docker build -t localai-ui-e2e -f tests/e2e-ui/Dockerfile . docker run --rm localai-ui-e2e diff --git a/core/http/endpoints/localai/branding_endpoints_test.go b/core/http/endpoints/localai/branding_endpoints_test.go new file mode 100644 index 000000000..ddf7ecfe8 --- /dev/null +++ b/core/http/endpoints/localai/branding_endpoints_test.go @@ -0,0 +1,233 @@ +package localai_test + +import ( + "bytes" + "encoding/json" + "fmt" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "os" + "path/filepath" + + "github.com/labstack/echo/v4" + "github.com/mudler/LocalAI/core/config" + . "github.com/mudler/LocalAI/core/http/endpoints/localai" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +// multipartFile builds a multipart/form-data body with a single "file" part, +// letting the caller control the part's filename and Content-Type so the +// upload handler's MIME allow-list and extension-fallback paths can be tested. +func multipartFile(filename, contentType string, data []byte) (*bytes.Buffer, string) { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + h := make(textproto.MIMEHeader) + h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, filename)) + if contentType != "" { + h.Set("Content-Type", contentType) + } + part, err := w.CreatePart(h) + Expect(err).ToNot(HaveOccurred()) + _, err = part.Write(data) + Expect(err).ToNot(HaveOccurred()) + Expect(w.Close()).To(Succeed()) + return body, w.FormDataContentType() +} + +var _ = Describe("Branding endpoints", func() { + var ( + dir string + e *echo.Echo + appCfg *config.ApplicationConfig + ) + + BeforeEach(func() { + var err error + dir, err = os.MkdirTemp("", "branding-endpoints-*") + Expect(err).ToNot(HaveOccurred()) + + appCfg = config.NewApplicationConfig() + appCfg.DynamicConfigsDir = dir + appCfg.Branding.InstanceName = "Acme AI" + appCfg.Branding.InstanceTagline = "do things" + + e = echo.New() + e.GET("/api/branding", GetBrandingEndpoint(appCfg)) + e.POST("/api/branding/asset/:kind", UploadBrandingAssetEndpoint(appCfg)) + e.DELETE("/api/branding/asset/:kind", DeleteBrandingAssetEndpoint(appCfg)) + e.GET("/branding/asset/:kind", ServeBrandingAssetEndpoint(appCfg)) + }) + + AfterEach(func() { + Expect(os.RemoveAll(dir)).To(Succeed()) + }) + + decode := func(rec *httptest.ResponseRecorder) BrandingResponse { + var b BrandingResponse + Expect(json.Unmarshal(rec.Body.Bytes(), &b)).To(Succeed()) + return b + } + + Describe("GET /api/branding", func() { + It("returns instance text and bundled default asset URLs when nothing is uploaded", func() { + req := httptest.NewRequest(http.MethodGet, "/api/branding", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + b := decode(rec) + Expect(b.InstanceName).To(Equal("Acme AI")) + Expect(b.InstanceTagline).To(Equal("do things")) + Expect(b.LogoURL).To(Equal("/static/logo.png")) + Expect(b.LogoHorizontalURL).To(Equal("/static/logo_horizontal.png")) + Expect(b.FaviconURL).To(Equal("/favicon.svg")) + }) + + It("points an uploaded asset at the dynamic serve route", func() { + appCfg.Branding.LogoFile = "logo.png" + req := httptest.NewRequest(http.MethodGet, "/api/branding", nil) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(decode(rec).LogoURL).To(Equal("/branding/asset/logo")) + }) + }) + + Describe("POST /api/branding/asset/:kind", func() { + It("stores an uploaded PNG and persists the setting", func() { + body, ct := multipartFile("mylogo.png", "image/png", []byte("\x89PNG\r\n\x1a\n")) + req := httptest.NewRequest(http.MethodPost, "/api/branding/asset/logo", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(decode(rec).LogoURL).To(Equal("/branding/asset/logo")) + + // File landed under /branding/logo.png ... + _, err := os.Stat(filepath.Join(dir, "branding", "logo.png")) + Expect(err).ToNot(HaveOccurred()) + // ... the in-memory config was updated ... + Expect(appCfg.Branding.LogoFile).To(Equal("logo.png")) + // ... and it was persisted to runtime_settings.json. + _, err = os.Stat(filepath.Join(dir, "runtime_settings.json")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("accepts a generic content-type when the filename extension is allowed", func() { + body, ct := multipartFile("favicon.svg", "application/octet-stream", []byte("")) + req := httptest.NewRequest(http.MethodPost, "/api/branding/asset/favicon", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + Expect(appCfg.Branding.FaviconFile).To(Equal("favicon.svg")) + }) + + It("replaces a prior asset of a different extension", func() { + brandingDir := filepath.Join(dir, "branding") + Expect(os.MkdirAll(brandingDir, 0o750)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(brandingDir, "logo.svg"), []byte(""), 0o644)).To(Succeed()) + + body, ct := multipartFile("new.png", "image/png", []byte("\x89PNG\r\n\x1a\n")) + req := httptest.NewRequest(http.MethodPost, "/api/branding/asset/logo", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(http.StatusOK)) + // Old companion removed, new one present. + _, err := os.Stat(filepath.Join(brandingDir, "logo.svg")) + Expect(os.IsNotExist(err)).To(BeTrue()) + _, err = os.Stat(filepath.Join(brandingDir, "logo.png")) + Expect(err).ToNot(HaveOccurred()) + }) + + It("rejects an unknown asset kind", func() { + body, ct := multipartFile("x.png", "image/png", []byte("x")) + req := httptest.NewRequest(http.MethodPost, "/api/branding/asset/banner", body) + req.Header.Set("Content-Type", ct) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("invalid asset kind")) + }) + + It("rejects a request with no file", func() { + body := &bytes.Buffer{} + w := multipart.NewWriter(body) + Expect(w.Close()).To(Succeed()) + req := httptest.NewRequest(http.MethodPost, "/api/branding/asset/logo", body) + req.Header.Set("Content-Type", w.FormDataContentType()) + rec := httptest.NewRecorder() + e.ServeHTTP(rec, req) + Expect(rec.Code).To(Equal(http.StatusBadRequest)) + Expect(rec.Body.String()).To(ContainSubstring("file is required")) + }) + + It("rejects an unsupported file type", func() { + body, ct := multipartFile("evil.txt", "text/html", []byte("