Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for dialers in invariant testing #109

Merged
merged 19 commits into from
Feb 5, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bd1c945
Add support for `dialers` in invariant testing
BowTiedRadone Feb 4, 2025
110065b
Add SIP-010 print detection post-execution to the example folder
BowTiedRadone Feb 4, 2025
8757cd8
Add test for dialers file path logging
BowTiedRadone Feb 4, 2025
c267bec
Add tests for dialer feature
BowTiedRadone Feb 4, 2025
b998807
Remove not needed import
BowTiedRadone Feb 4, 2025
5d4cb40
Standardize the dialer type and move it to a types file
BowTiedRadone Feb 4, 2025
4160944
Refine example dialers file
BowTiedRadone Feb 4, 2025
d2c74c0
Refine naming and use the `DialerContext` type across `dialer` tests
BowTiedRadone Feb 5, 2025
a48b423
Update `DialerContext` type to fit `pre-dialers` context
BowTiedRadone Feb 5, 2025
0445b80
Refine comment in the example dialers file
BowTiedRadone Feb 5, 2025
ab670b0
Use `DialerContext` type in `DialerRegistry` methods
BowTiedRadone Feb 5, 2025
9652edd
Make `dialPath` readonly in `DialerRegistry`
BowTiedRadone Feb 5, 2025
6362813
Add user-friendly comment for `DialerRegistry`
BowTiedRadone Feb 5, 2025
c238b7b
Add the `--dial` flag to the `README`
BowTiedRadone Feb 5, 2025
5257ad8
Add the `--dial` flag to the help message
BowTiedRadone Feb 5, 2025
ac5ebf6
Add `help` flag to the help message usage line
BowTiedRadone Feb 5, 2025
e9bb990
Add custom error classes for pre and post dialers error reporting
BowTiedRadone Feb 5, 2025
16a8acc
Rename `e` to `error` in `invariant.ts`
BowTiedRadone Feb 5, 2025
d402d7f
Rename `e` to `error` in catch statements across the app
BowTiedRadone Feb 5, 2025
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ npx rv <path-to-clarinet-project> <contract-name> <type>
- `--path` – The path to use for the replay functionality.
- `--runs` – The number of test iterations to use for exercising the contracts.
(default: `100`)
- `--dial` – The path to a JavaScript file containing custom pre- and
post-execution functions (dialers).

---

