| name | canton-test-generator |
| description | Generate TypeScript tests from Daml scripts. Use when user mentions "generate tests", "create tests for canton", or "generate canton tests". |
| allowed-tools | Bash, Read, Write, Edit, Glob, Grep |
Generate Canton TypeScript Tests
Generate TypeScript integration tests that mirror Daml test scripts, using the SDK.
Prerequisites
This skill requires a pre-generated SDK. Before running:
- Check for SDK at
<project-path>/sdk
- Verify SDK has
<project-name>-api.ts with template namespaces
- If SDK doesn't exist, tell user: "Please run the canton-sdk-generator skill first to create the SDK."
STEP 1: Discover Daml Project
Look for Daml project configuration:
- Check for
daml.yaml in workspace - extract project name
- Verify SDK exists at
<project-path>/sdk/<project-name>-api.ts
STEP 2: Check for Daml Test Scripts
Look for Daml test scripts:
ls <project-path>/daml/Scripts/tests/
If directory does NOT exist or is empty:
- Inform user: "No Daml test scripts found at
<project-path>/daml/Scripts/tests/"
- Explain: "This skill generates TypeScript tests by reading Daml test scripts. To use it, create
.daml script files that define test workflows."
- Stop - do not proceed.
STEP 3: Check for Setup.daml (Convention)
Convention: If <project-path>/daml/Scripts/Setup.daml exists, it contains ledger setup logic (party allocation, initial contracts, configuration) that should run before all tests.
Check for the setup file:
ls <project-path>/daml/Scripts/Setup.daml
If Setup.daml exists, create sdk/__tests__/testSetup.ts:
- Read
Setup.daml and understand what it sets up (factories, instruments, accounts, initial state)
- Extract actual values - note the specific IDs, amounts, and configuration values used (e.g.,
vaultId = "vault-001", not made-up values)
- Create a
testSetup.ts that CREATES the same contracts - the setup file must replicate the Setup.daml logic using the SDK, actually creating contracts on the ledger
- Export parties (from environment variables), ledger clients, and any contract IDs created
- Export configuration values as constants so tests use the same values as Setup.daml
- Use vitest's
beforeAll to run the setup once before all tests
- Test files will import what they need from this setup file
CRITICAL: The testSetup.ts must actually CREATE contracts, not just verify they exist. It replaces Setup.daml for TypeScript tests. The tests should be runnable on a fresh ledger with only the DAR deployed.
CRITICAL: Never hardcode different values than what Setup.daml uses. Read the setup script and use the EXACT same IDs, amounts, and configuration.
testSetup.ts Structure
The setup file should:
- Check if setup already ran - Query for a key contract (e.g., VaultState). If it exists, skip creation to allow re-running tests.
- Re-fund user accounts if needed - When reusing existing state, users may have consumed their holdings from previous test runs. Check and re-fund if necessary.
- Create factories - Account factory, holding factory, token factory
- Create instruments - Tokens/assets the system uses
- Create accounts - User accounts via workflow requests
- Fund accounts - Credit initial balances via workflow requests
- Create state contracts - Config, state, and other initial contracts
- Export everything tests need - Parties, clients, keys, contract IDs, constants
import { beforeAll } from 'vitest';
import { TemplateIds, Query, Account_Account_Factory, ... } from '../<project>-api';
import type { Party, ContractId, Id, Numeric } from '../core/primitives';
import type { AccountKey, InstrumentKey, HoldingFactoryKey } from '../core/interfaces';
import { createLedgerClient } from '../ledger';
export const user = process.env.USER_PARTY as Party;
export const operator = process.env.OPERATOR_PARTY as Party;
if (!user || !operator) {
throw new Error('USER_PARTY and OPERATOR_PARTY environment variables required');
}
export const userLedger = createLedgerClient(user);
export const operatorLedger = createLedgerClient(operator);
export const configId: Id = { unpack: "config-001" };
export const initialAmount: Numeric = "1000.0";
export let userAccount: AccountKey;
export let primaryInstrument: InstrumentKey;
export let mainConfigCid: ContractId<unknown>;
beforeAll(async () => {
const existingState = await operatorLedger.query(TemplateIds.YourProject_State_MainState);
if (existingState.length > 0) {
console.log('Setup already completed, reusing existing contracts');
const userHoldings = await userLedger.query(TemplateIds.Holding_TransferableFungible);
const relevantHoldings = userHoldings.filter(h =>
h.payload.instrument.id.unpack === "TOKEN-ID" && h.payload.account.owner === user
);
if (relevantHoldings.length === 0) {
console.log('Re-funding user account...');
const creditCmd = Workflow_CreditAccount_Request.create({ ... });
}
return;
}
console.log('Running ledger setup...');
const accountFactoryCmd = Account_Account_Factory.create({ provider: operator, observers: [] });
const accountFactoryCid = await operatorLedger.create(accountFactoryCmd.templateId, accountFactoryCmd.argument);
const holdingFactoryCmd = Holding_Factory.create({
provider: operator,
id: { unpack: "Holding Factory" },
observers: []
});
const holdingFactoryCid = await operatorLedger.create(holdingFactoryCmd.templateId, holdingFactoryCmd.argument);
const holdingFactoryRefCmd = Holding_Factory_Reference.create({
factoryView: { provider: operator, id: { unpack: "Holding Factory" } } as any,
cid: holdingFactoryCid as any,
observers: []
});
await operatorLedger.create(holdingFactoryRefCmd.templateId, holdingFactoryRefCmd.argument);
const holdingFactory: HoldingFactoryKey = {
provider: operator,
id: { unpack: "Holding Factory" }
};
console.log('Setup complete!');
}, 60000);
If Setup.daml does NOT exist, skip this step. Tests will handle their own setup.
STEP 4: Translate Each Daml Script to TypeScript
For each .daml file in Scripts/tests/:
Goal: Create a TypeScript test that achieves the same end result as the DAML script, using the SDK. The translation doesn't need to be 1:1 - what matters is that the test verifies the same workflow behavior.
Translation Rules
- Read the DAML script completely - understand what workflow is being tested and what the expected outcome is
- Extract values from DAML - use the same IDs, amounts, and references as the DAML script (don't invent new values)
- Write clean TypeScript - use the SDK to achieve the same result:
- Create contracts with
TemplateName.create({...}) + ledger.create()
- Exercise choices with
TemplateName.choiceName(cid, args) + ledger.exercise()
- Query contracts using the
Query helper - IMPORTANT: The Query.<templateName>() functions return a QuerySpec object with templateId and filter properties. You must pass these separately to ledger.query():
const spec = Query.yourProject_State_MainState({ operator });
const contracts = await ledger.query(spec.templateId, spec.filter);
const { templateId, filter } = Query.holding_Fungible({ account: accountKey });
const holdings = await ledger.query(templateId, filter);
- Apply DRY - extract helpers for repeated patterns, don't duplicate code
- Map parties - DAML party names become environment variables (e.g.
alice → process.env.ALICE_PARTY)
- Verify the same outcome - the test should assert that the final state matches what the DAML script expects
Fee / Net vs Gross Assertions (Common Pitfall)
If a workflow charges fees, do not assume “vault assets decrease by what the user received”.
Often:
- User payout is net (after fee)
- State like totalAssets moves by the gross amount (before fee), depending on the model
Derive expectations from the DAML logic / returned values to avoid off‑by‑fee errors.
Test Independence
Each test should be runnable independently where possible:
- If a test depends on prior state (e.g., redemption requires a prior deposit), either:
- Create the required state in the test's own
beforeAll
- OR clearly document the dependency and required test order
IMPORTANT: Test file execution order is not guaranteed. Prefer making each test self-contained.
If you must enforce order (last resort), prefix filenames with numbers (e.g., 01_, 02_, 03_).
Setup Verification
In beforeAll(), verify the ledger is properly set up before running tests:
- Query for expected contracts from Setup.daml
- Throw a clear error if setup contracts are missing (e.g., "VaultState not found. Ensure
daml start completed successfully.")
CRITICAL: Query API Pattern
The SDK's Query.<templateName>() functions return a QuerySpec object, NOT a template ID string. You MUST destructure the result before passing to ledger.query():
const spec = Query.yourProject_State_MainState({ operator });
const contracts = await ledger.query(spec.templateId, spec.filter);
const { templateId, filter } = Query.holding_Fungible({ account });
const holdings = await ledger.query(templateId, filter);
const contracts = await ledger.query(Query.yourProject_State_MainState());
This pattern applies to ALL query calls in tests. Always destructure first.
CRITICAL: Canton Type System
Canton/Daml Finance uses wrapped types for type safety. You MUST use these correctly:
1. Id Type - Wrapped string identifier
The Id type is NOT a plain string. It has an unpack property:
export interface Id {
unpack: string;
}
const configId: Id = { unpack: "config-001" };
const instrumentId: Id = { unpack: "USD" };
const accountId: Id = { unpack: "User@Operator" };
const configId = "config-001";
This applies to ALL id fields: configId, instrument.id, account.id, holdingFactory.id, etc.
2. Numeric Type - String-based decimal
The Numeric type is a string, not a number. This preserves decimal precision:
export type Numeric = string;
const amount: Numeric = "500.0";
const sharePrice: Numeric = "1.0";
const initialBalance: Numeric = "1000.0";
const amount = 500.0;
3. Arithmetic with Numeric
Since Numeric is a string, you must parse before arithmetic:
const depositAmount = "500.0";
const sharePrice = "1.0";
const expectedShares = parseFloat(depositAmount) / parseFloat(sharePrice);
const actualShares = parseFloat(vaultState.payload.totalShares);
expect(actualShares).toBeCloseTo(expectedShares, 6);
const shares = depositAmount / sharePrice;
4. InstrumentKey and AccountKey
These compound types contain Id fields:
const instrumentKey: InstrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: { unpack: "USD" },
version: "0",
holdingStandard: { tag: "TransferableFungible" }
};
const accountKey: AccountKey = {
custodian: custodianParty,
owner: aliceParty,
id: { unpack: "Alice@Vault" }
};
const instrumentKey = {
depository: custodianParty,
issuer: custodianParty,
id: "USD",
version: "0"
};
5. ContractId Type Casting
When exercising choices, you often need to cast ContractId<unknown> to the specific payload type:
const requestCid = await ledger.create(requestCmd.templateId, requestCmd.argument);
const acceptCmd = Workflow_CreateAccount_Request.accept(
requestCid as ContractId<Workflow_CreateAccount_Request.Payload>
);
await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ }
);
const acceptCmd = Workflow_CreateAccount_Request.accept(requestCid);
Extracting Choice Results: Daml choices can return values. Access them via exerciseResult:
const result = await ledger.exercise(
acceptCmd.templateId!,
requestCid,
acceptCmd.choice!,
{ label: "User@Operator", description: "User account", accountFactoryCid, holdingFactory, observers: [] }
);
const accountKey = result.exerciseResult as AccountKey;
6. Daml Enum vs Variant JSON Encoding (CRITICAL)
Daml has enums and variants and they serialize differently in the JSON API:
- Enums (no payload) serialize as strings:
- JSON:
"TransferableFungible"
- Variants (sum types) serialize as objects:
- JSON:
{ "tag": "Constructor", "value": <payload> }
- If the constructor has no payload, the JSON may be
{ "tag": "Constructor" }
Daml Finance note: HoldingStandard is commonly an enum in practice, so the JSON API expects a string.
const holdingStandard = "TransferableFungible" as any;
const someVariant = { tag: "SomeCase", value: { } } as any;
Rule of thumb:
- If the generated type looks like a string union, pass a string.
- If it looks like a union of
{ tag: ... } objects, pass the tagged object.
7. Type Imports
Import the necessary types from the SDK:
import type { Id, Numeric, Party, ContractId, DamlMap } from '../core/primitives';
import type { InstrumentKey, AccountKey, HoldingFactoryKey } from '../core/interfaces';
8. Map Types in Daml JSON API
Daml Map types are serialized as arrays of key-value pairs, NOT as objects:
const observers: DamlMap<string, Party[]> = [];
const observersWithData: DamlMap<string, Party[]> = [["label1", [party1, party2]]];
const observers = {};
This applies to all observers fields in Daml Finance templates (Account_Factory, Holding_Factory, Token_Instrument, etc.).
8.1 Query Filters: Avoid Enums/Variants When Possible
The JSON API query predicate parser can be fragile with nested enum/variant fields inside filters.
If a record field contains an enum/variant (e.g., InstrumentKey.holdingStandard), prefer query-safe filters:
- Build a filter that omits enum/variant fields (partial record matching), e.g.
Omit<InstrumentKey, 'holdingStandard'>
- Or filter by primitive sub-fields (issuer/depository/id/version) rather than the entire nested record
9. Daml Finance Holdings Behavior
When working with holdings in Daml Finance workflows:
const depositAmount = "1000.0";
STEP 5: Generate TypeScript Test Files
For each Daml script, create a corresponding test file at sdk/__tests__/<script-name>.test.ts.
Required: Prerequisites Comment
Every test file MUST start with a prerequisites comment:
Required: Descriptive Assertions
Use descriptive assertion messages that explain what's being checked:
expect(holdings.length).toBeGreaterThan(0);
expect(holdings.length, 'Alice should have USD holdings from setup script').toBeGreaterThan(0);
Test File Structure (with Setup)
If testSetup.ts was created, import from it:
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { ContractId } from '../core/primitives';
import { user, operator, userLedger, operatorLedger, configId } from './testSetup';
describe('<ScriptName> Workflow', () => {
beforeAll(async () => {
const spec = Query.expectedContract({ });
const setupContracts = await operatorLedger.query(spec.templateId, spec.filter);
if (setupContracts.length === 0) {
throw new Error('Setup contracts not found. Ensure `daml start` completed successfully.');
}
});
test('complete workflow', async () => {
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
});
});
Test File Structure (without Setup)
If no Setup.daml exists, tests are self-contained:
import { describe, test, expect, beforeAll } from 'vitest';
import { TemplateIds, Query } from '../<project-name>-api';
import type { Party, ContractId } from '../core/primitives';
import { createLedgerClient, type CantonLedgerClient } from '../ledger';
const user = process.env.USER_PARTY as Party;
const operator = process.env.OPERATOR_PARTY as Party;
if (!user) {
throw new Error('USER_PARTY environment variable required');
}
describe('<ScriptName> Workflow', () => {
let userLedger: CantonLedgerClient;
let operatorLedger: CantonLedgerClient;
beforeAll(() => {
userLedger = createLedgerClient(user);
if (operator) operatorLedger = createLedgerClient(operator);
});
test('complete workflow', async () => {
const { templateId, filter } = Query.someTemplate({ owner: user });
const contracts = await userLedger.query(templateId, filter);
});
});
STEP 6: Create Vitest Configuration
If sdk/vitest.config.ts doesn't exist, create it:
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
globals: true,
environment: 'node',
include: ['__tests__/**/*.test.ts'],
setupFiles: ['__tests__/testSetup.ts'],
testTimeout: 30000,
fileParallelism: false,
sequence: {
concurrent: false,
},
},
});
Note: Only include setupFiles if testSetup.ts was generated from Setup.daml.
STEP 7: Validate
Run TypeScript validation (do NOT run the tests):
cd <project-path>/sdk && npx tsc --noEmit
Fix any compilation errors.
OUTPUT
Report when complete:
- Project name:
<project-name>
- SDK path:
<project-path>/sdk
- Setup file:
testSetup.ts created ✅ / not needed (no Setup.daml)
- Daml scripts processed: (list each with brief description)
- Test files generated: (list each)
- TypeScript validation: ✅/❌
Then tell the user:
To run the tests:
- Start the Canton environment:
cd <project-path> && daml start
- In another terminal, run:
cd <project-path>/sdk && npx vitest run