<!-- MIT License. Copyright (c) 2026 Decentralized Science Labs LLC. -->
<!-- summary: How COLLAB is tested: 331 Foundry tests in 92 suites, the browser suites, the server kit's tests, lint and the generated-file checks. -->
# Testing

COLLAB is tested in four layers: the Foundry suite for the contracts, end-to-end suites that drive the real site in a browser against a local chain, the server kit's unit tests and sandbox install, and static checks of the web app and of every generated file. This page says what each layer covers, how to run it and how to add to it. It is for developers and reviewers; the tests are part of every release build ([Building a release](release-build.md)).

## At a glance

| Layer | Command | Size in 2.2.0 |
|---|---|---|
| Contracts | `forge test` | 331 tests in 92 suites |
| Deeper invariants | `FOUNDRY_PROFILE=ci forge test --match-path "test/invariants/*"` | 25 tests in 7 suites |
| End to end | `python3 tools/e2e/run.py [filter]` | 161 scenarios in 17 suites |
| Visual sweep | `python3 tools/e2e/sweep.py --base <web> --rpc <rpc>` | the main pages and every DAO tab, light, dark and phone |
| Server kit, unit | `python3 -m unittest discover -s server/tests` | 32 tests |
| Server kit, install | `sudo server/tests/sandbox_install.sh [--keep]` | install, re-run, upgrade, rollback |
| Web app | `python3 tools/lint_web.py` | every script and page |

For release 2.2.0 every layer passed: the Foundry suite in the release build (331 of 331), the end-to-end suites on the extracted archive, the 32 unit tests and the sandbox install. Passing tests are not an audit: COLLAB 2.2 has had AI-assisted internal reviews and no external audit.

## The Foundry suite

```bash
forge build --skip test --skip script     # the contracts, then a few test files per build (memory)
forge test                                # 331 tests in 92 suites
forge test --match-path test/Profile.t.sol -vv
FOUNDRY_PROFILE=ci forge test --match-path "test/invariants/*"
```

| Folder | Suites | Tests | What it holds |
|---|---:|---:|---|
| `test/*.t.sol` | 29 | 167 | unit and integration tests on the full stack |
| `test/invariants/` | 7 | 25 | invariant campaigns |
| `test/audit/` | 56 | 139 | the reviews' proof-of-concept tests and fuzz checks |

`test/Base.t.sol` deploys the whole protocol with the real CIPNFT (twice), GL1F and GL1F Crypto bytecode from `externals/bin/`, so every proposal type runs end to end against the contracts DAOs use on GenesisL1. The main files:

| File | Tests | Covers |
|---|---:|---|
| `Governance.t.sol` | 36 | the proposal lifecycle, deposits and penalties, quorum, veto, threshold and abstain cases, delegation, snapshots |
| `Profile.t.sol` | 30 | the 2.2 profile: PNG checks, 128 × 128, 512 KB, rewrites in place, chunk reads (fuzzed), the description's text rules and 256 KB, slugs (fuzzed against a reference), the directory, and `ProfileGasTest` |
| `Integrations.t.sol` | 22 | each DAO's integrations: lists, roles, rule precedence (fuzzed), listing within one proposal, removal, misbehaving cross-reference getters |
| `Modules.t.sol` | 16 | constitution versions, representatives and the permanent title, operator calls through module permissions, the treasury's asset kinds, ERC-1271 signatures, locked implementations, airdrops, TextStore's large blobs, the lens summaries |
| `GovernorHardening.t.sol` | 14 | the outside review's governance items (OR-2 to OR-4) and the 2.1 review's: the minimum Yes, critical rules never laxer than standard ones, freeze votes side by side |
| `Utf8.t.sol` | 12 | the text checks against a reference decoder written differently |
| `DeSciFi.t.sol`, `Launcher.t.sol`, `GL1F.t.sol`, `CipherVault.t.sol`, `CipherVaultMulti.t.sol`, `HoldingVotes.t.sol` | 34 | pools and fees, the issuance example from the specification, a DAO earning GL1F inference fees, key custody, purchase, reveal and sale on CIPNFT, one key across several CIPNFT contracts, votes with a holding period |
| `Invariants.t.sol` | 2 | two quick invariants: share units and votes conserved, pool fees always covered |
| `SizeLimits.t.sol` | 1 | every COLLAB contract fits EIP-170's 24,576 bytes (local chains run anvil with a raised limit, so this test is what catches a contract GenesisL1 would refuse) |

Fuzz tests run 512 cases each, 4,096 with `FOUNDRY_PROFILE=ci`. Invariant campaigns run 64 runs of depth 48, 128 of depth 96 under `ci`; four suites (`DistributorInvariants`, `GovernorInvariants`, `LauncherInvariants` and `TreasuryStanceInvariants`) set their own in the file (`/// forge-config:` lines). `fail_on_revert` is off: the handlers predict from the rules whether each call must succeed, so a call that should have failed counts as a violation, and so does one that should have worked.