Expand Down
20 changes: 19 additions & 1 deletion app.tests.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ describe("Command-line arguments handling", () => {
const helpMessage = `
rv v${version}

Usage: rv <path-to-clarinet-project> <contract-name> <type> [--seed=<seed>] [--path=<path>] [--runs=<runs>]
Usage: rv <path-to-clarinet-project> <contract-name> <type> [--seed=<seed>] [--path=<path>] [--runs=<runs>] [--dial=<path-to-dialers-file>] [--help]

Positional arguments:
path-to-clarinet-project - The path to the Clarinet project.
Expand All @@ -21,6 +21,7 @@ describe("Command-line arguments handling", () => {
--seed - The seed to use for the replay functionality.
--path - The path to use for the replay functionality.
--runs - The runs to use for iterating over the tests. Default: 100.
--dial – The path to a JavaScript file containing custom pre- and post-execution functions (dialers).
--help - Show the help message.
`;
const noManifestMessage = red(
Expand Down Expand Up @@ -196,6 +197,23 @@ describe("Command-line arguments handling", () => {
`\nStarting invariant testing type for the counter contract...\n`,
],
],
[
["manifest path", "contract name", "type=invariant", "dialers file path"],
[
"node",
"app.js",
"example",
"counter",
"invariant",
"--dial=example/sip010.js",
BowTiedRadone marked this conversation as resolved.
Show resolved Hide resolved
],
[
`Using manifest path: example/Clarinet.toml`,
`Target contract: counter`,
`Using dial path: example/sip010.js`,
`\nStarting invariant testing type for the counter contract...\n`,
],
],
[
["manifest path", "contract name", "type=test"],
["node", "app.js", "example", "counter", "test"],
Expand Down
28 changes: 26 additions & 2 deletions app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { issueFirstClassCitizenship } from "./citizen";
import { version } from "./package.json";
import { red } from "ansicolor";
import { existsSync } from "fs";
import { DialerRegistry } from "./dialer";

const logger = (log: string, logLevel: "log" | "error" | "info" = "log") => {
console[logLevel](log);
Expand Down Expand Up @@ -43,7 +44,7 @@ export const getManifestFileName = (
const helpMessage = `
rv v${version}

Usage: rv <path-to-clarinet-project> <contract-name> <type> [--seed=<seed>] [--path=<path>] [--runs=<runs>]
Usage: rv <path-to-clarinet-project> <contract-name> <type> [--seed=<seed>] [--path=<path>] [--runs=<runs>] [--dial=<path-to-dialers-file>] [--help]

Positional arguments:
path-to-clarinet-project - The path to the Clarinet project.
Expand All @@ -54,6 +55,7 @@ const helpMessage = `
--seed - The seed to use for the replay functionality.
--path - The path to use for the replay functionality.
--runs - The runs to use for iterating over the tests. Default: 100.
--dial – The path to a JavaScript file containing custom pre- and post-execution functions (dialers).
--help - Show the help message.
`;

Expand Down Expand Up @@ -140,6 +142,27 @@ export async function main() {
radio.emit("logMessage", `Using runs: ${runs}`);
}

/**
* The path to the dialer file. The dialer file allows the user to register
* custom pre and post-execution JavaScript functions to be executed before
* and after the public function calls during invariant testing.
*/
const dialPath = parseOptionalArgument("dial") || undefined;
if (dialPath !== undefined) {
radio.emit("logMessage", `Using dial path: ${dialPath}`);
}

/**
* The dialer registry, which is used to keep track of all the custom dialers
* registered by the user using the `--dial` flag.
*/
const dialerRegistry =
dialPath !== undefined ? new DialerRegistry(dialPath) : undefined;

if (dialerRegistry !== undefined) {
dialerRegistry.registerDialers();
}

const simnet = await issueFirstClassCitizenship(
manifestDir,
manifestPath,
Expand Down Expand Up @@ -168,14 +191,15 @@ export async function main() {
// If "test", call `checkProperties` for property-based testing.
switch (type) {
case "invariant": {
checkInvariants(
await checkInvariants(
moodmosaic marked this conversation as resolved.
Show resolved Hide resolved
simnet,
sutContractName,
rendezvousList,
rendezvousAllFunctions,
seed,
path,
runs,
dialerRegistry,
radio
);
break;
Expand Down
8 changes: 4 additions & 4 deletions citizen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,9 @@ export const buildRendezvousData = (
rendezvousSource: rendezvousSource,
rendezvousContractName: contractName,
};
} catch (e: any) {
} catch (error: any) {
throw new Error(
`Error processing "${contractName}" contract: ${e.message}`
`Error processing "${contractName}" contract: ${error.message}`
);
}
};
Expand Down Expand Up @@ -295,9 +295,9 @@ export const getTestContractSource = (
return readFileSync(join(manifestDir, testContractPath), {
encoding: "utf-8",
}).toString();
} catch (e: any) {
} catch (error: any) {
throw new Error(
`Error retrieving the corresponding test contract for the "${sutContractName}" contract. ${e.message}`
`Error retrieving the corresponding test contract for the "${sutContractName}" contract. ${error.message}`
);
}
};
Expand Down
88 changes: 88 additions & 0 deletions dialer.tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { join } from "path";
import { DialerContext } from "./dialer.types";
import { DialerRegistry } from "./dialer";
BowTiedRadone marked this conversation as resolved.
Show resolved Hide resolved

const dialPath = join("example", "dialer.ts");

describe("DialerRegistry interaction", () => {
it("initializes the dialer registry with the dialer file", async () => {
// Act
const actual = new DialerRegistry(dialPath);

// Assert
expect(actual).toBeInstanceOf(DialerRegistry);
});

it("correctly executes registered pre-dialer", async () => {
// Arrange
const mockPreDialer = jest.fn();
const registry = new DialerRegistry(dialPath);
registry.registerPreDialer(mockPreDialer);

// Act
await registry.executePreDialers({} as any as DialerContext);

// Assert
expect(mockPreDialer).toHaveBeenCalledTimes(1);
});

it("correctly executes registered post-dialer", async () => {
// Arrange
const mockPostDialer = jest.fn();
const registry = new DialerRegistry(dialPath);
registry.registerPostDialer(mockPostDialer);

// Act
await registry.executePostDialers({} as any as DialerContext);

// Assert
expect(mockPostDialer).toHaveBeenCalledTimes(1);
});

it("all the pre-dialers are executed", async () => {
// Arrange
const mockPreDialer1 = jest.fn();
const mockPreDialer2 = jest.fn();
const registry = new DialerRegistry(dialPath);

registry.registerPreDialer(mockPreDialer1);
registry.registerPreDialer(mockPreDialer2);

// Act
await registry.executePreDialers({} as any as DialerContext);

// Assert
expect(mockPreDialer1).toHaveBeenCalledTimes(1);
expect(mockPreDialer2).toHaveBeenCalledTimes(1);
});

it("all the post-dialers are executed", async () => {
// Arrange
const mockPostDialer1 = jest.fn();
const mockPostDialer2 = jest.fn();
const registry = new DialerRegistry(dialPath);

registry.registerPostDialer(mockPostDialer1);
registry.registerPostDialer(mockPostDialer2);

// Act
await registry.executePostDialers({} as any as DialerContext);

// Assert
expect(mockPostDialer1).toHaveBeenCalledTimes(1);
expect(mockPostDialer2).toHaveBeenCalledTimes(1);
});

it("early exits when specified dialer file is not found", async () => {
// Arrange
jest.spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit was called");
});
const registry = new DialerRegistry("non-existent.js");

// Act & Assert
expect(registry.registerDialers()).rejects.toThrow(
"process.exit was called"
);
});
});
85 changes: 85 additions & 0 deletions dialer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { existsSync } from "fs";
import { resolve } from "path";
import { Dialer, DialerContext } from "./dialer.types";

// In telephony, a registry is used for maintaining a known set of handlers,
// devices, or processes. This aligns with this class's purpose. Dialers are
// loaded/stored and run in a structured way, so "registry" fits both telephony
// and DI semantics.
export class DialerRegistry {
BowTiedRadone marked this conversation as resolved.
Show resolved Hide resolved
private readonly dialPath: string;
private preDialers: Dialer[] = [];
private postDialers: Dialer[] = [];

constructor(dialPath: string) {
this.dialPath = dialPath;
}

registerPreDialer(dialer: Dialer) {
this.preDialers.push(dialer);
}

registerPostDialer(dialer: Dialer) {
this.postDialers.push(dialer);
}

async registerDialers() {
const resolvedDialPath = resolve(this.dialPath);

if (!existsSync(resolvedDialPath)) {
console.error(`Error: Dialer file not found: ${resolvedDialPath}`);
process.exit(1);
}

try {
const userModule = await import(resolvedDialPath);

Object.entries(userModule).forEach(([key, fn]) => {
if (typeof fn === "function") {
if (key.startsWith("pre")) {
this.registerPreDialer(fn as Dialer);
} else if (key.startsWith("post")) {
this.registerPostDialer(fn as Dialer);
}
}
});
} catch (error) {
console.error(`Failed to load dialers:`, error);
process.exit(1);
}
}

async executePreDialers(context: DialerContext) {
if (this.preDialers.length === 0) {
return;
}

for (const dial of this.preDialers) {
await dial(context);
}
}

async executePostDialers(context: DialerContext) {
if (this.postDialers.length === 0) {
return;
}

for (const dial of this.postDialers) {
await dial(context);
}
}
}

export class PreDialerError extends Error {
constructor(message: string) {
super(message);
this.name = "Pre-dialer error";
}
}

export class PostDialerError extends Error {
constructor(message: string) {
super(message);
this.name = "Post-dialer error";
}
}
11 changes: 11 additions & 0 deletions dialer.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { ParsedTransactionResult } from "@hirosystems/clarinet-sdk";
import { ClarityValue } from "@stacks/transactions";
import { EnrichedContractInterfaceFunction } from "./shared.types";

export type Dialer = (context: DialerContext) => Promise<void> | void;

export type DialerContext = {
clarityValueArguments: ClarityValue[];
functionCall: ParsedTransactionResult | undefined;
selectedFunction: EnrichedContractInterfaceFunction;
};
24 changes: 21 additions & 3 deletions example/contracts/rendezvous-token.clar
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

(define-fungible-token rendezvous)

(define-constant deployer tx-sender)

(define-constant ERR_UNAUTHORIZED (err u400))

;; SIP-010 methods.

(define-read-only (get-total-supply)
Expand Down Expand Up @@ -34,8 +38,22 @@
(recipient principal)
(memo (optional (buff 34)))
)
(match (ft-transfer? rendezvous amount sender recipient)
response (ok response)
error (err error)
(begin
;; This print event is required for rendezvous to be a SIP-010 compliant
;; fungible token. Comment out this line and run the following command to
;; see the `dialers` in action:
;; ```rv example rendezvous-token invariant --dial=example/sip010.js```
(match memo to-print (print to-print) 0x)
(match (ft-transfer? rendezvous amount sender recipient)
response (ok response)
error (err error)
)
)
)

(define-public (mint (recipient principal) (amount uint))
(begin
(asserts! (is-eq contract-caller deployer) ERR_UNAUTHORIZED)
(ft-mint? rendezvous amount recipient)
)
)
7 changes: 7 additions & 0 deletions example/contracts/rendezvous-token.tests.clar
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
;; Invariants

;; This invariant returns true regardless of the state of the contract. Its
;; purpose is to allow the demonstration of the `dialers` feature.
(define-read-only (invariant-always-true)
true
)
Loading