Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,22 @@ npx rv <path-to-clarinet-project> <contract-name> <type>
- `--bail` – Stop after the first failure.
- `--dial` – The path to a JavaScript file containing custom pre- and
post-execution functions (dialers).
- `--snapshot` – After a failure, report the target contract's data variables
before each call of the failing run (opt-in).

---

### Contract state on failure (opt-in)

```bash
rv example counter invariant --snapshot
```

When a run fails, `--snapshot` resets the simnet session, replays the same
seed up to the first failing run, and prints the target contract's data
variables before each call of that run. Passing runs do no extra work. Maps,
constants and token balances are not included. The config file key is
`snapshot`.

---

Expand Down
2 changes: 2 additions & 0 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ export const main = async () => {
radio,
eligibleAccounts,
allAddresses,
runConfig.snapshot,
);
break;
}
Expand All @@ -160,6 +161,7 @@ export const main = async () => {
radio,
eligibleAccounts,
allAddresses,
runConfig.snapshot,
);
break;
}
Expand Down
41 changes: 41 additions & 0 deletions cli.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,4 +233,45 @@ describe("CLI parsing with parseCli", () => {
const config = parseCli(["./example", "counter", "test", "--seed=42"])!;
expect(config.warnings).toEqual([]);
});

it("keeps snapshots disabled unless --snapshot is passed", () => {
expect(parseCli(["./example", "counter", "test"])!.snapshot).toBe(false);
expect(
parseCli(["./example", "counter", "test", "--snapshot"])!.snapshot,
).toBe(true);
});

