Skip to content

Commit 5f3ecd4

Browse files
authored
test(integration): drive the integration specs against the live stack (#717)
1 parent 1f8d419 commit 5f3ecd4

23 files changed

Lines changed: 1872 additions & 590 deletions

CONTRIBUTING.md

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ We really appreciate and value contributions to OpenZeppelin Contracts for Compa
2626
[Running Tests](#running-tests)
2727

2828
* [Unit Tests](#unit-tests)
29+
* [Integration Tests](#integration-tests)
2930
* [Live Tests](#live-tests)
3031

3132
[Styleguides](#styleguides)
@@ -169,6 +170,14 @@ Unit tests run against an in-process mock backend (no network, ZK proving skippe
169170
yarn test
170171
```
171172

173+
### Integration Tests
174+
175+
Composed-contract specs (`contracts/test/integration/specs`): several modules assembled into one contract under `test/integration/_mocks`, then deployed and driven as a unit. Same mock backend as the unit tests.
176+
177+
```bash
178+
yarn test:integration
179+
```
180+
172181
### Live Tests
173182

174183
Live tests run against a local Midnight network (node, indexer, and proof server) defined in [`local-env.yml`](./local-env.yml). They require [Docker](https://docs.docker.com/get-docker/) and a completed `yarn install`.
@@ -179,29 +188,37 @@ One command runs everything — it compiles, resets the stack, runs a quick harn
179188
yarn test:live
180189
```
181190

182-
Currently `multisig` is the only live-ready category; the others still assume dry-only semantics and are skipped (listed in the run banner). Each category joins the run — with its own `test:live:<category>` script — as its specs are refactored for the live backend.
191+
Currently `multisig` is the only live-ready category. The others still assume dry-only semantics and are skipped, and the run banner lists them. Each joins the run as its specs are refactored for the live backend, with its own `test:live:<category>` script.
192+
193+
`integration` is a target of its own, not a category, so an unscoped run skips it. Ask for it by name. Only one live target runs per invocation, since both live projects draw wallets from the same genesis-funded pool.
194+
195+
```bash
196+
yarn test:live integration # or: yarn test:integration:live
197+
yarn test:live --list # the live targets, as the CI matrix reads them
198+
```
199+
200+
> **Note:** the `integration` live target is the harness capability plus a boundary check, not functional coverage. As of ledger v8 the composed contract does not deploy: its circuits' IR overruns the per-tx block byte budget, and the spec asserts that rejection rather than skipping. A ledger bump can move the budget, so a red spec there means the deploy now fits and the functional specs are worth porting to live.
183201
184202
If any files fail, a second round re-runs just those files on a fresh node with one worker, to separate a real failure from an environment flake:
185203

186204
* Fails round 1, passes round 2 → **FLAKY** (exit 0, reported loudly).
187205
* Fails both rounds → **REAL** (exit non-zero).
188206

189-
Scope the same mechanism to one category, or to a subset within it. The first
190-
argument names the category; any further argument is a filename substring
191-
(vitest matches it), so pass a spec name to run every file whose name matches it
192-
on the live backend — the fast loop while iterating on one feature, instead of
193-
waiting for the whole category. The match is a substring, not an exact file, so
194-
a name that prefixes others runs all of them:
207+
Scope the same mechanism to one target, or a subset within it. The first argument
208+
names the target (a category, or `integration`). Any further argument is a
209+
filename substring vitest matches, which is the fast loop while iterating on one
210+
feature. Being a substring, a name that prefixes others runs all of them:
195211

196212
```bash
197213
yarn test:live multisig # the whole category
198214
yarn test:live multisig ShieldedTreasury # any file matching "ShieldedTreasury"
215+
yarn test:live integration ConfidentialFungibleToken # one integration spec
199216
```
200217

201218
The two-round flake check still applies to a scoped run, so a green result
202219
means the same thing it does for the full suite.
203220

204-
Stop the network when done: `yarn env:down`. (No manual `env:up` is needed — the runner resets the stack itself.)
221+
The runner owns the stack: it starts it (`make env-up`, itself a reset) and stops it on every exit path, Ctrl-C included. No manual `env:up` or `env:down` needed. To inspect a run afterwards, set `MIDNIGHT_LIVE_KEEP_ENV=1` and stop it yourself. Container logs land in `logs/` either way.
205222

206223
> **Note:** The live tests all run against one shared node, so state left by an earlier run can make a later one fail. Two rules keep them reliable, both enforced by a guard that fails fast, before any wallet build:
207224
>
@@ -212,12 +229,13 @@ Environment knobs:
212229

213230
| Variable | Default | Effect |
214231
| --- | --- | --- |
215-
| `MIDNIGHT_LIVE_WORKERS` | 3 | Parallel spec files (max 3 one genesis-funded deployer each). |
232+
| `MIDNIGHT_LIVE_WORKERS` | 3 | Parallel spec files under `unit-live` (max 3, one genesis-funded deployer each). |
216233
| `MIDNIGHT_LIVE_ALLOW_DIRTY` | unset | `1` skips the freshness check (run against a dirty node). |
217234
| `MIDNIGHT_LIVE_MAX_COIN_EVENTS` | 0 | Coin events beyond genesis tolerated before "not fresh". |
218235
| `MIDNIGHT_LIVE_MAX_SCAN_BLOCKS` | 3600 | Above this indexer head, the guard asks you to `env:up` rather than scan. |
236+
| `MIDNIGHT_LIVE_KEEP_ENV` | unset | `1` leaves the stack running after the run instead of tearing it down. |
219237

220-
`unit-live` runs up to 3 workers in parallel, so their output interleaves. It is tagged per worker: a `▶ live worker N/3 ready` banner when a worker's wallets are funded, a `[wN] ❯ <file>` line as each spec file starts, and a `[wN] ✓ <test> (<ms>) [done/total]` line per test — showing the worker, the result, and overall progress through the run. Each worker also writes a detailed log to `logs/live-harness-wN.log`.
238+
`integration-live` runs one worker (only the deployer wallet is in play). `unit-live` runs up to 3 workers in parallel, so their output interleaves. It is tagged per worker: a `▶ live worker N/3 ready` banner when a worker's wallets are funded, a `[wN] ❯ <file>` line as each spec file starts, and a `[wN] ✓ <test> (<ms>) [done/total]` line per test — showing the worker, the result, and overall progress through the run. Each worker also writes a detailed log to `logs/live-harness-wN.log`.
221239

222240
> **Tip:** to save the run to a colored, readable log, force color and pipe to `tee`. Piping (stdout is no longer a TTY) makes vitest print one clean line per result instead of an animated spinner, and `FORCE_COLOR=1` keeps the color. Write it to a `.ansi` file:
223241
>

contracts/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
"compile:security": "compact-compiler --dir security",
3333
"compile:token": "compact-compiler --dir token",
3434
"compile:utils": "compact-compiler --dir utils",
35-
"compile:integration": "SKIP_ZK=true compact-compiler --src test/integration/_mocks",
35+
"compile:integration": "compact-compiler --src test/integration/_mocks",
3636
"build": "compact-builder --hierarchical --out dist --clean-dist --exclude '*/archive/*' --exclude 'Mock*' --exclude '*.mock.compact' --copy package.json --copy ../README.md && find dist -type d -empty -delete",
3737
"test": "vitest run --project unit",
3838
"test:coverage": "vitest run --project unit --coverage",

contracts/test-utils/harness/live.globalSetup.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,11 @@ import { fetchCoinEvents, indexerHead } from './ledgerEvents.js';
99
* of after a slow wallet build. Gated on `MIDNIGHT_BACKEND === 'live'` so a dry
1010
* `vitest run` that happens to glob the live tests is a no-op.
1111
*
12-
* It guards two things:
12+
* It guards three things:
13+
* - **One live project per invocation.** Every live project derives its
14+
* wallets from the same `VITEST_POOL_ID` partition, so two of them in one
15+
* vitest run would spend the same genesis deployer's coins. See
16+
* {@link assertSoleLiveProject}.
1317
* - **Freshness.** The live tests are not isolated from one another: they all
1418
* run against the same node, so shielded-coin state left by an earlier run
1519
* changes a later run's outcome (a coin re-spent against stale state is
@@ -49,6 +53,50 @@ const ENV_UP_HINT = "run 'yarn env:up' to reset the local stack";
4953
const sleep = (ms: number): Promise<void> =>
5054
new Promise((resolve) => setTimeout(resolve, ms));
5155

56+
// --- one live project per invocation ---------------------------------------
57+
58+
// Parked on `globalThis`, not in a module-level binding: each project loads this
59+
// file through its OWN module runner, so a module variable would not be shared
60+
// between two projects in the same process.
61+
const PROJECT_CLAIM = '__midnightLiveProject';
62+
63+
/**
64+
* Reject a second live project in the same vitest invocation.
65+
*
66+
* Every live project builds its wallets from `walletSeedsFor(VITEST_POOL_ID)`,
67+
* so worker 1 of `unit-live` and worker 1 of `integration-live` resolve to the
68+
* SAME genesis deployer seed. Two pools would then balance transactions against
69+
* one deployer's UTXO snapshot — the stale-UTXO race (node `Custom error: 103`)
70+
* that `WalletPool.ensureReady`'s serial build exists to prevent — and both
71+
* would write `logs/live-harness-w1.log`, interleaving two runs' diagnostics.
72+
*
73+
* The run lock below cannot catch this: it is deliberately reentrant for our own
74+
* pid, precisely so one process CAN run several live globalSetups. So the claim
75+
* is tracked separately here.
76+
*
77+
* @param project - the project whose globalSetup is running
78+
* @param claimed - the project that already claimed this process, if any
79+
*/
80+
export function assertSoleLiveProject(
81+
project: string,
82+
claimed: string | undefined,
83+
): void {
84+
if (claimed === undefined || claimed === project) return;
85+
throw new Error(
86+
`two live projects in one vitest run ('${claimed}' and '${project}'): ` +
87+
'both derive their wallets from the same VITEST_POOL_ID partition, so ' +
88+
"each project's worker 1 would spend the same genesis deployer's coins " +
89+
'(node "Custom error: 103"). Pass one --project per invocation.',
90+
);
91+
}
92+
93+
/** Claim this process for `project`, or throw if another live project holds it. */
94+
function claimLiveProject(project: string): void {
95+
const registry = globalThis as Record<string, unknown>;
96+
assertSoleLiveProject(project, registry[PROJECT_CLAIM] as string | undefined);
97+
registry[PROJECT_CLAIM] = project;
98+
}
99+
52100
// --- lock ------------------------------------------------------------------
53101

54102
export interface LockInfo {
@@ -206,8 +254,15 @@ async function assertFreshNode(): Promise<void> {
206254
}
207255
}
208256

209-
export default async function setup(): Promise<() => void> {
257+
/**
258+
* Vitest calls this once per project, in the main process, passing that project.
259+
* Typed structurally so this file keeps importing nothing but `node:` builtins.
260+
*/
261+
export default async function setup(project?: {
262+
readonly name?: string;
263+
}): Promise<() => void> {
210264
if (process.env.MIDNIGHT_BACKEND !== 'live') return () => {};
265+
claimLiveProject(project?.name ?? '(unnamed project)');
211266
const { reentrant } = acquireLock();
212267
try {
213268
if (process.env.MIDNIGHT_LIVE_ALLOW_DIRTY !== '1') await assertFreshNode();

contracts/test-utils/harness/liveProgressReporter.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import type { Reporter, TestCase, TestModule } from 'vitest/node';
1010
* worker in `live.setup` (the only place that knows its `VITEST_POOL_ID`). The
1111
* total accrues as modules are collected, so the first few lines may show a
1212
* smaller denominator until collection finishes.
13+
*
14+
* A SKIPPED test never runs `beforeEach`, so no worker stamps it. Rather than
15+
* print `[w?]`, fall back to the last worker seen for that test's module: the
16+
* module was loaded and run by that worker even where an individual test was
17+
* skipped. `?` remains only for a module where nothing ever reported a worker.
1318
*/
1419
const MARKS: Record<string, string> = {
1520
passed: '✓',
@@ -20,10 +25,13 @@ const MARKS: Record<string, string> = {
2025
export default class LiveProgressReporter implements Reporter {
2126
private total = 0;
2227
private done = 0;
28+
/** module id → the last worker that reported a test from it. */
29+
private workerByModule = new Map<string, number>();
2330

2431
onTestRunStart(): void {
2532
this.total = 0;
2633
this.done = 0;
34+
this.workerByModule.clear();
2735
}
2836

2937
onTestModuleCollected(module: TestModule): void {
@@ -34,7 +42,10 @@ export default class LiveProgressReporter implements Reporter {
3442
const { state } = testCase.result();
3543
if (state === 'pending') return; // not finished yet
3644
this.done += 1;
37-
const worker = (testCase.meta() as { workerId?: number }).workerId ?? '?';
45+
const moduleId = testCase.module.moduleId;
46+
const stamped = (testCase.meta() as { workerId?: number }).workerId;
47+
if (stamped !== undefined) this.workerByModule.set(moduleId, stamped);
48+
const worker = stamped ?? this.workerByModule.get(moduleId) ?? '?';
3849
const mark = MARKS[state] ?? '·';
3950
const ms = Math.round(testCase.diagnostic()?.duration ?? 0);
4051
console.log(

contracts/test/integration/_mocks/ConfidentialFungibleTokenPublicSupply.compact renamed to contracts/test/integration/_mocks/ComposedConfidentialFungibleTokenPublicSupply.compact

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,10 @@
1717
// "Public" supply means the total, and therefore each mint/burn delta, is
1818
// disclosed on chain. Balances stay confidential; only the aggregate is
1919
// visible.
20+
//
21+
// The `Composed` prefix is load-bearing: compactc keys each `artifacts/<name>/`
22+
// directory on the source basename, so this file must not share a basename with
23+
// the `src/token/extensions` extension it composes.
2024
pragma language_version >= 0.23.0;
2125

2226
import CompactStandardLibrary;

contracts/test/integration/fixtures/confidentialFungibleTokenPublicSupply.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
ledger,
88
Contract as MockConfidentialFungibleTokenPublicSupply,
99
type Token_EscrowEntry,
10-
} from '../../../artifacts/ConfidentialFungibleTokenPublicSupply/contract/index.js';
10+
} from '../../../artifacts/ComposedConfidentialFungibleTokenPublicSupply/contract/index.js';
1111
import {
1212
ConfidentialFungibleTokenPrivateState,
1313
ConfidentialFungibleTokenWitnesses,
@@ -16,7 +16,8 @@ import {
1616

1717
/**
1818
* Integration fixture for the assembled ConfidentialFungibleToken + PublicSupply
19-
* contract (`test/integration/_mocks/ConfidentialFungibleTokenPublicSupply`).
19+
* contract
20+
* (`test/integration/_mocks/ComposedConfidentialFungibleTokenPublicSupply`).
2021
*
2122
* It reuses the base token's confidential-token witnesses and private state (SK,
2223
* EK, plaintext cache, randomness seed) verbatim — the assembled contract's
@@ -45,7 +46,7 @@ const Base = createSimulator<
4546
contractArgs: (name, symbol, decimals) => [name, symbol, decimals],
4647
ledgerExtractor: (state) => ledger(state),
4748
witnessesFactory: () => ConfidentialFungibleTokenWitnesses(),
48-
artifactName: 'ConfidentialFungibleTokenPublicSupply',
49+
artifactName: 'ComposedConfidentialFungibleTokenPublicSupply',
4950
});
5051

5152
export class ConfidentialFungibleTokenPublicSupplySimulator extends Base {

0 commit comments

Comments
 (0)