diff --git a/bin/cli/README.md b/bin/cli/README.md index d01c8ee..fea2f7e 100644 --- a/bin/cli/README.md +++ b/bin/cli/README.md @@ -83,6 +83,7 @@ jammin build auth-service --config ./custom.build.yml - Build logs are automatically saved to a `logs/` directory in your project root. - The command uses Docker to build services based on their SDK configuration. - Output `.jam` files are detected and listed after each successful build. +- Automatically generates `config/jammin.test.config.ts` with type-safe service ID mappings for testing. - If any service fails to build, the command will exit with an error code. ### `test` diff --git a/bin/cli/src/commands/build-command.ts b/bin/cli/src/commands/build-command.ts index e2fee22..ae4342b 100644 --- a/bin/cli/src/commands/build-command.ts +++ b/bin/cli/src/commands/build-command.ts @@ -2,7 +2,14 @@ import { mkdir } from "node:fs/promises"; import { join, relative, resolve } from "node:path"; import * as p from "@clack/prompts"; import type { ServiceConfig } from "@fluffylabs/jammin-sdk"; -import { copyJamToDist, getJamFiles, getServiceConfigs, SDK_CONFIGS } from "@fluffylabs/jammin-sdk"; +import { + copyJamToDist, + generateTestConfigInProjectDir, + getJamFiles, + getServiceConfigs, + loadServices, + SDK_CONFIGS, +} from "@fluffylabs/jammin-sdk"; import { Command } from "commander"; /** @@ -107,6 +114,17 @@ Examples: p.log.info("--------------------------------"); + try { + s.start("Generating test configuration..."); + const services = await loadServices(projectRoot); + await generateTestConfigInProjectDir(services, projectRoot); + s.stop("✅ Test configuration generated"); + p.log.message("📝 Generated: config/jammin.test.config.ts"); + } catch (_error) { + s.stop("⚠️ Could not generate test configuration"); + p.log.warn("Test configuration generation failed (this is optional)"); + } + if (buildFailed) { p.outro("❌ Build failed. See the output above and check the logs for more details."); process.exit(1); diff --git a/bun.lock b/bun.lock index 360cc24..466f497 100644 --- a/bun.lock +++ b/bun.lock @@ -38,7 +38,7 @@ "name": "@fluffylabs/jammin-sdk", "version": "0.1.0", "dependencies": { - "@typeberry/lib": "0.5.2-9afefa7", + "@typeberry/lib": "0.5.7", "yaml": "^2.8.2", "zod": "^4.3.6", }, @@ -82,11 +82,17 @@ "@noble/ed25519": ["@noble/ed25519@2.2.3", "", {}, "sha512-iHV8eI2mRcUmOx159QNrU8vTpQ/Xm70yJ2cTk3Trc86++02usfqFoNl6x0p3JN81ZDS/1gx6xiK0OwrgqCT43g=="], + "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="], + "@tsconfig/node20": ["@tsconfig/node20@20.1.8", "", {}, "sha512-Em+IdPfByIzWRRpqWL4Z7ArLHZGxmc36BxE3jCz9nBFSm+5aLaPMZyjwu4yetvyKXeogWcxik4L1jB5JTWfw7A=="], - "@typeberry/lib": ["@typeberry/lib@0.5.2-9afefa7", "", { "dependencies": { "@fluffylabs/anan-as": "^1.1.5", "@noble/ed25519": "2.2.3", "@typeberry/native": "0.0.4-4c0cd28", "hash-wasm": "4.12.0" } }, "sha512-8RgtMK29flVgYRF8FEoTuwc249NLajBuQ7/fc+24bc8gNDkJH39mJiPz5TQKPcOPNQ6MSKnayMNO6u+i125RJA=="], + "@typeberry/bandersnatch-native-darwin-arm64": ["@typeberry/bandersnatch-native-darwin-arm64@0.2.0-74dd7d7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-eRzvfdoEDeRIHhMzzCX/0rPVlrHMIH0Ja0FkA/OC3vr9XkaJXp9DW3HmOHbPZPdt3T2ySwqrv9P3NxUHIIvUIg=="], + + "@typeberry/bandersnatch-native-linux-x64-gnu": ["@typeberry/bandersnatch-native-linux-x64-gnu@0.2.0-74dd7d7", "", { "os": "linux", "cpu": "x64" }, "sha512-kPTDJ6YYPghxMubhxMsn9wBea/aH9/hIaiG66PW7skRwDM9D5wNzZrmSD7BlpSKv9ayK2hIQiSL1amCiTS9mXQ=="], + + "@typeberry/lib": ["@typeberry/lib@0.5.7", "", { "dependencies": { "@fluffylabs/anan-as": "^1.1.5", "@noble/ed25519": "2.2.3", "@opentelemetry/api": "1.9.0", "@typeberry/native": "0.2.0-74dd7d7", "eventemitter3": "^5.0.1", "hash-wasm": "4.12.0" } }, "sha512-q5g8jWeAeGBwh4Ywdjtc9R8R5xIkChD5uIpjvG8tEFIUGyN0/rf+e4wbrB71dRxJEojKK4ZPYt3gvuk9eWQI4w=="], - "@typeberry/native": ["@typeberry/native@0.0.4-4c0cd28", "", {}, "sha512-VhZiiSYex3/jDk1I8PlcwPxiM9GslryGxdG+4sbbjNvpr1JxRkH0fAdUnKzxAxGYPNz2MOfwHTmTdVcrk1a5rA=="], + "@typeberry/native": ["@typeberry/native@0.2.0-74dd7d7", "", { "optionalDependencies": { "@typeberry/bandersnatch-native-darwin-arm64": "0.2.0-74dd7d7", "@typeberry/bandersnatch-native-linux-x64-gnu": "0.2.0-74dd7d7" } }, "sha512-b7qq7cIO30KKUVe2WISd+WmipurlzVePz7zFWFkZ2g8nqvfAt8l3upANC1Yxg+LPFLQLWUbEJ7vZ0reOX68Vqg=="], "@types/blake2b": ["@types/blake2b@2.1.3", "", {}, "sha512-MFCdX0MNxFBP/xEILO5Td0kv6nI7+Q2iRWZbTL/yzH2/eDVZS5Wd1LHdsmXClvsCyzqaZfHFzZaN6BUeUCfSDA=="], @@ -113,5 +119,9 @@ "yaml": ["yaml@2.8.2", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A=="], "zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + + "@fluffylabs/jammin-sdk/@types/bun": ["@types/bun@1.3.9", "", { "dependencies": { "bun-types": "1.3.9" } }, "sha512-KQ571yULOdWJiMH+RIWIOZ7B2RXQGpL1YQrBtLIV3FqDcCu6FsbFUBwhdKUlCKUpS3PJDsHlJ1QKlpxoVR+xtw=="], + + "@fluffylabs/jammin-sdk/@types/bun/bun-types": ["bun-types@1.3.9", "", { "dependencies": { "@types/node": "*" } }, "sha512-+UBWWOakIP4Tswh0Bt0QD0alpTY8cb5hvgiYeWCMet9YukHbzuruIEeXC2D7nMJPB12kbh8C7XJykSexEqGKJg=="], } } diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index afa6ce1..5b4f827 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -4,6 +4,7 @@ - [Requirements](requirements.md) - [Getting Started](getting-started.md) - [Service SDK examples](service-examples.md) +- [Testing JAM Services](testing.md) - [Contributing](contributing.md) - [Bootstrap](bootstrap/jammin-suite.md) - [Proposed API](bootstrap/jammin-examples.md) diff --git a/docs/src/testing.md b/docs/src/testing.md new file mode 100644 index 0000000..efa6d83 --- /dev/null +++ b/docs/src/testing.md @@ -0,0 +1,600 @@ +# Testing JAM Services + +This guide covers how to write integration tests for JAM services using the jammin SDK testing utilities. + +## Overview + +The jammin SDK provides a comprehensive testing framework for simulating JAM accumulation in your test environment. The core of this framework is the `TestJam` class, which provides a fluent API for creating work reports, running accumulation, and inspecting state changes. + +## Setup + +### Prerequisites + +Before writing tests, ensure your services are built: + +```bash +jammin build +``` + +This generates service binaries and creates `config/jammin.test.config.ts` with type-safe service mappings for use in tests. + +### Installation + +The testing utilities are included in the `@fluffylabs/jammin-sdk` package: + +```bash +bun add -d @fluffylabs/jammin-sdk +``` + +### Basic Test Structure + +Here's a minimal test using Bun's built-in test runner with custom assertions: + +```typescript +import { test } from "bun:test"; +import { + CoreId, + createWorkReportAsync, + expectAccumulationSuccess, + Gas, + TestJam, +} from "@fluffylabs/jammin-sdk"; +import { SERVICES, testJam } from "../config/jammin.test.config"; + +test("should process work report", async () => { + // Create a work report + const report = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.test.id, gas: Gas(1000n) }], + }); + + // Execute accumulation + const result = await testJam.withWorkReport(report).accumulate(); + + // Use custom assertion helper + expectAccumulationSuccess(result); +}); +``` + +## TestJam Class + +The `TestJam` class is the main entry point for testing JAM services. It manages state, work reports, and accumulation execution. + +### Creating a TestJam Instance + +#### With Loaded Services + +```typescript +// Loads all services from your project's jammin.build.yml files +const jam = await TestJam.create(); +``` + +This is the recommended approach for most tests as it automatically discovers and loads your service configurations. + +#### With Empty State + +```typescript +// Creates an empty state with no services +const jam = TestJam.empty(); +``` + +Useful for testing edge cases or when you don't need any services. + +### Using Generated Service Configuration + +After building your project with `jammin build`, a type-safe configuration file is automatically generated at `config/jammin.test.config.ts`. This file provides convenient mappings and a pre-configured TestJam instance: + +#### Option 1: Use Pre-configured TestJam Instance + +The simplest approach - the generated config includes a ready-to-use TestJam instance: + +```typescript +import { testJam, SERVICES } from "./config/jammin.test.config.js"; + +test("should process work report for auth service", async () => { + // testJam is already created with all services loaded + const report = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.auth.id, gas: Gas(1000n) }], + }); + + const result = await testJam.withWorkReport(report).accumulate(); + expectAccumulationSuccess(result); +}); +``` + +#### Option 2: Create Individual TestJam Instances + +For test isolation, create a new instance in each test: + +```typescript +import { SERVICES } from "./config/jammin.test.config.js"; + +test("should process work report for auth service", async () => { + const jam = await TestJam.create(); + + // Use the type-safe service mapping + const report = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.auth.id, gas: Gas(1000n) }], + }); + + const result = await jam.withWorkReport(report).accumulate(); + expectAccumulationSuccess(result); +}); +``` + +The generated configuration includes: +- `testJam` - Pre-configured TestJam instance with all services loaded (use for simplicity) +- `SERVICES` - Type-safe mapping of service names to IDs +- `TEST_CHAIN_SPEC` - Pre-configured chain specification + +### Adding Work Reports + +Work reports can be added using the fluent `withWorkReport()` API: + +```typescript +const report = await createWorkReportAsync({ + results: [ + { serviceId: SERVICES.auth.id, gas: Gas(1000n) }, + { serviceId: SERVICES.bank.id, gas: Gas(2000n) }, + ], +}); + +const result = await jam.withWorkReport(report).accumulate(); +``` + +#### Multiple Work Reports + +Chain multiple `withWorkReport()` calls to process multiple reports in a single accumulation: + +```typescript +const report1 = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.auth.id, gas: Gas(1000n) }], +}); + +const report2 = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.bank.id, gas: Gas(2000n) }], +}); + +const result = await jam + .withWorkReport(report1) + .withWorkReport(report2) + .accumulate(); + +console.log(`Processed ${result.accumulationStatistics.size} work items`); +``` + +### Configuring Accumulation Options + +Use `withOptions()` to configure accumulation behavior: + +```typescript +import { PvmBackend } from "@fluffylabs/jammin-sdk/config"; + +const result = await jam + .withWorkReport(report) + .withOptions({ + slot: Slot(100), // Custom time slot + debug: true, // Enable debug logging + pvmBackend: PvmBackend.BuiltIn, // Use built-in PVM + sequential: true, // Sequential accumulation (default) + }) + .accumulate(); +``` + +#### Available Options + +- `slot?: TimeSlot` - Time slot for accumulation (defaults to state's current timeslot) +- `debug?: boolean | DebugLoggingOptions` - Enable debug logging (can be a boolean for all logs, or an object for fine-grained control) +- `pvmBackend?: PvmBackend` - PVM backend to use (`PvmBackend.Ananas` or `PvmBackend.BuiltIn`) +- `sequential?: boolean` - Use sequential accumulation mode (default: `true`) +- `entropy?: EntropyHash` - Entropy for randomness (defaults to zero hash for deterministic tests) +- `chainSpec?: ChainSpec` - Chain specification to use + +### Querying State + +After accumulation, you can query the service state: + +#### Get Service Info + +```typescript +const info = jam.getServiceInfo(SERVICES.auth.id); +console.log(`Balance: ${info?.balance}`); +console.log(`Code hash: ${info?.codeHash}`); +``` + +#### Get Service Storage + +```typescript +const key = TestJam.stringToBlob("testKey"); +const value = jam.getServiceStorage(SERVICES.auth.id, key); +``` + +#### Get Preimage Data + +```typescript +const preimage = jam.getServicePreimage(SERVICES.auth.id, someHash); +``` + +## Creating Work Reports + +The SDK provides flexible utilities for creating work reports with varying levels of detail. + +### Simple Work Report + +```typescript +import { createWorkReportAsync, Gas } from "@fluffylabs/jammin-sdk"; + +const report = await createWorkReportAsync({ + results: [ + { serviceId: SERVICES.auth.id, gas: Gas(1000n) }, + ], +}); +``` + +### Work Report with Multiple Work Items + +```typescript +const report = await createWorkReportAsync({ + results: [ + { + serviceId: SERVICES.auth.id, + gas: Gas(1000n), + result: { type: "ok", output: TestJam.numbersToBlob([1, 2, 3]) } + }, + { + serviceId: SERVICES.auth.id, + gas: Gas(2000n), + result: { type: "ok" } + }, + { + serviceId: SERVICES.auth.id, + gas: Gas(500n), + result: { type: "panic" } // Simulate a panic + }, + ], +}); +``` + +### Work Report with Custom Configuration + +```typescript +import { CoreId, Slot } from "@fluffylabs/jammin-sdk"; + +const report = await createWorkReportAsync({ + coreIndex: CoreId(5), + results: [ + { + serviceId: SERVICES.auth.id, + gas: Gas(1000n), + payload: TestJam.numbersToBlob([4, 5, 6]), + }, + ], + context: { + lookupAnchorSlot: Slot(42), + }, +}); +``` + +### Work Result Types + +Work results can have different status types: + +```typescript +// Successful execution +{ type: "ok", output: TestJam.hexToBlob("0xaabbccdd") } + +// Execution errors +{ type: "panic" } +{ type: "outOfGas" } +{ type: "badCode" } +{ type: "digestTooBig" } +{ type: "incorrectNumberOfExports" } +{ type: "codeOversize" } +``` + +## Testing Helpers + +The SDK provides assertion helpers to simplify test writing. + +### expectAccumulationSuccess + +Assert that accumulation completed with a valid structure: + +```typescript +import { expectAccumulationSuccess } from "@fluffylabs/jammin-sdk/testing-helpers"; + +const result = await jam.withWorkReport(report).accumulate(); +expectAccumulationSuccess(result); // Throws if result is invalid +``` + +### expectStateChange + +Assert that state changed according to a predicate: + +```typescript +import { expectStateChange } from "@fluffylabs/jammin-sdk/testing-helpers"; + +const beforeBalance = jam.getServiceInfo(SERVICES.auth.id)?.balance; + +await jam.withWorkReport(report).accumulate(); + +const afterBalance = jam.getServiceInfo(SERVICES.auth.id)?.balance; + +expectStateChange( + beforeBalance, + afterBalance, + (before, after) => after !== undefined && before !== undefined && after > before, + "Balance should increase" +); +``` + +### expectServiceInfoChange + +Specialized helper for validating service account changes: + +```typescript +import { expectServiceInfoChange } from "@fluffylabs/jammin-sdk/testing-helpers"; + +const before = jam.getServiceInfo(SERVICES.auth.id); +await jam.withWorkReport(report).accumulate(); +const after = jam.getServiceInfo(SERVICES.auth.id); + +expectServiceInfoChange( + before, + after, + (b, a) => a && b && a.balance > b.balance, + "Service should consume gas" +); +``` + +## Common Test Patterns + +### Testing Service Execution + +```typescript +import { test, expect } from "bun:test"; +import {createWorkReportAsync, Gas } from "@fluffylabs/jammin-sdk"; +import { expectAccumulationSuccess } from "@fluffylabs/jammin-sdk/testing-helpers"; +import { testJam, SERVICES } from "./config/jammin.test.config.js"; + +test("service should execute successfully", async () => { + const report = await createWorkReportAsync({ + results: [{ serviceId: SERVICES.auth.id, gas: Gas(100000n) }], + }); + + const result = await testJam.withWorkReport(report).accumulate(); + + expectAccumulationSuccess(result); +}); +``` + +### Testing State Changes + +```typescript +import { test, expect } from "bun:test"; +import { createWorkReportAsync, Gas, TestJam } from "@fluffylabs/jammin-sdk"; +import { testJam as jam, SERVICES } from "./config/jammin.test.config.js"; + +test("service storage should update", async () => { + const authId = SERVICES.auth.id; + + const storageKey = TestJam.stringToBlob("myKey"); + const beforeValue = jam.getServiceStorage(authId, storageKey); + + const report = await createWorkReportAsync({ + results: [{ serviceId: authId, gas: Gas(50000n) }], + }); + + await jam.withWorkReport(report).accumulate(); + + const afterValue = jam.getServiceStorage(authId, storageKey); + expect(afterValue).not.toEqual(beforeValue); +}); +``` + +### Testing Multiple Services + +```typescript +import { test, expect } from "bun:test"; +import { createWorkReportAsync, Gas } from "@fluffylabs/jammin-sdk"; +import { expectAccumulationSuccess } from "@fluffylabs/jammin-sdk/testing-helpers"; +import { testJam as jam, SERVICES } from "./config/jammin.test.config.js"; + +test("should process multiple services", async () => { + const report = await createWorkReportAsync({ + results: [ + { serviceId: SERVICES.auth.id, gas: Gas(10000n) }, + { serviceId: SERVICES.bank.id, gas: Gas(20000n) }, + { serviceId: SERVICES.exchange.id, gas: Gas(15000n) }, + ], + }); + + const result = await jam.withWorkReport(report).accumulate(); + + expectAccumulationSuccess(result); +}); +``` + +### Testing Error Conditions + +```typescript +import { test, expect } from "bun:test"; +import { createWorkReportAsync, Gas } from "@fluffylabs/jammin-sdk"; +import { expectAccumulationSuccess } from "@fluffylabs/jammin-sdk/testing-helpers"; +import { testJam as jam, SERVICES } from "./config/jammin.test.config.js"; + +test("should handle panic gracefully", async () => { + const report = await createWorkReportAsync({ + results: [ + { + serviceId: ServiceId(0), + gas: Gas(1000n), + result: { type: "panic" } + }, + ], + }); + + const result = await jam.withWorkReport(report).accumulate(); + + expectAccumulationSuccess(result); +}); +``` + +## Troubleshooting + +### Service Not Found + +If you see errors like "Service with id X not found", ensure that: + +1. Your `jammin.build.yml` file is properly configured +2. You've run `jammin build` command +3. You're using `SERVICES` from generated `config/jammin.test.config.js`; + +### Type Errors with Branded Types + +The SDK uses branded types for safety. Use the helper functions to create them: + +```typescript +import { ServiceId, Gas, CoreId, Slot, U32 } from "@fluffylabs/jammin-sdk"; + +// Correct +const serviceId = ServiceId(0); +const gas = Gas(1000n); +const coreId = CoreId(5); +const slot = Slot(100); +const value = U32(42); + +// Incorrect - will cause type errors +const serviceId = 0; // Type error +const gas = 1000n; // Type error +``` + +### Debug Logging + +Enable debug logging to see what's happening during accumulation: + +```typescript +const result = await jam + .withWorkReport(report) + .withOptions({ debug: true }) + .accumulate(); +``` + +This will output detailed logs including: +- Accumulation steps +- PVM host calls + +#### Fine-Grained Logging Control + +You can enable only specific log categories for more focused debugging: + +```typescript +// Enable only ecalli traces +const result = await jam + .withWorkReport(report) + .withOptions({ + debug: { + ecalliTrace: true, + }, + }) + .accumulate(); +``` + +Available debug options: +- `pvmExecution` - PVM (Polkadot Virtual Machine) execution details including instruction traces and memory access patterns +- `ecalliTrace` - Ecalli (host call) traces for service execution and debugging service interactions +- `hostCalls` - Host calls made during service execution +- `accumulate` - Accumulation process details and state transitions showing how work items are processed +- `safrole` - Safrole (randomness and validator selection) operations for consensus-related debugging +- `refine` - Refinement process for work reports including validation and processing +- `stateTransitions` - State transitions and state root changes during processing + +### State Not Persisting + +Remember that `accumulate()` automatically applies state updates. If you need to inspect state at different points: + +```typescript +// Check initial state +const initialInfo = jam.getServiceInfo(SERVICES.auth.id); + +// Run first accumulation (state is updated) +await jam.withWorkReport(report1).accumulate(); + +// Check intermediate state +const midInfo = jam.getServiceInfo(SERVICES.auth.id); + +// Run second accumulation (state is updated again) +await jam.withWorkReport(report2).accumulate(); + +// Check final state +const finalInfo = jam.getServiceInfo(SERVICES.auth.id); +``` + +## Advanced Usage + +### Custom Blake2b Hasher + +For more control over work report creation: + +```typescript +import { Blake2b } from "@fluffylabs/jammin-sdk/hash"; +import { createWorkReport } from "@fluffylabs/jammin-sdk"; + +const blake2b = await Blake2b.createHasher(); + +const report = createWorkReport(blake2b, { + results: [{ serviceId: SERVICES.auth.id, gas: Gas(1000n) }], +}); +``` + +### Manual State Management + +For advanced use cases, you can manually manage state: + +```typescript +import { generateState, loadServices } from "@fluffylabs/jammin-sdk"; + +const services = await loadServices(); +const state = generateState(services); + +// Use state directly with simulateAccumulation +import { simulateAccumulation } from "@fluffylabs/jammin-sdk"; + +const result = await simulateAccumulation(state, [report], { + debug: true, +}); +``` + +### Custom Chain Specifications + +Override the default chain spec: + +```typescript +import { tinyChainSpec } from "@fluffylabs/jammin-sdk/config"; + +const customSpec = { + ...tinyChainSpec, + // Customize as needed +}; + +const result = await jam + .withWorkReport(report) + .withOptions({ chainSpec: customSpec }) + .accumulate(); +``` + +## Best Practices + +1. **Use `testJam` by default** - It automatically loads your services +2. **Use preconfigured `SERVICES` instead of manually writing service ids** - It prevents changing tests when reassigning + service ids +3. **Chain method calls** - The fluent API makes tests more readable +4. **Use helper assertions** - They provide better error messages than raw `expect()` +5. **Test state changes explicitly** - Don't assume accumulation modified state +6. **Use branded types** - They prevent common mistakes with raw numbers +7. **Enable debug logging when troubleshooting** - It shows exactly what's happening +8. **Test both success and failure cases** - Include tests for panics and out-of-gas scenarios + +## Next Steps + +- Review the [Service SDK examples](service-examples.md) for service implementation patterns +- Explore the [jammin suite](bootstrap/jammin-suite.md) for more tools and features diff --git a/packages/jammin-sdk/index.ts b/packages/jammin-sdk/index.ts index 0057430..726f656 100644 --- a/packages/jammin-sdk/index.ts +++ b/packages/jammin-sdk/index.ts @@ -1,23 +1,11 @@ export * as block from "@typeberry/lib/block"; -export * as bytes from "@typeberry/lib/bytes"; export * as codec from "@typeberry/lib/codec"; export * as config from "@typeberry/lib/config"; export * as config_node from "@typeberry/lib/config-node"; export * as hash from "@typeberry/lib/hash"; export * as numbers from "@typeberry/lib/numbers"; -export * as state from "@typeberry/lib/state"; export * as state_merkleization from "@typeberry/lib/state-merkleization"; export * from "./config/index.js"; -export * from "./genesis-state-generator.js"; -export * from "./simulator.js"; +export * from "./testing-helpers.js"; export * from "./types.js"; export * from "./utils/index.js"; -export * from "./work-report.js"; - -export function getSDKInfo() { - return { - name: "@fluffylabs/jammin-sdk", - version: "0.0.2", - description: "JAM SDK for e2e integration tests and object encoding", - }; -} diff --git a/packages/jammin-sdk/package.json b/packages/jammin-sdk/package.json index a3bc944..f0e3c75 100644 --- a/packages/jammin-sdk/package.json +++ b/packages/jammin-sdk/package.json @@ -30,7 +30,7 @@ "type": "module", "types": "./dist/index.d.ts", "dependencies": { - "@typeberry/lib": "0.5.2-9afefa7", + "@typeberry/lib": "0.5.7", "yaml": "^2.8.2", "zod": "^4.3.6" } diff --git a/packages/jammin-sdk/simulator.test.ts b/packages/jammin-sdk/simulator.test.ts index 687a410..92af325 100644 --- a/packages/jammin-sdk/simulator.test.ts +++ b/packages/jammin-sdk/simulator.test.ts @@ -1,16 +1,14 @@ import { beforeAll, describe, expect, test } from "bun:test"; import * as config from "@typeberry/lib/config"; -import type * as state from "@typeberry/lib/state"; -import { generateState } from "./genesis-state-generator.js"; -import { generateGuarantees, simulateAccumulation } from "./simulator.js"; +import { generateGuarantees, TestJam } from "./simulator.js"; import { CoreId, Gas, ServiceId, Slot } from "./types.js"; import { createWorkReportAsync } from "./work-report.js"; describe("simulateAccumulation", () => { - let initialState: state.InMemoryState; + let jam: TestJam; - beforeAll(async () => { - initialState = generateState([]); + beforeAll(() => { + jam = TestJam.empty(); }); test("simulates accumulation with minimal configuration", async () => { @@ -18,12 +16,11 @@ describe("simulateAccumulation", () => { results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], }); - const result = await simulateAccumulation(initialState, [report]); + const result = await jam.withWorkReport(report).accumulate(); expect(result).toBeDefined(); expect(result.stateUpdate).toBeDefined(); expect(result.accumulationStatistics).toBeDefined(); - expect(result.pendingTransfers).toBeDefined(); expect(result.accumulationOutputLog).toBeDefined(); expect(result.accumulationStatistics.size).toBe(1); }); @@ -36,7 +33,7 @@ describe("simulateAccumulation", () => { results: [{ serviceId: ServiceId(1), gas: Gas(750n) }], }); - const result = await simulateAccumulation(initialState, [report1, report2]); + const result = await jam.withWorkReport(report1).withWorkReport(report2).accumulate(); expect(result.accumulationStatistics).toBeDefined(); expect(result.accumulationStatistics.size).toBe(2); @@ -51,16 +48,14 @@ describe("simulateAccumulation", () => { ], }); - const result = await simulateAccumulation(initialState, [report], { - chainSpec: config.tinyChainSpec, - }); + const result = await jam.withWorkReport(report).accumulate(); expect(result).toBeDefined(); expect(result.accumulationStatistics.size).toBe(3); }); test("handles empty reports array", async () => { - const result = await simulateAccumulation(initialState, []); + const result = await jam.accumulate(); expect(result).toBeDefined(); expect(result.stateUpdate).toBeDefined(); @@ -73,9 +68,12 @@ describe("simulateAccumulation", () => { }); const customSlot = Slot(100); - const result = await simulateAccumulation(initialState, [report], { - slot: customSlot, - }); + const result = await jam + .withWorkReport(report) + .withOptions({ + slot: customSlot, + }) + .accumulate(); expect(result.stateUpdate.timeslot).toBeDefined(); // The timeslot in the update should be >= the slot we passed @@ -87,9 +85,12 @@ describe("simulateAccumulation", () => { results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], }); - const result = await simulateAccumulation(initialState, [report], { - pvmBackend: config.PvmBackend.BuiltIn, - }); + const result = await jam + .withWorkReport(report) + .withOptions({ + pvmBackend: config.PvmBackend.BuiltIn, + }) + .accumulate(); expect(result).toBeDefined(); }); diff --git a/packages/jammin-sdk/simulator.ts b/packages/jammin-sdk/simulator.ts index 5a9d9f3..85802af 100644 --- a/packages/jammin-sdk/simulator.ts +++ b/packages/jammin-sdk/simulator.ts @@ -1,19 +1,23 @@ -import type { EntropyHash, TimeSlot } from "@typeberry/lib/block"; import * as jamBlock from "@typeberry/lib/block"; +import { Credential, type EntropyHash, ReportGuarantee, type TimeSlot } from "@typeberry/lib/block"; +import { BytesBlob } from "@typeberry/lib/bytes"; import { Encoder } from "@typeberry/lib/codec"; import { asKnownSize } from "@typeberry/lib/collections"; import { type ChainSpec, PvmBackend, tinyChainSpec } from "@typeberry/lib/config"; import { ed25519, keyDerivation } from "@typeberry/lib/crypto"; -import { Blake2b, ZERO_HASH } from "@typeberry/lib/hash"; +import { Blake2b, type OpaqueHash, ZERO_HASH } from "@typeberry/lib/hash"; import * as jamNumbers from "@typeberry/lib/numbers"; -import type { State } from "@typeberry/lib/state"; +import { InMemoryState, type LookupHistorySlots, type ServiceAccountInfo, type State } from "@typeberry/lib/state"; +import { type SerializedState, type StateEntries, serializeStateUpdate } from "@typeberry/lib/state-merkleization"; import { Accumulate, type AccumulateInput, type AccumulateResult, type AccumulateState, } from "@typeberry/lib/transition"; +import { asOpaqueType } from "@typeberry/lib/utils"; import { Slot } from "./types.js"; +import { generateState, loadServices } from "./utils/index.js"; import type { WorkReport } from "./work-report.js"; // Re-export types for convenience @@ -27,6 +31,53 @@ export interface GuaranteeOptions { slot?: TimeSlot; } +/** + * Fine-grained debug logging options + */ +export interface DebugLoggingOptions { + /** + * Log PVM (Polkadot Virtual Machine) execution details. + * Includes instruction traces, memory access patterns, etc. + */ + pvmExecution?: boolean; + + /** + * Log ecalli traces for service execution. + * Useful for debugging service interactions. + */ + ecalliTrace?: boolean; + + /** + * Log host calls made during service execution. + * Useful for debugging service interactions. + */ + hostCalls?: boolean; + + /** + * Log accumulation process details and state transitions. + * Shows how work items are processed and state is updated. + */ + accumulate?: boolean; + + /** + * Log safrole operations. + * Helps debug randomness and consensus-related issues. + */ + safrole?: boolean; + + /** + * Log refinement process for work reports. + * Useful for understanding work report validation and processing. + */ + refine?: boolean; + + /** + * Log state transitions and state root changes. + * Helps understand how state evolves during processing. + */ + stateTransitions?: boolean; +} + /** * Configuration options for the accumulation simulator. */ @@ -39,7 +90,7 @@ export interface SimulatorOptions { /** * PVM backend for executing accumulation logic. - * Defaults to PvmBackend.Ananas (Assembly script interpreter). + * Defaults to Ananas (Assembly script interpreter). */ pvmBackend?: PvmBackend; @@ -63,9 +114,20 @@ export interface SimulatorOptions { /** * Enable debug logging for accumulation and PVM host calls. - * Defaults to false. + * When true, enables logging for all available debug categories. + * Can also pass specific logging options for fine-grained control. + * Defaults to true. + * + * @example + * ```typescript + * // Enable all debug logs + * .withOptions({ debug: true }) + * + * // Enable only specific logs + * .withOptions({ debug: { hostCalls: true, stateTransitions: true } }) + * ``` */ - debug?: boolean; + debug?: boolean | DebugLoggingOptions; } /** @@ -88,7 +150,7 @@ async function signWorkReport( keyPair: Awaited, blake2b: Blake2b, ): Promise { - const reportBlob = Encoder.encodeObject(jamBlock.workReport.WorkReport.Codec, workReport); + const reportBlob = Encoder.encodeObject(jamBlock.WorkReport.Codec, workReport); const reportHash = blake2b.hashBytes(reportBlob); @@ -116,17 +178,17 @@ async function signWorkReport( export async function generateGuarantees( workReports: WorkReport[], options: GuaranteeOptions = {}, -): Promise { +): Promise { const slot = options.slot ?? Slot(0); const credentialCount = 3; const startValidatorIndex = 0; const blake2b = await Blake2b.createHasher(); - const guarantees: jamBlock.guarantees.ReportGuarantee[] = []; + const guarantees: ReportGuarantee[] = []; for (const workReport of workReports) { - const credentials: jamBlock.guarantees.Credential[] = []; + const credentials: Credential[] = []; for (let i = 0; i < credentialCount; i++) { const validatorIndexNum = startValidatorIndex + i; @@ -135,7 +197,7 @@ export async function generateGuarantees( const signature = await signWorkReport(workReport, keyPair, blake2b); credentials.push( - jamBlock.guarantees.Credential.create({ + Credential.create({ validatorIndex, signature, }), @@ -143,7 +205,7 @@ export async function generateGuarantees( } guarantees.push( - jamBlock.guarantees.ReportGuarantee.create({ + ReportGuarantee.create({ report: workReport, slot, credentials: asKnownSize(credentials), @@ -167,11 +229,11 @@ export async function simulateAccumulation( const isSequential = options.sequential ?? true; const slot = options.slot ?? state.timeslot; const entropy = options.entropy ?? ZERO_HASH.asOpaque(); + const debug = options.debug ?? true; - // Configure logging if debug is enabled - if (options.debug) { - await enableLogs(); - } + const loggingOptions: DebugLoggingOptions = + typeof debug === "boolean" ? (debug === true ? { refine: true, accumulate: true, hostCalls: true } : {}) : debug; + await enableLogs(loggingOptions); const blake2b = await Blake2b.createHasher(); @@ -195,13 +257,303 @@ export async function simulateAccumulation( return result.ok; } -async function enableLogs() { +async function enableLogs(options: DebugLoggingOptions) { try { const loggerModule = await import("@typeberry/lib/logger"); if (loggerModule.Logger && loggerModule.Level) { - loggerModule.Logger.configureAll("info,host-calls=trace,accumulate=trace", loggerModule.Level.LOG, process.cwd()); + const enabledLoggers: string[] = ["error"]; + + if (options.pvmExecution === true) { + enabledLoggers.push("pvm=trace"); + } + if (options.ecalliTrace === true) { + enabledLoggers.push("ecalli=trace"); + } + if (options.hostCalls === true) { + enabledLoggers.push("host-calls=trace"); + } + if (options.refine === true) { + enabledLoggers.push("refine=trace"); + } + if (options.accumulate === true) { + enabledLoggers.push("accumulate=trace"); + } + if (options.safrole === true) { + enabledLoggers.push("safrole=trace"); + } + if (options.stateTransitions === true) { + enabledLoggers.push("stf=trace"); + } + + const logConfig = enabledLoggers.join(","); + + loggerModule.Logger.configureAll(logConfig, loggerModule.Level.LOG, process.cwd()); } } catch { console.warn("Warning: Could not configure typeberry logger"); } } + +/** + * Test helper class for simulating JAM accumulation in Bun tests. + * Automatically generates state on construction and provides a fluent API + * for injecting work reports and running accumulation. + * + * @example + * ```typescript + * // Create a test instance with loaded services + * const jam = await TestJam.create(); + * + * // Create and submit a work report + * const report = await createWorkReportAsync({ + * results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + * }); + * const result = await jam.withWorkReport(report).accumulate(); + * + * // Chain multiple work reports + * const result2 = await jam + * .withWorkReport(report1) + * .withWorkReport(report2) + * .withOptions({ debug: true }) + * .accumulate(); + * + * // Query service state after accumulation + * const serviceInfo = jam.getServiceInfo(ServiceId(0)); + * ``` + */ +export class TestJam { + public readonly state: InMemoryState | SerializedState; + private workReports: WorkReport[] = []; + private options: SimulatorOptions = {}; + private blake2b?: Blake2b; + + private constructor(state: InMemoryState | SerializedState) { + this.state = state; + } + + /** + * Create a new TestJam instance with services loaded from the project. + * This is the recommended way to initialize TestJam for most tests. + * + * @returns Promise resolving to a new TestJam instance + * + * @example + * ```typescript + * const jam = await TestJam.create(); + * ``` + */ + static async create(): Promise { + const state = generateState(await loadServices()); + return new TestJam(state); + } + + /** + * Create a new TestJam instance with empty state (no services). + * Useful for testing edge cases or when services are not needed. + * + * @returns A new TestJam instance with empty state + * + * @example + * ```typescript + * const jam = TestJam.empty(); + * ``` + */ + static empty(): TestJam { + const state = generateState([]); + return new TestJam(state); + } + + /** + * Convert a UTF-8 string to a BytesBlob. + * + * @param str - UTF-8 string to convert + * @returns BytesBlob representation of the string + * + * @example + * ```typescript + * const blob = TestJam.stringToBlob("hello"); + * ``` + */ + static stringToBlob(str: string): BytesBlob { + return BytesBlob.blobFromString(str); + } + + /** + * Convert an array of numbers to a BytesBlob. + * + * @param numbers - Array of numbers (0-255) to convert + * @returns BytesBlob representation of the numbers + * + * @example + * ```typescript + * const blob = TestJam.numbersToBlob([1, 2, 3]); + * ``` + */ + static numbersToBlob(numbers: number[]): BytesBlob { + return BytesBlob.blobFromNumbers(numbers); + } + + /** + * Parse a hex string (with or without 0x prefix) to a BytesBlob. + * + * @param hex - Hex string to parse (can include 0x prefix) + * @returns BytesBlob representation of the hex data + * + * @example + * ```typescript + * const blob = TestJam.hexToBlob("0xaabbccdd"); + * const blob2 = TestJam.hexToBlob("aabbccdd"); + * ``` + */ + static hexToBlob(hex: string): BytesBlob { + if (hex.startsWith("0x")) { + return BytesBlob.parseBlob(hex); + } + return BytesBlob.parseBlobNoPrefix(hex); + } + + /** + * Configure simulator options for the next accumulation. + * Options persist across multiple accumulate() calls until changed. + * + * @param options - Simulator configuration options + * @returns This instance for method chaining + * + * @example + * ```typescript + * const result = await jam + * .withOptions({ debug: true, slot: Slot(100) }) + * .withWorkReport(report) + * .accumulate(); + * ``` + */ + withOptions(options: SimulatorOptions): this { + this.options = options; + return this; + } + + /** + * Add a work report to be processed in the next accumulation. + * Multiple work reports can be chained before calling accumulate(). + * + * @param report - Work report to process + * @returns This instance for method chaining + * + * @example + * ```typescript + * const report1 = await createWorkReportAsync({ + * results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + * }); + * const report2 = await createWorkReportAsync({ + * results: [{ serviceId: ServiceId(1), gas: Gas(2000n) }], + * }); + * + * const result = await jam + * .withWorkReport(report1) + * .withWorkReport(report2) + * .accumulate(); + * ``` + */ + withWorkReport(report: WorkReport): this { + this.workReports.push(report); + return this; + } + + /** + * Execute accumulation with all queued work reports and apply state changes. + * Work reports are automatically cleared after accumulation completes. + * + * @returns Promise resolving to accumulation result including state updates + * @throws Error if accumulation fails + * + * @example + * ```typescript + * const result = await jam.withWorkReport(report).accumulate(); + * console.log(`Processed ${result.accumulationStatistics.size} work items`); + * ``` + */ + async accumulate(): Promise { + const result = await simulateAccumulation(this.state, this.workReports, this.options); + this.workReports = []; + if (this.state instanceof InMemoryState) { + this.state.applyUpdate(result); + } else { + if (!this.blake2b) { + this.blake2b = await Blake2b.createHasher(); + } + const chainSpec = this.options.chainSpec ?? tinyChainSpec; + this.state.backend.applyUpdate(serializeStateUpdate(chainSpec, this.blake2b, result)); + } + return result; + } + + /** + * Get service account information for a specific service ID. + * + * @param id - Service ID to query + * @returns Service account info or undefined if not found + * + * @example + * ```typescript + * const info = jam.getServiceInfo(ServiceId(0)); + * console.log(`Service balance: ${info?.balance}`); + * ``` + */ + getServiceInfo(id: jamBlock.ServiceId): ServiceAccountInfo | undefined { + return this.state.getService(id)?.getInfo(); + } + + /** + * Get storage value for a specific key in a service's storage. + * + * @param id - Service ID + * @param key - Storage key + * @returns Storage value, undefined if service not found, null if key not found + * + * @example + * ```typescript + * const key = BytesBlob.blobFrom(new Uint8Array([1, 2, 3])); + * const value = jam.getServiceStorage(ServiceId(0), key); + * ``` + */ + getServiceStorage(id: jamBlock.ServiceId, key: BytesBlob): BytesBlob | undefined | null { + return this.state.getService(id)?.getStorage(asOpaqueType(key)); + } + + /** + * Get preimage data for a given hash in a service's preimage store. + * + * @param id - Service ID + * @param hash - Preimage hash + * @returns Preimage data, undefined if service not found, null if preimage not found + * + * @example + * ```typescript + * const preimage = jam.getServicePreimage(ServiceId(0), someHash); + * ``` + */ + getServicePreimage(id: jamBlock.ServiceId, hash: OpaqueHash): BytesBlob | undefined | null { + return this.state.getService(id)?.getPreimage(hash.asOpaque()); + } + + /** + * Get preimage lookup history for a given hash in a service's preimage store. + * + * @param id - Service ID + * @param hash - Preimage hash + * @param len - Length parameter + * @returns Lookup history, undefined if service not found, null if not found + * + * @example + * ```typescript + * const history = jam.getServicePreimageLookup(ServiceId(0), someHash, U32(32)); + * ``` + */ + getServicePreimageLookup( + id: jamBlock.ServiceId, + hash: OpaqueHash, + len: jamNumbers.U32, + ): LookupHistorySlots | undefined | null { + return this.state.getService(id)?.getLookupHistory(hash.asOpaque(), len); + } +} diff --git a/packages/jammin-sdk/testing-helpers.test.ts b/packages/jammin-sdk/testing-helpers.test.ts new file mode 100644 index 0000000..d7702c3 --- /dev/null +++ b/packages/jammin-sdk/testing-helpers.test.ts @@ -0,0 +1,104 @@ +import { beforeAll, describe, expect, test } from "bun:test"; +import { ZERO_HASH } from "@typeberry/lib/hash"; +import { ServiceAccountInfo } from "@typeberry/lib/state"; +import { + expectAccumulationSuccess, + expectServiceInfoChange, + expectStateChange, + generateGuarantees, + StateChangeAssertionError, + TestJam, +} from "./testing-helpers.js"; +import { CoreId, Gas, ServiceId, Slot, U32, U64 } from "./types.js"; +import { createWorkReportAsync } from "./work-report.js"; + +describe("testing-helpers", () => { + let jam: TestJam; + + beforeAll(() => { + jam = TestJam.empty(); + }); + + describe("generateGuarantees re-export", () => { + test("should generate guarantees from testing-helpers", async () => { + const report = await createWorkReportAsync({ + coreIndex: CoreId(0), + results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + }); + + const guarantees = await generateGuarantees([report], { + slot: Slot(42), + }); + + expect(guarantees).toHaveLength(1); + expect(guarantees[0]?.credentials.length).toBe(3); + }); + }); + + describe("expectAccumulationSuccess", () => { + test("should pass for valid accumulation result", async () => { + const report = await createWorkReportAsync({ + results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + }); + + const result = await jam.withWorkReport(report).accumulate(); + + // Should not throw + expectAccumulationSuccess(result); + }); + }); + + describe("expectStateChange", () => { + test("should pass when predicate returns true", () => { + expectStateChange(10, 20, (before, after) => after > before); + }); + + test("should pass when predicate returns undefined (no explicit return)", () => { + // If predicate doesn't return false, it passes + expectStateChange(10, 20, (_before, _after) => undefined); + }); + + test("should throw when predicate returns false", () => { + expect(() => { + expectStateChange(10, 5, (before, after) => after > before, "Should increase"); + }).toThrow(StateChangeAssertionError); + }); + + test("should include custom error message", () => { + expect(() => { + expectStateChange(10, 5, (before, after) => after > before, "Custom error"); + }).toThrow("Custom error"); + }); + }); + + describe("expectServiceInfoChange", () => { + const zeroService = ServiceAccountInfo.create({ + accumulateMinGas: Gas(0), + balance: U64(0), + codeHash: ZERO_HASH.asOpaque(), + created: Slot(0), + gratisStorage: U64(0), + lastAccumulation: Slot(0), + onTransferMinGas: Gas(0), + parentService: ServiceId(0), + storageUtilisationBytes: U64(0), + storageUtilisationCount: U32(0), + }); + + test("should pass when service info predicate is satisfied", () => { + const before = ServiceAccountInfo.create({ ...zeroService, balance: U64(1000) }); + const after = ServiceAccountInfo.create({ ...zeroService, balance: U64(1500) }); + + expectServiceInfoChange(before, after, (b, a) => a && b && a.balance > b.balance, "Balance should increase"); + }); + + test("should throw when predicate fails", () => { + const before = ServiceAccountInfo.create({ ...zeroService, balance: U64(1000) }); + const after = ServiceAccountInfo.create({ ...zeroService, balance: U64(500) }); + + expect(() => { + expectServiceInfoChange(before, after, (b, a) => a && b && a.balance > b.balance, "Balance should increase"); + }).toThrow(StateChangeAssertionError); + }); + }); +}); diff --git a/packages/jammin-sdk/testing-helpers.ts b/packages/jammin-sdk/testing-helpers.ts new file mode 100644 index 0000000..c59972b --- /dev/null +++ b/packages/jammin-sdk/testing-helpers.ts @@ -0,0 +1,170 @@ +/** + * Testing helpers for JAM service development and integration tests. + * Provides utilities for assertions, state comparisons, and common test patterns. + * + * @module testing-helpers + */ + +import type { ServiceAccountInfo } from "@typeberry/lib/state"; +import type { AccumulateResult } from "@typeberry/lib/transition"; + +export type { AccumulateResult, AccumulateState, GuaranteeOptions, SimulatorOptions, State } from "./simulator.js"; +export { generateGuarantees, simulateAccumulation, TestJam } from "./simulator.js"; +export type { WorkReport, WorkReportConfig, WorkResultConfig, WorkResultStatus } from "./work-report.js"; +export { createWorkReport, createWorkReportAsync, createWorkResult } from "./work-report.js"; + +/** + * Custom error thrown when accumulation assertions fail + */ +export class AccumulationAssertionError extends Error { + constructor( + message: string, + public result: AccumulateResult, + ) { + super(message); + this.name = "AccumulationAssertionError"; + } +} + +/** + * Custom error thrown when state change assertions fail + */ +export class StateChangeAssertionError extends Error { + constructor( + message: string, + public before: unknown, + public after: unknown, + ) { + super(message); + this.name = "StateChangeAssertionError"; + } +} + +/** + * Assert that accumulation completed successfully without errors. + * Validates that the accumulation result has the expected structure. + * + * @param result - Accumulation result to validate + * @throws AccumulationAssertionError if accumulation structure is invalid + * + * @example + * ```typescript + * import { expectAccumulationSuccess } from "@fluffylabs/jammin-sdk/testing-helpers"; + * + * const jam = await TestJam.create(); + * const report = await createWorkReportAsync({ + * results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + * }); + * const result = await jam.withWorkReport(report).accumulate(); + * + * // Assert accumulation completed + * expectAccumulationSuccess(result); + * ``` + */ +export function expectAccumulationSuccess(result: AccumulateResult): void { + if (!result.stateUpdate) { + throw new AccumulationAssertionError("Accumulation result missing stateUpdate", result); + } + + if (!result.accumulationStatistics) { + throw new AccumulationAssertionError("Accumulation result missing accumulationStatistics", result); + } + + if (!result.accumulationOutputLog) { + throw new AccumulationAssertionError("Accumulation result missing accumulationOutputLog", result); + } +} + +/** + * Predicate function for validating state changes. + * Returns true if the state change is valid, false or throws an error otherwise. + */ +export type StateChangePredicate = (before: T, after: T) => boolean | undefined; + +/** + * Assert that a state change satisfies a given predicate. + * Useful for validating service state transitions after accumulation. + * + * @param before - State before accumulation + * @param after - State after accumulation + * @param predicate - Function that validates the state change + * @param errorMessage - Custom error message if assertion fails + * @throws StateChangeAssertionError if predicate returns false + * + * @example + * ```typescript + * import { expectStateChange } from "@fluffylabs/jammin-sdk/testing-helpers"; + * + * const jam = await TestJam.create(); + * const beforeInfo = jam.getServiceInfo(ServiceId(0)); + * + * const report = await createWorkReportAsync({ + * results: [{ serviceId: ServiceId(0), gas: Gas(1000n) }], + * }); + * await jam.withWorkReport(report).accumulate(); + * + * const afterInfo = jam.getServiceInfo(ServiceId(0)); + * + * // Assert that balance increased + * expectStateChange( + * beforeInfo?.balance, + * afterInfo?.balance, + * (before, after) => after > before, + * "Service balance should increase after work" + * ); + * ``` + */ +export function expectStateChange( + before: T, + after: T, + predicate: StateChangePredicate, + errorMessage?: string, +): void { + const result = predicate(before, after); + + // If predicate returns false explicitly, throw error + if (result === false) { + throw new StateChangeAssertionError(errorMessage ?? "State change validation failed", before, after); + } +} + +/** + * Assert that service account information changed as expected. + * Common use case for validating balance changes, gas refunds, etc. + * + * @param before - Service info before accumulation + * @param after - Service info after accumulation + * @param predicate - Function that validates the info change + * @param errorMessage - Custom error message if assertion fails + * @throws StateChangeAssertionError if predicate returns false + * + * @example + * ```typescript + * import { expectServiceInfoChange } from "@fluffylabs/jammin-sdk/testing-helpers"; + * + * const jam = await TestJam.create(); + * const before = jam.getServiceInfo(ServiceId(0)); + * + * await jam.withWorkReport(report).withOptions({ slot: Slot(100) }).accumulate(); + * + * const after = jam.getServiceInfo(ServiceId(0)); + * + * // Validate that gas was consumed + * expectServiceInfoChange( + * before, + * after, + * (b, a) => { + * return a && b && a.lastAccumulation > b.lastAccumulation; + * }, + * "Service should update last accumulation during execution" + * ); + * ``` + */ +export function expectServiceInfoChange( + before: ServiceAccountInfo | undefined, + after: ServiceAccountInfo | undefined, + predicate: StateChangePredicate, + errorMessage?: string, +): void { + expectStateChange(before, after, predicate, errorMessage); +} diff --git a/packages/jammin-sdk/utils/generate-service-output.ts b/packages/jammin-sdk/utils/generate-service-output.ts index fb6876b..532e543 100644 --- a/packages/jammin-sdk/utils/generate-service-output.ts +++ b/packages/jammin-sdk/utils/generate-service-output.ts @@ -1,6 +1,6 @@ import { join, resolve } from "node:path"; import { - type preimage, + type PreimageHash, type ServiceId as ServiceIdType, tryAsServiceGas, tryAsServiceId, @@ -19,8 +19,8 @@ export interface ServiceBuildOutput { code: BytesBlob; storage?: Record; info?: Partial; - preimageBlobs?: HashDictionary; - preimageRequests?: Map; + preimageBlobs?: HashDictionary; + preimageRequests?: Map; } /** @@ -111,7 +111,7 @@ export async function generateServiceOutput( ]), ); - const preimageRequestsMap = new Map( + const preimageRequestsMap = new Map( Object.entries(preimageRequests ?? {}).map(([hash, slots]) => [ Bytes.parseBytes(hash, HASH_SIZE).asOpaque(), tryAsLookupHistorySlots(slots.map((slot) => Slot(slot))), diff --git a/packages/jammin-sdk/utils/generate-test-config.test.ts b/packages/jammin-sdk/utils/generate-test-config.test.ts new file mode 100644 index 0000000..c88b6a8 --- /dev/null +++ b/packages/jammin-sdk/utils/generate-test-config.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, test } from "bun:test"; +import { generateTestConfigCode } from "./generate-test-config.js"; + +describe("generateTestConfigCode", () => { + test("generates valid TypeScript for empty service map", () => { + const code = generateTestConfigCode({}); + + // Should parse without throwing + const transpiler = new Bun.Transpiler({ loader: "ts" }); + expect(() => transpiler.transformSync(code)).not.toThrow(); + }); + + test("generates valid TypeScript for single service", () => { + const code = generateTestConfigCode({ + auth: { id: 0, name: "auth" }, + }); + + const transpiler = new Bun.Transpiler({ loader: "ts" }); + expect(() => transpiler.transformSync(code)).not.toThrow(); + }); + + test("generates valid TypeScript for multiple services", () => { + const code = generateTestConfigCode({ + auth: { id: 0, name: "auth" }, + bank: { id: 1, name: "bank" }, + exchange: { id: 2, name: "exchange" }, + }); + + const transpiler = new Bun.Transpiler({ loader: "ts" }); + expect(() => transpiler.transformSync(code)).not.toThrow(); + }); + + test("includes correct import from SDK package", () => { + const code = generateTestConfigCode({}); + + expect(code).toContain('from "@fluffylabs/jammin-sdk"'); + expect(code).toContain("import { config, ServiceId, TestJam }"); + }); + + test("includes SERVICES constant with correct service entries", () => { + const code = generateTestConfigCode({ + auth: { id: 0, name: "auth" }, + bank: { id: 1, name: "bank" }, + }); + + expect(code).toContain("export const SERVICES"); + expect(code).toContain('auth: { id: ServiceId(0), name: "auth" }'); + expect(code).toContain('bank: { id: ServiceId(1), name: "bank" }'); + }); + + test("includes TEST_CHAIN_SPEC export", () => { + const code = generateTestConfigCode({}); + + expect(code).toContain("export const TEST_CHAIN_SPEC = config.tinyChainSpec"); + }); + + test("includes pre-configured testJam instance", () => { + const code = generateTestConfigCode({}); + + expect(code).toContain("export const testJam = await TestJam.create()"); + }); + + test("handles service names with special characters", () => { + const code = generateTestConfigCode({ + my_service: { id: 42, name: "my_service" }, + }); + + const transpiler = new Bun.Transpiler({ loader: "ts" }); + expect(() => transpiler.transformSync(code)).not.toThrow(); + expect(code).toContain('my_service: { id: ServiceId(42), name: "my_service" }'); + }); +}); diff --git a/packages/jammin-sdk/utils/generate-test-config.ts b/packages/jammin-sdk/utils/generate-test-config.ts new file mode 100644 index 0000000..bc990f3 --- /dev/null +++ b/packages/jammin-sdk/utils/generate-test-config.ts @@ -0,0 +1,104 @@ +import { resolve } from "node:path"; +import type { tinyChainSpec } from "@typeberry/lib/config"; +import { loadBuildConfig } from "../config/config-loader.js"; +import type { ServiceBuildOutput } from "./generate-service-output.js"; + +/** Directory name for generated config output */ +const CONFIG_DIR = "config"; + +/** Generated test config filename */ +const TEST_CONFIG_FILENAME = "jammin.test.config.ts"; + +/** SDK package name used in generated import statements */ +const SDK_PACKAGE_NAME = "@fluffylabs/jammin-sdk"; + +/** + * Generated test configuration with service mappings + */ +export interface GeneratedTestConfig { + services: Record; + chainSpec: typeof tinyChainSpec; +} + +/** + * Generate a TypeScript file with test configuration including: + * - Service ID mappings + * - ChainSpec configuration + * - Service names and IDs + * + * @param services - Array of service build outputs + * @param outputPath - Path where to write the generated test config file + */ +export async function generateTestConfigFile(services: ServiceBuildOutput[], outputPath: string): Promise { + // Create service mapping: service name -> { id, name } + const serviceMap: Record = {}; + + // Load the config to get service names + const config = await loadBuildConfig(); + + // Match services from config with their IDs from build outputs + for (let i = 0; i < config.services.length; i++) { + const service = config.services[i]; + const buildOutput = services[i]; + if (service && buildOutput) { + serviceMap[service.name] = { + id: buildOutput.id, + name: service.name, + }; + } + } + + // Generate TypeScript code + const tsCode = generateTestConfigCode(serviceMap); + + // Write to file + await Bun.write(outputPath, tsCode); +} + +/** + * Generate test config file in the project's config directory + * This is typically called after the build command completes + */ +export async function generateTestConfigInProjectDir( + services: ServiceBuildOutput[], + projectRoot: string = process.cwd(), +): Promise { + const configPath = resolve(projectRoot, CONFIG_DIR, TEST_CONFIG_FILENAME); + await generateTestConfigFile(services, configPath); + return configPath; +} + +/** + * Generate the TypeScript code for test configuration + */ +export function generateTestConfigCode(serviceMap: Record): string { + const serviceEntries = Object.entries(serviceMap) + .map(([name, config]) => ` ${name}: { id: ServiceId(${config.id}), name: "${config.name}" },`) + .join("\n"); + + return `/** + * Auto-generated test configuration + * This file is generated by the jammin build command + * Do not edit manually + */ +import { config, ServiceId, TestJam } from "${SDK_PACKAGE_NAME}"; + +/** + * Type-safe service ID mappings + * Use these constants instead of hardcoding service IDs in tests + */ +export const SERVICES = { +${serviceEntries} +} as const; + +/** + * Chain specification for tests + */ +export const TEST_CHAIN_SPEC = config.tinyChainSpec; + +/** + * Pre-configured TestJam instance with all services loaded in genesis state + */ +export const testJam = await TestJam.create(); +`; +} diff --git a/packages/jammin-sdk/genesis-state-generator.test.ts b/packages/jammin-sdk/utils/genesis-state-generator.test.ts similarity index 98% rename from packages/jammin-sdk/genesis-state-generator.test.ts rename to packages/jammin-sdk/utils/genesis-state-generator.test.ts index 7dfc034..4fe455a 100644 --- a/packages/jammin-sdk/genesis-state-generator.test.ts +++ b/packages/jammin-sdk/utils/genesis-state-generator.test.ts @@ -4,9 +4,9 @@ import { HashDictionary } from "@typeberry/lib/collections"; import { Blake2b, HASH_SIZE } from "@typeberry/lib/hash"; import { type StorageKey, tryAsLookupHistorySlots } from "@typeberry/lib/state"; import { asOpaqueType } from "@typeberry/lib/utils"; +import { Gas, ServiceId, Slot, U32, U64 } from "../types"; +import type { ServiceBuildOutput } from "./generate-service-output"; import { generateGenesis, generateState, toJip4Schema } from "./genesis-state-generator"; -import { Gas, ServiceId, Slot, U32, U64 } from "./types"; -import type { ServiceBuildOutput } from "./utils/generate-service-output"; const blake2b = await Blake2b.createHasher(); diff --git a/packages/jammin-sdk/genesis-state-generator.ts b/packages/jammin-sdk/utils/genesis-state-generator.ts similarity index 98% rename from packages/jammin-sdk/genesis-state-generator.ts rename to packages/jammin-sdk/utils/genesis-state-generator.ts index 20ad724..325d354 100644 --- a/packages/jammin-sdk/genesis-state-generator.ts +++ b/packages/jammin-sdk/utils/genesis-state-generator.ts @@ -21,8 +21,8 @@ import { } from "@typeberry/lib/state"; import { StateEntries } from "@typeberry/lib/state-merkleization"; import { asOpaqueType } from "@typeberry/lib/utils"; -import { Gas, ServiceId, Slot, U32, U64 } from "./types.js"; -import type { ServiceBuildOutput } from "./utils/generate-service-output.js"; +import { Gas, ServiceId, Slot, U32, U64 } from "../types.js"; +import type { ServiceBuildOutput } from "./generate-service-output.js"; const blake2b = await Blake2b.createHasher(); const spec = tinyChainSpec; diff --git a/packages/jammin-sdk/utils/index.ts b/packages/jammin-sdk/utils/index.ts index 8aadb0d..ef4f38d 100644 --- a/packages/jammin-sdk/utils/index.ts +++ b/packages/jammin-sdk/utils/index.ts @@ -1,4 +1,6 @@ export * from "./fetch-repo.js"; export * from "./file-utils.js"; export * from "./generate-service-output.js"; +export * from "./generate-test-config.js"; +export * from "./genesis-state-generator.js"; export * from "./get-service-configs.js"; diff --git a/packages/jammin-sdk/work-report.test.ts b/packages/jammin-sdk/work-report.test.ts index 460be02..48ef1b6 100644 --- a/packages/jammin-sdk/work-report.test.ts +++ b/packages/jammin-sdk/work-report.test.ts @@ -248,21 +248,12 @@ describe("createWorkReport", () => { expect(report.results[0]?.load.gasUsed).toBe(Gas(800n)); }); - test("throws error when results array is empty", () => { - expect(() => { - createWorkReport(blake2b, { - results: [], - }); - }).toThrow("WorkReport cannot contain less than 1 results"); - }); - - test("throws error when results array exceeds maximum", () => { - const results = Array.from({ length: 17 }, (_, i) => ({ serviceId: ServiceId(i) })); - expect(() => { - createWorkReport(blake2b, { - results, - }); - }).toThrow("WorkReport cannot contain more than 16 results"); + test("creates work report with empty results array", () => { + const report = createWorkReport(blake2b, { + results: [], + }); + expect(report).toBeDefined(); + expect(report.results.length).toBe(0); }); }); diff --git a/packages/jammin-sdk/work-report.ts b/packages/jammin-sdk/work-report.ts index 701c36e..1fba1f5 100644 --- a/packages/jammin-sdk/work-report.ts +++ b/packages/jammin-sdk/work-report.ts @@ -1,36 +1,34 @@ import { type CoreIndex, - refineContext, + RefineContext, type ServiceGas, type ServiceId, - workPackage, - workReport, - workResult, + WorkExecResult, + WorkExecResultKind, + type WorkPackageInfo, + WorkPackageSpec, + WorkRefineLoad, + WorkReport, + WorkResult, } from "@typeberry/lib/block"; + +// Re-export WorkReport type for use in other modules +export type { WorkReport }; + import { BytesBlob } from "@typeberry/lib/bytes"; import type { CodecRecord } from "@typeberry/lib/codec"; import { FixedSizeArray } from "@typeberry/lib/collections"; import { type Blake2b, type Blake2bHash, ZERO_HASH } from "@typeberry/lib/hash"; import { CoreId, Gas, Slot, U8, U16, U32 } from "./types.js"; -type RefineContext = ReturnType; -type WorkPackageInfo = ReturnType; -type WorkPackageSpec = ReturnType; -type WorkReport = ReturnType; -type WorkRefineLoad = ReturnType; -type WorkResult = ReturnType; - -// Re-export types for convenience -export type { RefineContext, WorkPackageInfo, WorkPackageSpec, WorkReport, WorkRefineLoad, WorkResult }; - -const { WorkExecResult, WorkExecResultKind } = workResult; - /** Work result status types */ export type WorkResultStatus = | { type: "ok"; output?: BytesBlob } | { type: "panic" } | { type: "outOfGas" } | { type: "badCode" } + | { type: "digestTooBig" } + | { type: "incorrectNumberOfExports" } | { type: "codeOversize" }; /** Configuration for a single work result with optional fields */ @@ -91,38 +89,41 @@ export function createWorkResult(blake2b: Blake2b, config: WorkResultConfig): Wo const payloadHash = blake2b.hashBytes(payloadBlob); const resultStatus = config.result ?? { type: "ok" }; - let execResult: InstanceType; + let execResult: WorkExecResult; switch (resultStatus.type) { case "ok": - execResult = new WorkExecResult( - WorkExecResultKind.ok, - resultStatus.output ?? BytesBlob.blobFrom(new Uint8Array()), - ); + execResult = WorkExecResult.ok(resultStatus.output ?? BytesBlob.blobFrom(new Uint8Array())); break; case "panic": - execResult = new WorkExecResult(WorkExecResultKind.panic); + execResult = WorkExecResult.error(WorkExecResultKind.panic); break; case "outOfGas": - execResult = new WorkExecResult(WorkExecResultKind.outOfGas); + execResult = WorkExecResult.error(WorkExecResultKind.outOfGas); break; case "badCode": - execResult = new WorkExecResult(WorkExecResultKind.badCode); + execResult = WorkExecResult.error(WorkExecResultKind.badCode); + break; + case "digestTooBig": + execResult = WorkExecResult.error(WorkExecResultKind.digestTooBig); + break; + case "incorrectNumberOfExports": + execResult = WorkExecResult.error(WorkExecResultKind.incorrectNumberOfExports); break; case "codeOversize": - execResult = new WorkExecResult(WorkExecResultKind.codeOversize); + execResult = WorkExecResult.error(WorkExecResultKind.codeOversize); break; } const load = config.load ?? {}; - return workResult.WorkResult.create({ + return WorkResult.create({ serviceId: config.serviceId, codeHash: (config.codeHash ?? ZERO_HASH).asOpaque(), payloadHash, gas: config.gas ?? Gas(0n), result: execResult, - load: workResult.WorkRefineLoad.create({ + load: WorkRefineLoad.create({ gasUsed: load.gasUsed ?? Gas(0n), importedSegments: load.importedSegments ?? U32(0), exportedSegments: load.exportedSegments ?? U32(0), @@ -155,28 +156,20 @@ export function createWorkResult(blake2b: Blake2b, config: WorkResultConfig): Wo * ``` */ export function createWorkReport(blake2b: Blake2b, config: WorkReportConfig): WorkReport { - if (config.results.length < workPackage.MIN_NUMBER_OF_WORK_ITEMS) { - throw new Error(`WorkReport cannot contain less than ${workPackage.MIN_NUMBER_OF_WORK_ITEMS} results`); - } - - if (config.results.length > workPackage.MAX_NUMBER_OF_WORK_ITEMS) { - throw new Error(`WorkReport cannot contain more than ${workPackage.MAX_NUMBER_OF_WORK_ITEMS} results`); - } - const results = config.results.map((resultConfig) => createWorkResult(blake2b, resultConfig)); const wpSpec = config.workPackageSpec ?? {}; const ctx = config.context ?? {}; - return workReport.WorkReport.create({ - workPackageSpec: workReport.WorkPackageSpec.create({ + return WorkReport.create({ + workPackageSpec: WorkPackageSpec.create({ hash: wpSpec.hash ?? ZERO_HASH.asOpaque(), length: wpSpec.length ?? U32(0), erasureRoot: wpSpec.erasureRoot ?? ZERO_HASH.asOpaque(), exportsRoot: wpSpec.exportsRoot ?? ZERO_HASH.asOpaque(), exportsCount: wpSpec.exportsCount ?? U16(0), }), - context: refineContext.RefineContext.create({ + context: RefineContext.create({ anchor: ctx.anchor ?? ZERO_HASH.asOpaque(), stateRoot: ctx.stateRoot ?? ZERO_HASH.asOpaque(), beefyRoot: ctx.beefyRoot ?? ZERO_HASH.asOpaque(),