This page covers every test layer in PA Webinar: what each one checks, how to run it, and which CI job runs it again. It lists the guard tests that turn project rules into failing tests, explains the coverage ratchet, and names what no automated test can check.
The process around these commands, such as when to run them, CI parity and code review, is in How we develop PA Webinar.
Run every command from the repository root unless a row or block says otherwise.
| Layer | What it checks | Command | Needs | CI job in ci.yml |
|---|---|---|---|---|
| Lint and type check | ESLint rules and TypeScript strict for the app workspace |
npm run lint --workspace=app and npx tsc --noEmit --project app/tsconfig.json |
Nothing | Lint & Typecheck |
| App unit and guard tests | The Vitest suite under app/src/ |
npm run test --workspace=app (also test:watch, test:coverage) |
Nothing: no database, Redis or Jitsi | Unit Tests, with the coverage thresholds |
| App database tests | Route handlers against real PostgreSQL: constraints, cascades, transactions, races between requests, column types | npm run test:db --workspace=app |
A disposable PostgreSQL in TEST_DATABASE_URL, with a superuser and a database name ending in _test |
Unit Tests |
| Recorder bot, recorder controller and captions gateway | Pure logic of infra/recorder and infra/recorder-controller; the captions gateway, end to end over WebSockets with a fake bridge and a fake engine |
In each folder: npm ci, then npx tsc --noEmit -p tsconfig.json && npm test |
Each folder's own dependencies | Unit Tests (recorder, controller) |
| Lobby | Type check of the Phaser square | npm run lobby:typecheck |
Nothing | Typecheck (lobby) |
| Repository tooling | The rules of the Git hooks (commit message, secret patterns, choice of checks) and the license policy (SPDX expressions, lockfile reading) | npm run test:scripts (Node's built-in test runner) |
Nothing | Lint & Typecheck |
| AI worker | Pure logic of the post-production worker | In infra/ai/worker/: pip install -r requirements-test.txt, then python -m pytest -q |
Python. No GPU, no models | Unit Tests (worker AI) |
| Helm chart | The chart renders on every profile and meets the installation invariants | ./scripts/validate-chart.sh |
Helm, python3 with PyYAML, openssl, the subcharts |
Helm Chart, plus a server-side dry run on kind |
| Migration integrity | Migrations apply to an empty database and match schema.prisma |
prisma migrate deploy, then prisma migrate diff --exit-code, against a throwaway database |
A disposable PostgreSQL | Migration Integrity |
| End-to-end smoke | The browser path from registration to the waiting room, and the CSS color repair in the production build | In app/: npx playwright test --project=chromium |
The running Docker Compose stack | E2E Smoke Tests: chromium only, non-blocking |
Patched jitsi/web bundle |
Each bundle patch matches exactly one place in the upstream bundle, and the CSS rule's target selector still exists | docker build infra/jitsi-web-patched |
Docker | Not in ci.yml: jitsi-web.yml |
| Installation check | A running installation, from outside and from its cluster; with --call, a real call between two headless participants |
scripts/verify-install.sh (--help lists the options) |
A running installation and kubectl access to it (--no-cluster for the outside checks only); Node.js and Playwright for --call |
None |
| Load tests | Bridge and portal capacity | See Load testing | A test installation | None |
Each layer in the diagram runs twice: once on your machine as a local gate, and again as a CI job. The dashed layer is the non-blocking one. The patched bundle, the installation check and the load tests are outside ci.yml and are left out.
flowchart LR
subgraph LOCAL["Local gates: your machine"]
direction TB
commit["Before every commit"]:::portal
area["Before a commit that<br/>touches the component"]:::portal
push["Before every push"]:::portal
all["All layers,<br/>as ci.yml runs them"]:::portal
end
subgraph LAYERS["Test layers"]
direction TB
static["Lint and type check<br/>ESLint, tsc strict"]:::job
unit["App unit and guard tests<br/>Vitest"]:::job
infra["Recorder and controller<br/>tsc + Vitest"]:::job
lobby["Lobby<br/>type check only"]:::job
worker["AI worker<br/>pytest, pure logic"]:::job
chart["Helm chart<br/>validate-chart.sh"]:::job
mig["Migrations<br/>prisma migrate diff"]:::job
e2e["End-to-end smoke<br/>Playwright"]:::job
end
subgraph CI["ci.yml jobs: push or pull request to main"]
direction TB
jLint["Lint & Typecheck"]:::data
jUnit["Unit Tests<br/>with coverage thresholds"]:::data
jInfra["Unit Tests<br/>(recorder, controller)"]:::data
jLobby["Typecheck (lobby)"]:::data
jWorker["Unit Tests (worker AI)"]:::data
jChart["Helm Chart<br/>plus dry-run on kind"]:::data
jMig["Migration Integrity"]:::data
jE2E["E2E Smoke Tests<br/>chromium, non-blocking"]:::data
end
commit --> static
commit --> unit
area --> infra
area --> lobby
area --> worker
area --> chart
push ==> all
all -.-> mig
all -.->|"when live-room<br/>flows changed"| e2e
static --> jLint
unit --> jUnit
infra --> jInfra
lobby --> jLobby
worker --> jWorker
chart --> jChart
mig --> jMig
e2e -.-> jE2E
class jE2E,e2e optional
classDef job fill:#FFF3E0,stroke:#CC7A00,stroke-width:2px,color:#17324D
classDef portal fill:#E6F0FA,stroke:#0066CC,stroke-width:2px,color:#17324D
classDef data fill:#E3F2EC,stroke:#008055,stroke-width:2px,color:#17324D
classDef optional stroke-dasharray:5 4
style LOCAL fill:#F7F9FB,stroke:#0066CC,color:#17324D
style LAYERS fill:#F7F9FB,stroke:#CC7A00,color:#17324D
style CI fill:#F7F9FB,stroke:#008055,color:#17324D
ci.ymlruns automatically only on pushes and pull requests tomain. It can also be dispatched by hand on any branch,devincluded. Ondev, apart from the patched-bundle checks injitsi-web.yml, the local gates are the only tests that run. The gates are in Local gates and the parity table in CI parity.dev.ymlbuilds the:devimages and runs no test.release.ymlbuilds and publishes on anyv*tag push. It does not wait forci.ymland runs no test. A tag is safe only when the tagged commit onmainhas already passedci.yml; see the release procedure in CI, images and releases.- Security Scan, License Compliance and Docker Build & Scan are CI gates but not tests. CodeQL and OpenSSF Scorecard are non-blocking analyses. See SECURITY.md and CI, images and releases.
Test files sit next to the code they test, as <module>.test.ts or <component>.test.tsx under app/src/. app/vitest.config.ts sets up the suite:
- it collects
src/**/*.{test,spec}.{ts,tsx}; - it runs them in jsdom with Vitest globals;
- it loads
app/src/test/setup.ts, which adds the jest-dom matchers; - it resolves the
@/alias toapp/src/.
npm run test --workspace=app # the whole suite, once
npm run test:watch --workspace=app # re-run on every change
npm run test:coverage --workspace=app # with coverage and its thresholds
npm run test --workspace=app -- src/lib/events/lifecycle.test.ts # one file, path relative to app/
npm run test --workspace=app -- -t "<test name pattern>"At the repository root, npm test runs the same suite: the root script only delegates to the app workspace. It does not reach the recorder, the controller, the lobby or the worker.
The suite needs no running service. Tests that touch data mock the Prisma client with vi.mock('@/lib/db', ...), declaring only the delegates the code under test calls. Storage, email and other I/O modules are mocked the same way. The suite passes with an unreachable DATABASE_URL. The CI job still starts a PostgreSQL service, which the tests do not use.
Most tests cover app/src/lib/ and route handlers. A route test sits next to its route.ts, imports the exported handler (GET, POST, and so on) and calls it directly. React components have almost no unit tests. The end-to-end smoke tests and manual checks exercise them.
A mocked client cannot say whether a unique index fires, a cascade removes the right rows, a transaction rolls back or a malformed id reaches a uuid column. The tests in app/src/test/db/ call the same route handlers against a real PostgreSQL to answer those questions. Only the session cookies are faked, plus file storage where a route deletes a blob.
docker run -d --rm --name pa-webinar-test-db \
-e POSTGRES_DB=pa_webinar_test -e POSTGRES_USER=eventi -e POSTGRES_PASSWORD=test \
-p 127.0.0.1:55433:5432 postgres:16-alpine
TEST_DATABASE_URL=postgresql://eventi:test@127.0.0.1:55433/pa_webinar_test \
npm run test:db --workspace=appapp/vitest.db.config.ts sets up the run:
- it applies the migrations with
prisma migrate deploy, the command production uses; - it refuses a database whose name does not end in
_test, because every test empties every table; - it uses
TEST_DATABASE_URLand ignoresDATABASE_URL, so a development connection cannot be picked up by accident; - it runs one file at a time, since all files share the database.
The user must be a superuser: the cleanup between tests turns off foreign key checks for its transaction. The user created by the official PostgreSQL image is one.
Add a test here when the behavior depends on the database itself. Logic that depends only on the input belongs in the unit suite.
The specs are in app/e2e/ and the configuration in app/playwright.config.ts. There are two projects: chromium (desktop Chrome) and mobile-chrome (a Pixel 5 profile). npm run test:e2e --workspace=app runs both, npm run test:e2e:ui --workspace=app opens the interactive runner, and CI runs chromium only.
What the specs cover:
live-flow.spec.ts, first group. The spec signs in as administrator through the API, creates an event, publishes it, registers a participant and sets the eventLIVE. It then checks that:- the moderator link and the registrant's link both reach the waiting room with Enter now;
- the waiting room shows the event title;
- a guest without a token on a
LIVEevent gets the guest name field.
live-flow.spec.ts, second group. The event starts within the waiting-room lead time (waitingRoomLeadMinutesinSiteSetting). The spec registers through the registration form and checks that:- the registration redirects into the waiting room;
- a duplicate registration shows the already-registered message with its resend button;
- returning to the live URL without the token still works, through the signed per-event cookie.
design-system.spec.ts. The institutional header strip must have a solid background in the served stylesheet. This proves that the CSS color repair (app/src/lib/css/repair-percent-colors.ts) is still wired into the production build. The spec skips itself when the stylesheet is not minified, as in a development build.participation.spec.ts. What a participant does in a live event, through the routes the room uses: a registrant asks a question, another one upvotes it, the moderator answers and the asker reads the answer; the moderator opens a poll, a registrant and a guest vote, a second vote is refused and the counts add up. It also downloads the event's.icsfile, which must name an organizing body and not the moderator, and switches the event page from Italian to English through the language selector, checking the localized address and the page language.gdpr-erasure.spec.ts. Erasure on request from start to finish: a registrant asks for erasure on the My data page, the email arrives in Mailpit with the confirmation link, the link is opened and confirmed, and the same address can then register again. The spec drains the email outbox by calling/api/cron/email-outboxinstead of waiting for thecronservice.keyboard.spec.ts. Keyboard use on the public pages, on the desktop project: the first Tab reaches the skip link and Enter moves focus to the main content; the language menu, when the site has enough languages to show one, opens with Enter and closes with Esc, returning focus to its button; a visitor registers for an event without the mouse; the focused field and button show a visible focus indicator.accessibility.spec.ts. An automatic accessibility check with axe-core (@axe-core/playwright), limited to the WCAG 2.1 level A and AA rules, the level EN 301 549 requires. It covers the public pages, the waiting room, the main administration pages and the live room: the moderator and a participant enter, and every tab of the side column (the bottom bar on a phone) is checked. Any violation fails the spec, and the message lists the rule and the elements. The Jitsi conference is excluded, as a third-party frame. An automatic check covers what a tool can see (contrast, accessible names, structure, ARIA attributes), not keyboard use, reading order or the sense of the texts, which stay manual.
Each group deletes its event when it finishes. Apart from the accessibility spec, which enters the live room to check the side column, no spec joins a Jitsi conference: the suite stops at the waiting room.
The browser runs with the it-IT locale. The specs open Italian URLs (/it/eventi/…) and match Italian labels, such as "Entra ora" for Enter now. If you change one of those strings in app/src/i18n/messages/it.json or one of those paths in app/src/i18n/routing.ts, update the spec in the same change.
To run the suite against the local stack:
docker compose up --build -d --wait # migrations run before the app
docker compose --profile setup run --rm db-seed # demo data
cd app
npx playwright install chromium # first run only
npx playwright test --project=chromiumE2E_BASE_URLpoints the suite at another address. The default ishttp://localhost:3000.ADMIN_API_KEYmust match the app's instance API key. The spec's default is the development key set indocker-compose.yml.E2E_MAILPIT_URLis the Mailpit web address thatgdpr-erasure.spec.tsreads messages from (defaulthttp://localhost:8025), andE2E_CRON_API_KEYthe key it uses to drain the email outbox (default: the development key indocker-compose.yml).- When the
CIvariable is not set, Playwright reuses a server already listening on the base URL, or startsnpm run devif none is. A development server serves unminified CSS, so the stylesheet spec skips. - When
CIis set, Playwright starts no server, retries a failed test twice on a single worker, and fails the run on a leftovertest.only. - The HTML report is written to
app/playwright-report/. A trace is recorded on the first retry.
In CI, the E2E Smoke Tests job runs after Lint & Typecheck and Unit Tests. It:
- starts the whole Compose stack, which builds the production image of the app;
- loads the demo data with
db-seed; - waits for
/api/health; - runs the
chromiumproject; - uploads the report as an artifact.
The job is continue-on-error, because the stack pulls several images from Docker Hub and a pull rate limit is not a regression. A red E2E run therefore does not block a merge. Read its report instead of assuming the failure is infrastructure.
infra/recorder is the headless-Chrome bot. infra/recorder-controller is the reconciling controller that starts it. They are standalone npm packages with their own lockfiles, outside the npm workspaces, so run npm ci in each folder before its first test run. The live captions gateway, infra/captions/gateway, is another such package, tested the same way; the speech engine it drives is measured, not unit-tested, by infra/captions/bench.
The tests cover pure logic:
- in the bot: claims, storage paths, manifests, uploads, retries, and the bot's login identity on the hidden domain;
- in the controller: the reconcile plan and the runner helpers.
infra/recorder/src/capture.ts needs a real conference. It has no unit tests and is excluded from the bot's coverage configuration. Neither package has a coverage floor.
npm run lobby:typecheck runs tsc --noEmit with the strict options of lobby/tsconfig.json. It checks the library source, the development harness and the Vite configuration. The lobby has no unit tests. On the app side, the presence and schedule adapters in app/src/lib/lobby/ are unit-tested with the rest of the app. The canvas itself is checked by hand; see The square.
scripts/git-hooks/rules.test.mjs and scripts/licenses/policy.test.mjs cover the logic behind the Git hooks and the license check: which commit messages pass, which added lines look like secrets or conflict markers, which checks a set of changed files calls for, how an SPDX expression is judged and which lockfile entries count as production packages. They use node:test, with no dependency to install. Test secrets are assembled from fragments, so that no scanner mistakes them for real ones.
requirements-test.txt installs pytest plus the few light libraries that the worker modules import at the top level. Heavy libraries such as torch and WhisperX are imported inside functions, so the tests need no CUDA, no GPU and no model download. They cover pure logic: aligning each per-participant track to the composite recording (including silent tracks), merging per-track transcripts onto one timeline, WebVTT output and speaker labels, archive packaging, the language-model client (summary shape, batched translation), and the speech-synthesis voice and transcription/diarization helpers.
Run the suite from infra/ai/worker/. conftest.py puts that folder on sys.path, so pytest -q, which is what CI runs, and python -m pytest -q behave the same way.
scripts/validate-chart.sh runs helm lint. It then renders the chart several times:
- with the default values;
- with the
simple,standardandfullexample profiles ininfra/helm/pa-webinar/examples/; - with the production and development values files;
- with variants that turn on the NetworkPolicy, use a Traefik ingress class, take secrets from an external store (
secrets.mode=external), and use an external Jitsi (jitsi.enabled=false).
Each rendering gets generated secrets of the right shape. The script checks every rendering for the invariants whose violation breaks an installation or leaves it half done. Among them:
- Ingress paths are absolute;
- no image reference is empty;
- in
generatemode, the app Secret carries the keys the app needs, and each datastore mounts a Secret that exists and holds its password; DATABASE_URLandREDIS_URLpoint to a Service the chart renders;- an Ingress's
ingressClassNamematches itskubernetes.io/ingress.classannotation, and an Ingress for a controller other than ingress-nginx carries no ingress-nginx annotations; - the chart's NetworkPolicy selects the app Deployment and nothing else, and admits every CronJob and Jibri on port 3000;
- the JVB scaler's
JVB_DEPLOYnames a Deployment the chart renders, andJVB_HEALTH_URLpoints to a rendered Service; - Bitnami images are pinned by digest;
- resource names fit in 63 characters.
The script itself is the authoritative list.
The script needs Helm, python3 with PyYAML, openssl and the subcharts. The subcharts are not in the repository. If they are missing, the script stops and prints the helm repo add and helm dependency build commands.
CI adds a second step, because some rules are known only to the Kubernetes API server. It renders the simple, standard and full examples with the Prometheus Operator resources turned off, and applies them to a throwaway kind cluster with kubectl apply --dry-run=server. Without a local cluster, skip that step and say so.
CI applies every migration to an empty PostgreSQL with prisma migrate deploy. It then runs prisma migrate diff --from-migrations app/prisma/migrations --to-schema-datamodel app/prisma/schema.prisma --shadow-database-url <database> --exit-code, which fails when schema.prisma holds a change that no migration expresses.
The local procedure with a throwaway database is in How we develop PA Webinar. Prisma resets the shadow database, so never point it at a database whose contents you want to keep.
infra/jitsi-web-patched/ patches the upstream bundle by shape, not by minified identifier. docker build infra/jitsi-web-patched fails unless each bundle patch matches exactly one place in the upstream bundle and the CSS rule's target selector still exists.
jitsi-web.yml repeats these checks on the exact image it pushes, on dev pushes that touch the folder and on manual dispatch; see CI, images and releases.
What each patch fixes, and how to build and bump the image, are in Patched jitsi/web image and ADR-017.
scripts/verify-install.sh checks a running Helm installation on any platform and exits non-zero when a check fails. From outside, it tests the portal (/api/health, /api/ready, component status), the conference config.js, the HTTP to HTTPS redirect, and certificate validity and days to expiry for both host names. From the cluster, it checks that the release's pods are ready, that the app and migration images match, the last successful run of each scheduled job, overdue and failed emails, and the free space on the database volume. It changes nothing, so it can also run from cron with --quiet, which prints only warnings and errors.
With --call, two headless browsers join a throwaway instant call, open their microphone and camera, which are closed on entry, and check that audio and video reach both of them; the call is deleted at the end. This needs Node.js, the root npm ci (for Playwright) and a Chromium (npx playwright install chromium, or --browser <path>).
The administrator key comes from a secrets file or standard input (--secrets-file), or from the release's Secret (--keys-from-cluster), never from the command line. Every sign-in with the key is recorded in the administrator audit log.
The script is not a feature test: feature behavior is covered by the route tests and the end-to-end suite above.
Load tests are not a gate:
- the method and the reference measurements are in Load testing;
- the toolkit is described in
scripts/load-test/README.md; - in-cluster runs with Selenium Grid are in
scripts/load-test/SELENIUM-GRID.md.
Pause the JVB scaler before a run, as described in Pausing for maintenance or load tests.
Some tests do not exercise a function. They read the repository, including source files, the Prisma schema and the message catalogs, and fail when a project rule is broken. Most follow one pattern: they enumerate every item from the source of truth and compare it with a list declared in the repository, where each entry carries its reason.
flowchart LR
truth["Source of truth<br/>Prisma schema, page files,<br/>route files, schemas"]:::data
found["Every item found<br/>by reading the repository"]:::job
declared["Declared list<br/>in the repository,<br/>one reason per entry"]:::portal
compare{"Compare"}:::neutral
undeclared["Fail: an item<br/>nobody classified"]:::bad
stale["Fail: a declared item<br/>that no longer exists"]:::bad
unapplied["Fail: declared,<br/>but the code does not do it"]:::bad
pass["Pass"]:::good
truth --> found --> compare
declared --> compare
compare --> undeclared
compare --> stale
compare --> unapplied
compare --> pass
classDef data fill:#E3F2EC,stroke:#008055,stroke-width:2px,color:#17324D
classDef job fill:#FFF3E0,stroke:#CC7A00,stroke-width:2px,color:#17324D
classDef portal fill:#E6F0FA,stroke:#0066CC,stroke-width:2px,color:#17324D
classDef neutral fill:#EEF1F4,stroke:#5C6F82,stroke-width:2px,color:#17324D
classDef bad fill:#FBE9EC,stroke:#D1344C,stroke-width:2px,color:#17324D
classDef good fill:#E6F4EE,stroke:#008055,stroke-width:2px,color:#17324D
All of the tests below run in the app unit suite.
| Guard | Rule it encodes | When it fails |
|---|---|---|
app/src/i18n/locale-parity.test.ts |
Exactly 24 catalogs exist. Every key of it.json exists in every catalog, is not empty, and uses the same ICU argument names. chat-keys.test.ts next to it checks the chat keys specifically. |
Add the key to every catalog. See The parity test. |
app/src/lib/utils/localized-url.test.ts |
Every page.tsx under app/src/app/[locale]/ is declared in the pathnames map of app/src/i18n/routing.ts. Every administration page has a breadcrumb label in app/src/components/admin/admin-breadcrumb.tsx. |
Add the map entry and the label. See Every page must be declared. |
app/src/i18n/localized-paths-guard.test.ts |
No source file builds a localized address by hand with a /${locale}/… template: the English segment would reach the languages that translate it. Addresses come from the router's map, through percorso() in links and localizedPath() or localizedUrl() elsewhere. |
Replace the template with the helper. See Building links. |
app/src/lib/auth/staff-access.test.ts |
Every administration API route calls an admin guard or a staff guard, except the listed login, logout and refresh routes. The routes open to organizers equal the allowlist in the test, each with its reason. Every administration page declares its audience. | Choose the guard on purpose. To open a route to organizers, add it to the allowlist with a reason. See The guard test. |
app/src/lib/gdpr/cleanup-coverage.test.ts |
app/src/lib/gdpr/cleanup-coverage.ts classifies every Prisma model with an eventId column as purged or kept, with a reason. No model is in both lists or missing from the schema. Every purged model is referenced in the cleanup route's transaction (tx.<model>.…), and every classification carries a reason. |
Classify the model. If it is purged, delete it in the transaction in app/src/app/api/cron/cleanup/route.ts and cover it in that route's test. See Privacy and data protection. |
app/src/lib/live-state/publish.test.ts |
No envelope published on the live channel carries a per-user or role-dependent field. | Publish a reload notice instead of a snapshot. See Snapshot or reload. |
app/src/content/changelog/changelog.test.ts |
The release-note translations cover every language the site ships. Every release has a title and notes in each language, with as many notes as the Italian text. Versions are unique, listed newest first, with ISO dates. CHANGELOG.md has a heading for every release and carries the English text. |
Complete the translations, then run npm run changelog:md. See Release notes in 24 languages and the release procedure. |
app/src/lib/events/create-fields.test.ts |
Every field the event schema (eventBaseSchema) accepts is either written by the create route or declared as not persisted, with a reason. The test reads the route's prisma.event.create block. |
Write the field in app/src/app/api/events/route.ts, or classify it in app/src/lib/events/create-fields.ts. |
app/src/lib/events/duplicate-fields.test.ts and duplicate-relations.test.ts |
Event duplication classifies every scalar column and every relation of Event as copied or not copied. The relations it copies are actually loaded and rebuilt. |
Classify the column or relation in duplicate-fields.ts or duplicate-relations.ts. |
app/src/lib/settings/public-projection.test.ts |
Every SiteSetting column is classified as public or withheld, with no overlap and no stale entry. This matters because GET /api/admin/settings serves the public projection to anonymous callers with a public cache header. |
Classify the column in app/src/lib/settings/public-projection.ts. Withhold anything an anonymous visitor does not need. |
app/src/lib/jitsi/rnnoise.test.ts and whiteboard.test.ts |
No source file reads NEXT_PUBLIC_JITSI_RNNOISE_ENFORCE or NEXT_PUBLIC_WHITEBOARD_ENABLED with dotted process.env, because the build would freeze the value into the image. The live page reads each flag at runtime with getPublicEnv() and passes it through every mount. |
Read public variables with getPublicEnv() from app/src/lib/env.ts. See Configuration reference. |
app/src/app/api/assets/[...path]/route.test.ts |
The public asset route does not import the database client, so serving a logo or an Open Graph image never costs a query. | Keep database access out of that route. |
Other rules are enforced outside the unit suite:
-
Lint. On top of the Next.js presets,
app/eslint.config.mjsmakes these rules errors:- importing
Iconfrom design-react-kit (no-restricted-imports); any(@typescript-eslint/no-explicit-any);- unused variables and arguments (
@typescript-eslint/no-unused-vars; names starting with_are exempt); - type-only imports written as value imports.
no-console, the project rulelocal/no-console-with-pii(inapp/eslint-rules/) andimport/orderare warnings. Warnings do not fail the gate. - importing
-
Chart invariants.
scripts/validate-chart.sh, described in Helm chart. -
Patched bundle shape. The shape assertions of
infra/jitsi-web-patched/, described in Patched jitsi/web bundle. -
Build wiring of the CSS repair.
app/e2e/design-system.spec.ts, described in End-to-end tests.
When a guard fails, the fix is almost never to edit the guard. Add the missing declaration with its reason, or fix the code. Changing a guard's list, for example to open a route to organizers or to keep a table out of the cleanup, is a decision the reviewer must see. State it in the commit body.
A guard catches only what it encodes:
- The parity test accepts English filler copied into another catalog.
- The cleanup guard sees only models with an
eventIdcolumn. A new model that holds participants' personal data without that column must be added to the cleanup by hand. - The staff-access test looks for guard calls in the source. It does not prove that the ownership check inside a route is right.
When you add a rule that can be checked mechanically, add its guard in the same change.
npm run test:coverage --workspace=app measures coverage with the V8 provider over app/src/**/*.{ts,tsx}. It excludes app/src/test/, app/src/types/ and declaration files. It prints a text summary and writes JSON and HTML reports to app/coverage/.
The thresholds for lines, functions, branches and statements are in app/vitest.config.ts. They are a ratchet: a floor just below the measured value, raised when coverage rises and never lowered to let a change pass. The policy is in The coverage ratchet. Read the numbers from the file, and never quote them in documentation, pull requests or commit messages.
Only test:coverage enforces the thresholds, and it is what the Unit Tests job runs. npm run test runs the same suite without them, so run test:coverage before a push. The recorder, the controller and the worker have no coverage floor, and the lobby is only type-checked.
Coverage cannot tell you whether a change is safe. Line coverage of the app is low because most of the interface has no unit tests. A green run proves that the tested behavior still holds, and nothing more. What to check beyond it is in Regression discipline.
- Put the decision in a pure function, and keep I/O at the edges. For example:
infra/recorder-controller/src/reconcile.tstakes the recorders the portal wants and the Jobs that exist, and returns an idempotent plan. The Kubernetes, Docker and HTTP code around it are thin wrappers.app/src/lib/events/lifecycle.tsholds the grace and overtime decisions, taken out of the scaler route and the event update handler.app/src/lib/jvb-sizing.tsis the single source of the bridge replica formula.app/src/lib/events/panel-read-access.tsis the one read rule the live-panel routes share.
- Pass time in. The lifecycle functions receive
nowinstead of callingnew Date(). For debounces and timers, usevi.useFakeTimers(), asapp/src/lib/live-state/publish.test.tsdoes. - Mock at the module boundary. Mock
@/lib/db, storage and the email outbox withvi.mock, declaring only the delegates the code calls. In a route test, import the handler and call it. Then assert on its status, its body, and the writes or deletes it makes. - Test the contract, not the implementation. Assert what callers depend on: the status code and response shape, who may call, what is written or deleted, and what goes on a channel. Assert call order only when the order is itself the contract.
- Test the paths that already worked. When you change a shared contract, add tests for the paths that must keep working: a guest as well as a registrant, a speaker as well as a moderator, an organizer as well as an administrator. This protects the paths nobody re-checks.
- Make rules checkable. If a rule can be verified by reading the repository, write it as a guard test in the same change, following the pattern above.
- Name the failure. The test's title or a comment says what breaks for a person if the test fails. A reader can then tell a regression from an outdated expectation.
Recipes for each kind of change are in Extending PA Webinar.
Headless browsers use fake devices, and no test joins a conference. The following need a real call on real devices:
- The media path: ICE, the TURN relay, reaching the bridge, and reconnection.
- Microphones and noise suppression. The patched
jitsi/webbuild proves only that the patch applied. Whether a microphone that runs at a sample rate other than 48 kHz stays audible with noise suppression on needs such a device. - What the recorder bot captures.
infra/recorder/src/capture.tshas no unit tests. After a recording, the silence guard (app/src/lib/ai/track-silence.ts) catches the case where every track sits at the digital-silence floor, and the manifest endpoint then skips transcription. The guard catches a failed capture. It cannot prove that capture works. Check capture as in Check that the recorder works. - Composite recording with Jibri. See Check that Jibri works.
- The JVB scaler acting on a real cluster. See Validating a tick.
No automated test covers the Phaser canvas: rendering, movement, avatars, and walking in through the gate. CI only type-checks the lobby. Check a change in the development harness:
npm run lobby:dev # serves the harness on http://localhost:5180The harness uses mock presence, conference, schedule and device adapters. ?embed=1 reproduces how the square runs inside the portal: the internal controls are off, and walking to the open gate is the way in. lobby/README.md describes the other query parameters and the console helpers.
Check keyboard use and the text alternatives against the accessibility contract.
- Visual rendering with the .italia design system, and keyboard navigation. Check these in the browser; contrast is part of the automatic accessibility check above.
- The Content Security Policy. Follow the manual plan in Testing CSP changes.
- Email content and delivery. In the local stack, Mailpit captures every message. The Compose
cronservice must be running, or nothing is sent. See Local development. - Capacity. See Load testing.
When a change touches the waiting room or the live room, run the end-to-end suite first. Then check the following in the local stack. Locally, the conference uses a self-signed certificate that the browser must accept first; see Local development.
- Open the event with its moderator link in one browser, and with a registrant's link in another. A private window is enough. Both reach the waiting room, and Enter now takes each of them into the room.
- Turn on the microphone and camera on both sides, and check that each side sees and hears the other. Share a screen from the moderator side.
- From the moderator side, toggle each live feature your change touches. Check that the participant sees the change without reloading.
- As moderator, leave with Just leave and rejoin. Then choose End for everyone and check what the participant sees.
- Repeat on a phone-sized screen, or run the
mobile-chromePlaywright project. - If the change touches recording, hold a short call in which at least two people speak. Check the result as in Setting up recording.
No automated test starts a GPU node or a model server, or loads a model, and none may:
- The worker suite runs on a CPU-only runner with
requirements-test.txt. - The portal side of the pipeline is unit-tested in
app/src/lib/ai/. This covers idempotency, storage paths, schemas, the provider policy, speaker alignment, reliability scoring, silence detection and transcript formatting.
To validate a change to GPU-bound code:
- Review it.
- Move the logic you changed into a function that the pure tests can reach, and test it there.
- Exercise the parts of the pipeline that need no GPU.
- If it needs a real run, use the off-cluster tool in
infra/ai/local-out/on a workstation GPU. Read its warnings first.
Do not scale up an installation's GPU node pool or model server to test a change. They are the most expensive resources in the platform, and they are designed to stay at zero when no job needs them. The pipeline is described in AI post-production.
- How we develop PA Webinar: gates, CI parity, code review and regression discipline
- CI, images and releases: workflows, image tags and the release procedure
- Extending PA Webinar: code conventions and change recipes
- Local development: the local stack and its troubleshooting
- Load testing: method and reference measurements
- CONTRIBUTING.md: how to propose a change