| Invariant suite | What always holds |
|---|---|
| `ShareTokenInvariants` | voting units equal the balances of eligible accounts and add up to the total; each delegate holds exactly its delegators' units, now and at every past block; lock claims are counted exactly; only the DAO mints and burns |
| `DAOTokenInvariants` | the supply never exceeds the cap; powers given up never return; a pause blocks transfers between holders; only the owner administers |
| `LauncherInvariants` | allocations add up to the supply and reach their destinations; pools hold exactly what was asked; the launcher keeps nothing |
| `DistributorInvariants` | each claim pays exactly what the snapshot entitles, once; the contract holds what open airdrops owe |
| `DeSciFiInvariants` | swaps never lower the constant product of the effective reserves; deposits and withdrawals never dilute others; fees owed never exceed fees held; lock boxes release only to their owner after the date |
| `GovernorInvariants` | no vote weighs more than its voter held or was lent at both readings; frozen accounts never vote, propose or delegate |
| `TreasuryStanceInvariants` | in a DAO where only treasury spends are critical, no action the policy calls standard leaves the treasury with less, across two GL1F collections and two CIPNFT contracts |

### The review suites

`test/audit/` keeps the proof-of-concept tests of the internal reviews, each now asserting the fixed behaviour: `AuditPoC.t.sol` (release 1.0's review), with the reviewers' regression and fuzz checks in `Controls`, `Informational`, `LaunchPoolFuzz`, `LockClaims` and `VoteFuzz`; `TokenReviewV2.t.sol` and the three `TokenVerify*.t.sol` files (2.0's token review and its two rounds of verification); `Review21.t.sol` and `Verify21.t.sol` (2.1's review and its verification); and `Hardening22.t.sol` (H22-1 and H22-2, found while building 2.2). [Security](SECURITY.md) maps each finding to its test. The 2.2 contract review ran its proof-of-concept tests (RV22-1 to RV22-8) in the reviewer's own copy; its findings are documented in [Security](SECURITY.md) or handled in the interface, and they have no test in this suite.

## End-to-end tests

`tools/e2e/run.py` drives headless Chromium through Playwright against the real contracts on a local chain, with an injected test wallet. It starts its own sandbox from the snapshot (see [Local development](local-development.md#private-sandboxes)), resets the chain before every scenario, and stops the sandbox at the end.

```bash
python3 tools/e2e/run.py                               # every scenario
python3 tools/e2e/run.py profile                       # scenarios whose "<module>.<function>" contains "profile"
python3 tools/e2e/run.py descifi --name mine --rpc-port 8601 --web-port 8701 --keep
```

`--name`, `--rpc-port` and `--web-port` pick the sandbox (default `e2e`, 8690, 8790), and `--keep` leaves it running. Each scenario prints `PASS` or `FAIL` with its time; a failure saves a screenshot of every open browser (`build/screenshots/FAIL-<scenario>-<n>.png`) and prints the console errors, and the run exits 1. All scenarios share one Chromium process. On a machine shared with other people's runs, run browser suites one at a time, for example under `flock` with a lock file of your own.

| Suite | Scenarios | Covers |
|---|---:|---|
| `admin` | 5 | sign-in, deploy and publish, publishing a record and site settings, a host without the service, the page's checks matching the server's |
| `create` | 7 | the wizard: limits matching the contracts, minimal and full creations, validation and drafts, a narrow screen, GL1F Crypto only, a malformed title |
| `deploy` | 7 | the Deploy and Check tabs: a deployment, a reload while sending, without a site configuration, checking configurations and tampered records, the names rule, config.js byte for byte equal to the tool's |
| `descifi` | 18 | swaps (native L1, launch pools, several hops), liquidity, pools, locks, fees, wrapping, requotes, unknown and look-alike tokens, without a wallet |
| `docs` | 10 | the docs home and index, a page's parts and anchors, search, the questions page's structured data, figures, the live figures (drawings and this site's contracts), a dark phone, widths from 320 to 2560 px, the 404 page, the manifest, icons and crawler files |
| `governance_builders` | 18 | the proposal builders: sends, rules, constitution, representatives, freezes, signatures, airdrops, NFTs, policy rules, integrations, raw calls |
| `governance_core` | 13 | a raw transaction's lifecycle, deposits, delegation overrides, the workspace and proposal pages, the composer, critical escalation, the minimum Yes, freeze votes, refresh races, phones |
| `landing` | 9 | the network molecule against the chain, the largest DAOs and votes, links, the directory's URL state and empty states |
| `market` | 7 | /tokens and token pages against tokens.json, without server data, hostile names, right after a deployment, lopsided pools, vouching |
| `models` | 7 | GL1F: paid inference after a pricing vote, donations, sends and deletes, listings, registration per collection, GL1F Crypto's rules |
| `portal` | 10 | the shell, the phone drawer, search, /votes, site settings, the site's price, the undeployed mode, the cookie notice, the settings bar, capped reads |
| `profile` | 8 | slug URLs, a logo by proposal, a heavy logo and creation on a node capped at 25M, the description limit, the permanent title, hostile and copycat titles |
| `review` | 3 | spoofed token symbols, cleaned proposal titles |
| `sealed` | 4 | key epochs, purchase, reveal, mint, sale, declassification, stewards and key rotation, a second CIPNFT contract |
| `seo` | 13 | the prerender: the glyph port, the demo DAOs, robots.txt on another main host, hostile and malformed DAOs, Markdown text, the member network, server configurations and the sitemap against the pages, config.js origins, static hosts, a hidden DAO, the /pools and /locks heads, failing parts |
| `tokens` | 7 | issuance, an airdrop claim, minting, liquidity and locks, critical value actions, every builder submitting cleanly |
| `views` | 15 | treasury values and flows, sweeps, lock fees, airdrops, NFTs per integration, members, settings (rules, pending changes, the treasury stance, outside keys), representatives, constitution versions |

The `sealed` suite needs the reference CIPNFT dapp (`CIPNFT_REF`). The `admin` suite runs the real admin service behind `tools/e2e/admin_site.py`, a local HTTPS front that does what nginx does. The suites that need server data make it with the real tools (`tools/seo/tokens.py`, `tools/prerender.py`) and serve it with `tools/serve_local.py --data` or `--prerender`.

### The harness

`tools/e2e/harness.py` holds `Browser(base, rpc, account, width=1360, height=900, dark=False, cookie_notice=False, ignore_https_errors=False)`: a browser context with the test wallet for `account` (anvil's unlocked development accounts), the cookie notice marked as seen unless `cookie_notice=True`, and every console error and warning collected in `errors`. Its helpers:

| Helper | Does |
|---|---|
| `go(path, wait=1500)` | loads a page by its clean path |
| `nav(path)` | follows an in-app link without a page load, so what the tab holds in memory stays |
| `wait_text(text)`, `click(name, role="button")`, `fill(label, value)` | waits, clicks and types by visible text, role and label |
| `wait_toast(text="Confirmed")` | waits for a transaction toast |
| `assert_no_errors()` | fails on any console error or page error |
| `shot(name)` | a full-page screenshot in `build/screenshots/` |
| `page` | the Playwright page, for everything else |

`tools/e2e/sweep.py` opens the home page, `/daos`, `/create`, `/swap`, `/pools`, `/pools/add`, `/locks`, `/docs`, `/settings`, `/privacy`, `/admin` and a 404, then every tab of one DAO (`--dao`, default 1), in light, dark and a 390 px phone layout. It saves screenshots to `build/screenshots/sweep/` and exits non-zero when a page logs an error or scrolls sideways on a phone. `/votes`, `/tokens` and the pages under `/docs/` are not in its list: their own suites (`portal`, `market`, `docs`) test them.

### Writing a scenario

A suite is a module in `tools/e2e/scenarios/` that exports `SCENARIOS`, a list of functions. Each function receives an `Env` and fails by raising, with an `assert` or any exception; the runner gives it a freshly reset chain and closes its browsers afterwards.

```python
# MIT License. Copyright (c) 2026 Decentralized Science Labs LLC.
"""Example: a DAO opened by its treasury address lands on its slug URL."""


def address_url_moves_to_slug(env):
    b = env.browser(0)                         # account #0, with the test wallet
    b.go(env.dao_url(1))                       # /dao/<treasury of DAO 1>
    b.wait_text("Open Proteomics Collective")
    assert b.page.url.endswith("/dao/open-proteomics-collective"), b.page.url
    b.assert_no_errors()


SCENARIOS = [address_url_moves_to_slug]
```

| `Env` member | Gives |
|---|---|
| `browser(i, **kw)` | a `Browser` for account `i` (`None` for no wallet), with the harness's options |
| `dao(n)` | DAO `n`'s addresses: treasury, governor, shares, profile, policy, vault, creator, createdAt, createdBlock |
| `dao_url(n, *parts, preset=)` | the clean path of DAO `n` or one of its sections, with a composer preset |
| `send(i, to, sig, *args, value="0")`, `cast(*args)` | a transaction from account `i` with `cast send --unlocked`; any `cast` command against the sandbox |
| `warp(seconds)`, `mine(n)`, `rpc(method, params)` | the chain clock, blocks and raw JSON-RPC |
| `accounts`, `deploy`, `demo`, `web`, `rpc_url` | the twelve accounts, `deployments/local.json`, `deployments/demo.json`, the sandbox's web and RPC URLs |

Check what you build by looking at it: take screenshots, in light and dark and at 390 px, and read them. Keep scenarios independent of each other's state, prefer visible text and roles to CSS selectors, and wait for what the user waits for (a toast, a text) rather than for fixed times.

## Server tests

```bash
python3 -m unittest discover -s server/tests        # 32 tests, a few seconds
sudo server/tests/sandbox_install.sh                # the installer end to end, in a sandbox
```

`server/tests/test_admin.py` tests the admin service and its helpers without a server: the site settings and record checks of `tools/write_web_config.py`, config.js and its policy, keccak-256 vectors, sessions (durable sign-out, damaged session files), the sign-in limit, safe reads and writes that never follow a link, the nginx scan, the API's routes, proxy secret, cross-site checks, bodies and flow, the history's bound, and the prerender and policy jobs. The policy-step test needs root. `server/tests/adminenv.py` writes the throwaway settings these tests and `tools/e2e/admin_site.py` use.

`server/tests/sandbox_install.sh` runs as root and needs nginx, python3, openssl and curl. It builds a private nginx (its own configuration, PID file and high ports: 8088, 8443 and 8417 by default, or the next free ones, `--http-port`, `--https-port`, `--admin-port`) with another site on it, in a folder under `/var/tmp` that it removes unless `--keep`. It checks that the installer refuses while that site claims one of COLLAB's names, installs with `--prefix`, `--etc`, `--self-signed` and `--no-systemd`, runs `nginx -t`, and then runs `server/tests/sandbox_checks.py` against the site: redirects, headers, caching, the 204s, the prerender overlay's limits and planted links, config.js, the admin API (cross-site checks, schemas, publish and unpublish, the prerender job, sign-out and revocation), the policy step with analytics, `deploy/check_site.py`, a re-run that changes nothing, an upgrade to a newer release and the rollback, the rate limits, the Let's Encrypt path with stubbed certbot and DNS (`stub_certbot.sh`, `stub_getent.sh`, `stub_check_certs.py`), and the refusal of a symbolic link planted in `shared/`. It stops only what it started, by process id, and leaves the system users `collab` and `collab-prerender` on the machine.

## Lint and generated files

`python3 tools/lint_web.py` checks the rules of [The web app](../web/DEVELOPING.md) that a script can check, and must report 0 errors: raw-HTML sinks and dynamic code in scripts, remote resources, hand-written page links, inline scripts, styles and handlers, a missing or differing Content-Security-Policy, relative paths, canonical links, missing imports, scripts, stylesheets and icons, the licence line of every script, page and stylesheet in `web/` and every Python file in `tools/`, stale shells, stale /pools and /locks copies, and the documentation as `tools/build_docs.py --check` reports it.

Generated files have checks of their own, each exiting 1 when its file is stale:

| Check | Verifies |
|---|---|
| `python3 tools/shell_html.py --check` | the portal shell and preload blocks of every entry |
| `python3 tools/prerender.py --static-pages --check` | `web/pools/` and `web/locks/` |
| `python3 tools/build_docs.py --check [--strict]` | the docs sources, the generated docs pages and the docs pages `web/sitemap.xml` lists; `--strict` also fails placeholder pages |
| `python3 tools/docs_figures.py --check` | every figure the docs pages show |
| `python3 tools/make_assets.py --check`, `python3 tools/make_demo_logos.py --check` | the app icons and the demo DAOs' logos |
| `python3 tools/export_deploy_artifacts.py --check` | the deploy bundle against the compiled contracts |
| `python3 tools/merge_css.py --check` | that no CSS part is still imported |
| `python3 deploy/sync_csp.py --check` | the nginx and Caddy configurations and `web/_headers` against the pages' policy |
| `python3 tools/version_assets.py web --check` | in a release tree: every asset URL carries the release's `?v=` |

The docs renderer has unit tests of its own, which the release build runs: `python3 tools/tests/test_build_docs.py` (escaping, links, headings, tables, callouts, code, lists, figures, the FAQ's structured data and the checks `--check` makes).

## Related

- [Local development](local-development.md): the chain, the sandboxes and the test wallets.
- [Building a release](release-build.md): where each check runs in the build.
- [Contracts guide](contracts.md): what the Foundry suite tests.
- [Security](SECURITY.md): the reviews behind the audit suites.
- [Installing COLLAB on a server](INSTALL.md): the installer the sandbox runs.