it("takes snapshot only from the config when --config is provided", () => {
// Arrange
const tempDir = join(tmpdir(), "rendezvous-test-parsecli-snapshot");
mkdirSync(tempDir, { recursive: true });
const configPath = join(tempDir, "rv.config.json");
writeFileSync(configPath, JSON.stringify({ snapshot: true }), "utf-8");

// Act
const fromConfig = parseCli([
"./example",
"counter",
"test",
`--config=${configPath}`,
])!;
writeFileSync(configPath, JSON.stringify({}), "utf-8");
const ignored = parseCli([
"./example",
"counter",
"test",
`--config=${configPath}`,
"--snapshot",
])!;

// Assert
expect(fromConfig.snapshot).toBe(true);
expect(ignored.snapshot).toBe(false);
expect(ignored.warnings).toEqual([
"Warning: --snapshot ignored when --config is used.",
]);

// Teardown
rmSync(tempDir, { recursive: true, force: true });
});
});
9 changes: 9 additions & 0 deletions cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ export const helpMessage = `
--seed=<n> Seed for replay functionality
--runs=<n> Number of test iterations [default: 100]
--dial=<f> Path to custom dialers file
--snapshot Report contract state before a failure
--regr Run regression tests only
--bail Stop on first failure
-h, --help Show this message
Expand All @@ -44,6 +45,7 @@ export interface RunConfig {
bail: boolean;
regr: boolean;
dial: string | undefined;
snapshot: boolean;
accounts: ConfigAccount[] | undefined;
accountsMode: AccountsMode;
configPath: string | undefined;
Expand All @@ -69,6 +71,7 @@ export const parseCli = (argv: string[]): RunConfig | undefined => {
seed: { type: "string" },
runs: { type: "string" },
dial: { type: "string" },
snapshot: { type: "boolean" },
bail: { type: "boolean" },
regr: { type: "boolean" },
help: { type: "boolean", short: "h" },
Expand Down Expand Up @@ -123,6 +126,7 @@ export const parseCli = (argv: string[]): RunConfig | undefined => {
options.bail && "--bail",
options.regr && "--regr",
options.dial && "--dial",
options.snapshot && "--snapshot",
].filter(Boolean) as string[];

if (ignoredFlags.length > 0) {
Expand All @@ -140,6 +144,7 @@ export const parseCli = (argv: string[]): RunConfig | undefined => {
bail: fileConfig.bail ?? false,
regr: fileConfig.regr ?? false,
dial: fileConfig.dial,
snapshot: fileConfig.snapshot ?? false,
accounts: fileConfig.accounts,
accountsMode: fileConfig.accounts_mode ?? "overwrite",
configPath: options.config,
Expand Down Expand Up @@ -168,6 +173,7 @@ export const parseCli = (argv: string[]): RunConfig | undefined => {
bail: options.bail ?? false,
regr: options.regr ?? false,
dial: options.dial,
snapshot: options.snapshot ?? false,
accounts: undefined,
accountsMode: "overwrite",
configPath: undefined,
Expand Down Expand Up @@ -202,6 +208,9 @@ export const logRunConfig = (
if (config.dial !== undefined) {
radio.emit("logMessage", `Using dial path: ${config.dial}`);
}
if (config.snapshot) {
radio.emit("logMessage", "Reporting contract state before a failure.");
}
if (config.configPath) {
radio.emit("logMessage", `Using config file: ${config.configPath}`);
}
Expand Down
19 changes: 19 additions & 0 deletions config.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,25 @@ describe("Config loading and validation", () => {
// Act & Assert
expect(() => loadConfig(filePath)).toThrow(`"bail" must be a boolean.`);
});

it("reads snapshot as a boolean", () => {
// Arrange
const valid = createTempConfigFile(
"snapshot",
JSON.stringify({ snapshot: true }),
);
const invalid = createTempConfigFile(
"snapshot-string",
JSON.stringify({ snapshot: "yes" }),
);

// Act & Assert
expect(loadConfig(valid)).toEqual({
config: { snapshot: true },
unknownKeys: [],
});
expect(() => loadConfig(invalid)).toThrow(`"snapshot" must be a boolean.`);
});
});

describe("Account resolution", () => {
Expand Down
10 changes: 10 additions & 0 deletions config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ export interface RendezvousConfig {
regr?: boolean;
/** Path to custom dialers file. */
dial?: string;
/** Report the contract state before a failure. */
snapshot?: boolean;
}

/**
Expand Down Expand Up @@ -149,6 +151,13 @@ const validateConfig = (
config.dial = obj.dial;
}

if ("snapshot" in obj) {
if (typeof obj.snapshot !== "boolean") {
throw new Error(`"snapshot" must be a boolean.`);
}
config.snapshot = obj.snapshot;
}

const knownKeys = new Set([
"accounts",
"accounts_mode",
Expand All @@ -157,6 +166,7 @@ const validateConfig = (
"bail",
"regr",
"dial",
"snapshot",
]);
const unknownKeys = Object.keys(obj).filter((k) => !knownKeys.has(k));

Expand Down
33 changes: 32 additions & 1 deletion docs/chapter_6.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This chapter explains how to use Rendezvous in different situations. By the end,
To run Rendezvous, use the following command:

```bash
rv <path-to-clarinet-project> <contract-name> <type> [--config] [--seed] [--runs] [--regr] [--bail] [--dial]
rv <path-to-clarinet-project> <contract-name> <type> [--config] [--seed] [--runs] [--regr] [--bail] [--dial] [--snapshot]
```

Let's break down each part of the command.
Expand Down Expand Up @@ -389,11 +389,41 @@ A config file is a JSON object with optional fields:
| `bail` | boolean | Stop on first failure. |
| `regr` | boolean | Run regression tests only. |
| `dial` | string | Path to custom dialers file. |
| `snapshot` | boolean | Report the contract state before a failure. |

The `accounts` field lets you define custom accounts for testing. By default (`"overwrite"` mode), these replace the Devnet.toml accounts entirely. With `"concatenate"` mode, config accounts are merged with the existing Devnet accounts — if a name appears in both, the config account's address takes precedence.

Rendezvous warns if the config file contains unrecognized keys (e.g. a typo like `"sedd"` instead of `"seed"`), and also warns if CLI flags are passed alongside `--config`.

**7. Reporting the Contract State Before a Failure**

To see the contract state that led to a failure, use `--snapshot`:

```bash
rv root contract invariant --snapshot
```

When a run fails, Rendezvous resets the simnet session and replays the same seed up to the first failing run. It then prints the target contract's data variables before each call of that run:

```
Replaying seed 1 up to run 5 to report the state before the first failure...
Simnet session reset.

Contract state before each call of run 5, the first failing run (contract data variables only):

₿ 12 Ӿ 16 before test-below-limit
counter: u4
flag: false
```

For property-based tests, the state is read right before the failing test function is called. For invariant tests, it is read before each public function call of the failing run and before the invariant check, so you can follow how the state changed.

Passing runs do no extra work; the replay only happens after a failure. Some details to keep in mind:

- Only data variables (`define-data-var`) of the target contract are shown. Maps, constants, token balances and other contracts are not.
- The state comes from the first failing run, before shrinking. With `--bail`, this is the run in the failure report. Without it, the reported counterexample may be a shrunk version of that run.
- If the replay does not fail at the same run, for example because a dialer behaves differently, Rendezvous says so and reports no state.

### Summary

| Argument/Option | Description | Example |
Expand All @@ -407,6 +437,7 @@ Rendezvous warns if the config file contains unrecognized keys (e.g. a typo like
| `--bail` | Stop after the first failure. | `rv root contract test --bail` |
| `--dial=<file>` | Loads JavaScript dialers from a file for pre/post-processing. | `rv root contract test --dial=./custom-dialer.js` |
| `--config=<file>` | Uses a JSON config file for all run options. | `rv root contract test --config=rv.config.json` |
| `--snapshot` | Reports the contract's data variables before a failure. | `rv root contract test --snapshot` |

---

Expand Down
Loading