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

Replica test runner #246

Merged
merged 16 commits into from
Aug 30, 2024
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cli/api/actors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {_SERVICE as _STORAGE_SERVICE} from '../declarations/storage/storage.did.
import {getEndpoint} from './network.js';
import {getNetwork} from './network.js';

let agentPromiseByPrincipal = new Map <string, Promise<HttpAgent>>();
let agentPromiseByPrincipal = new Map<string, Promise<HttpAgent>>();

let getAgent = async (identity ?: Identity) : Promise<HttpAgent> => {
let principal = identity ? identity?.getPrincipal().toText() : '';
Expand Down
9 changes: 7 additions & 2 deletions cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ import {resolvePackages} from './resolve-packages.js';
declare global {
// eslint-disable-next-line no-var
var MOPS_NETWORK : string;
// eslint-disable-next-line no-var
var mopsReplicaTestRunning : boolean;
}

let networkFile = getNetworkFile();
Expand Down Expand Up @@ -219,9 +221,11 @@ program
.command('test [filter]')
.description('Run tests')
.addOption(new Option('-r, --reporter <reporter>', 'Test reporter').choices(['verbose', 'compact', 'files', 'silent']))
.addOption(new Option('--mode <mode>', 'Test mode').choices(['interpreter', 'wasi']).default('interpreter'))
.addOption(new Option('--mode <mode>', 'Test mode').choices(['interpreter', 'wasi', 'replica']).default('interpreter'))
.addOption(new Option('--replica <replica>', 'Which replica to use to run tests in replica mode').choices(['dfx', 'pocket-ic']))
.option('-w, --watch', 'Enable watch mode')
.action(async (filter, options) => {
checkConfigFile(true);
await installAll({silent: true, lock: 'ignore'});
await test(filter, options);
});
Expand All @@ -230,13 +234,14 @@ program
program
.command('bench [filter]')
.description('Run benchmarks')
.addOption(new Option('--replica <replica>', 'Which replica to use to run benchmarks').choices(['dfx', 'pocket-ic']).default('dfx'))
.addOption(new Option('--replica <replica>', 'Which replica to use to run benchmarks').choices(['dfx', 'pocket-ic']))
.addOption(new Option('--gc <gc>', 'Garbage collector').choices(['copying', 'compacting', 'generational', 'incremental']).default('copying'))
.addOption(new Option('--save', 'Save benchmark results to .bench/<filename>.json'))
.addOption(new Option('--compare', 'Run benchmark and compare results with .bench/<filename>.json'))
// .addOption(new Option('--force-gc', 'Force GC'))
.addOption(new Option('--verbose', 'Show more information'))
.action(async (filter, options) => {
checkConfigFile(true);
await installAll({silent: true, lock: 'ignore'});
await bench(filter, options);
});
Expand Down
17 changes: 11 additions & 6 deletions cli/commands/bench-replica.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {execSync} from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import {execaCommand} from 'execa';
import {PocketIc} from 'pic-ic';
import {PocketIc, PocketIcServer} from 'pic-ic';
import {getRootDir, readConfig} from '../mops.js';
import {createActor, idlFactory} from '../declarations/bench/index.js';
import {toolchain} from './toolchain/index.js';
Expand All @@ -12,6 +12,7 @@ export class BenchReplica {
type : 'dfx' | 'pocket-ic';
verbose = false;
canisters : Record<string, {cwd : string; canisterId : string; actor : any;}> = {};
pocketIcServer ?: PocketIcServer;
pocketIc ?: PocketIc;

constructor(type : 'dfx' | 'pocket-ic', verbose = false) {
Expand All @@ -31,11 +32,14 @@ export class BenchReplica {
else {
let pocketIcBin = await toolchain.bin('pocket-ic');
let config = readConfig();
if (config.toolchain?.['pocket-ic'] !== '1.0.0') {
console.error('Currently only pocket-ic 1.0.0 is supported');
if (config.toolchain?.['pocket-ic'] !== '4.0.0') {
console.error('Current Mops CLI only supports pocket-ic 4.0.0');
process.exit(1);
}
this.pocketIc = await PocketIc.create(pocketIcBin);
this.pocketIcServer = await PocketIcServer.start({
binPath: pocketIcBin,
});
this.pocketIc = await PocketIc.create(this.pocketIcServer.getUrl());
}
}

Expand All @@ -44,8 +48,9 @@ export class BenchReplica {
let dir = path.join(getRootDir(), '.mops/.bench');
execSync('dfx stop' + (this.verbose ? '' : ' -qqqq'), {cwd: dir, stdio: ['pipe', this.verbose ? 'inherit' : 'ignore', 'pipe']});
}
else if (this.pocketIc) {
else if (this.pocketIc && this.pocketIcServer) {
await this.pocketIc.tearDown();
await this.pocketIcServer.stop();
}
}

Expand All @@ -61,7 +66,7 @@ export class BenchReplica {
this.canisters[name] = {cwd, canisterId, actor};
}
else if (this.pocketIc) {
let {canisterId, actor} = await this.pocketIc.setupCanister(idlFactory, wasm);
let {canisterId, actor} = await this.pocketIc.setupCanister({idlFactory, wasm});
this.canisters[name] = {
cwd,
canisterId: canisterId.toText(),
Expand Down
9 changes: 5 additions & 4 deletions cli/commands/bench.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,10 @@ type BenchOptions = {
};

export async function bench(filter = '', optionsArg : Partial<BenchOptions> = {}) : Promise<Benchmarks> {
let config = readConfig();

let defaultOptions : BenchOptions = {
replica: 'dfx',
replica: config.toolchain?.['pocket-ic'] ? 'pocket-ic' : 'dfx',
replicaVersion: '',
compiler: 'moc',
compilerVersion: getMocVersion(),
Expand All @@ -68,7 +70,6 @@ export async function bench(filter = '', optionsArg : Partial<BenchOptions> = {}
options.replicaVersion = getDfxVersion();
}
else if (options.replica == 'pocket-ic') {
let config = readConfig();
options.replicaVersion = config.toolchain?.['pocket-ic'] || '';
}

Expand Down Expand Up @@ -97,7 +98,7 @@ export async function bench(filter = '', optionsArg : Partial<BenchOptions> = {}
files.sort();

let benchDir = `${getRootDir()}/.mops/.bench/`;
fs.rmSync(benchDir, {recursive: true, force: true});
// fs.rmSync(benchDir, {recursive: true, force: true});
fs.mkdirSync(benchDir, {recursive: true});

if (!options.silent) {
Expand Down Expand Up @@ -146,7 +147,7 @@ export async function bench(filter = '', optionsArg : Partial<BenchOptions> = {}
options.silent || console.log('Stopping replica...');
await replica.stop();

fs.rmSync(benchDir, {recursive: true, force: true});
// fs.rmSync(benchDir, {recursive: true, force: true});

return benchResults;
}
Expand Down
2 changes: 1 addition & 1 deletion cli/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ export async function publish(options : {docs ?: boolean, test ?: boolean, bench
let reporter = new SilentReporter;
if (options.test) {
console.log('Running tests...');
await testWithReporter('silent');
await testWithReporter(reporter, '', 'interpreter', config.toolchain?.['pocket-ic'] ? 'pocket-ic' : 'dfx');
if (reporter.failed > 0) {
console.log(chalk.red('Error: ') + 'tests failed');
process.exit(1);
Expand Down
237 changes: 237 additions & 0 deletions cli/commands/replica.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,237 @@
import process from 'node:process';
import {ChildProcessWithoutNullStreams, execSync, spawn} from 'node:child_process';
import path from 'node:path';
import fs from 'node:fs';
import {PassThrough} from 'node:stream';

import {IDL} from '@dfinity/candid';
import {Actor, HttpAgent} from '@dfinity/agent';
import {PocketIc, PocketIcServer} from 'pic-ic';

import {readConfig} from '../mops.js';
import {toolchain} from './toolchain/index.js';

type StartOptions = {
type ?: 'dfx' | 'pocket-ic';
dir ?: string;
verbose ?: boolean;
silent ?: boolean;
};

export class Replica {
type : 'dfx' | 'pocket-ic' = 'dfx';
verbose = false;
canisters : Record<string, {cwd : string; canisterId : string; actor : any; stream : PassThrough;}> = {};
pocketIcServer ?: PocketIcServer;
pocketIc ?: PocketIc;
dfxProcess ?: ChildProcessWithoutNullStreams;
dir : string = ''; // absolute path (/.../.mops/.test/)
ttl = 60;

async start({type, dir, verbose, silent} : StartOptions = {}) {
this.type = type ?? this.type;
this.verbose = verbose ?? this.verbose;
this.dir = dir ?? this.dir;

silent || console.log(`Starting ${this.type} replica...`);

if (this.type == 'dfx') {
fs.mkdirSync(this.dir, {recursive: true});
fs.writeFileSync(path.join(this.dir, 'dfx.json'), JSON.stringify(this.dfxJson(''), null, 2));
fs.writeFileSync(path.join(this.dir, 'canister.did'), 'service : { runTests: () -> (); }');

await this.stop();

this.dfxProcess = spawn('dfx', ['start', '--clean', '--artificial-delay', '0', (this.verbose ? '' : '-qqqq')].filter(x => x), {cwd: this.dir});

// process canister logs
this._attachCanisterLogHandler(this.dfxProcess);

this.dfxProcess.stdout.on('data', (data) => {
console.log('DFX:', data.toString());
});

// await for dfx to start
let ok = false;
while (!ok) {
await fetch('http://127.0.0.1:4945/api/v2/status')
.then(res => {
if (res.status === 200) {
ok = true;
}
})
.catch(() => {})
.finally(() => {
return new Promise(resolve => setTimeout(resolve, 1000));
});
}
}
else {
let pocketIcBin = await toolchain.bin('pocket-ic');

// eslint-disable-next-line
let config = readConfig();
if (config.toolchain?.['pocket-ic'] !== '4.0.0') {
console.error('Current Mops CLI only supports pocket-ic 4.0.0');
process.exit(1);
}

this.pocketIcServer = await PocketIcServer.start({
showRuntimeLogs: false,
showCanisterLogs: false,
binPath: pocketIcBin,
ttl: this.ttl,
});
this.pocketIc = await PocketIc.create(this.pocketIcServer.getUrl());

// process canister logs
this._attachCanisterLogHandler(this.pocketIcServer.serverProcess as ChildProcessWithoutNullStreams);
}
}

_attachCanisterLogHandler(proc : ChildProcessWithoutNullStreams) {
let curData = '';
proc.stderr.on('data', (data) => {
curData = curData + data.toString();

if (curData.includes('\n')) {
let m = curData.match(/\[Canister ([a-z0-9-]+)\] (.*)/);
if (!m) {
return;
}
let [, canisterId, msg] = m;

let stream = this.getCanisterStream(canisterId || '');
if (stream) {
stream.write(msg);
}

curData = '';
}
});
}

async stop(sigint = false) {
if (this.type == 'dfx') {
this.dfxProcess?.kill();
// execSync('dfx stop' + (this.verbose ? '' : ' -qqqq'), {cwd: this.dir, timeout: 10_000, stdio: ['pipe', this.verbose ? 'inherit' : 'ignore', 'pipe']});
}
else if (this.pocketIc && this.pocketIcServer) {
if (!sigint) {
await this.pocketIc.tearDown(); // error 'fetch failed' if run on SIGINT
}
await this.pocketIcServer.stop();
}
}

async deploy(name : string, wasm : string, idlFactory : IDL.InterfaceFactory, cwd : string = process.cwd()) {
if (this.type === 'dfx') {
// prepare dfx.json for current canister
let dfxJson = path.join(this.dir, 'dfx.json');

let oldDfxJsonData;
if (fs.existsSync(dfxJson)) {
oldDfxJsonData = JSON.parse(fs.readFileSync(dfxJson).toString());
}
let newDfxJsonData = this.dfxJson(name, name + '.wasm');

if (oldDfxJsonData.canisters) {
newDfxJsonData.canisters = Object.assign(oldDfxJsonData.canisters, newDfxJsonData.canisters);
}

fs.mkdirSync(this.dir, {recursive: true});
fs.writeFileSync(dfxJson, JSON.stringify(newDfxJsonData, null, 2));

execSync(`dfx deploy ${name} --mode reinstall --yes --identity anonymous`, {cwd: this.dir, stdio: this.verbose ? 'pipe' : ['pipe', 'ignore', 'pipe']});
execSync(`dfx ledger fabricate-cycles --canister ${name} --t 100`, {cwd: this.dir, stdio: this.verbose ? 'pipe' : ['pipe', 'ignore', 'pipe']});

let canisterId = execSync(`dfx canister id ${name}`, {cwd: this.dir}).toString().trim();

let actor = Actor.createActor(idlFactory, {
agent: await HttpAgent.create({
host: 'http://127.0.0.1:4945',
shouldFetchRootKey: true,
}),
canisterId,
});

this.canisters[name] = {
cwd,
canisterId,
actor,
stream: new PassThrough(),
};
}
else if (this.pocketIc) {
// let {canisterId, actor} = await this.pocketIc.setupCanister(idlFactory, wasm);
let {canisterId, actor} = await this.pocketIc.setupCanister({
idlFactory,
wasm,
});
await this.pocketIc.addCycles(canisterId, 1_000_000_000_000);
this.canisters[name] = {
cwd,
canisterId: canisterId.toText(),
actor,
stream: new PassThrough(),
};
}

if (!this.canisters[name]) {
throw new Error(`Canister ${name} not found`);
}

return this.canisters[name];
}

getActor(name : string) : unknown {
if (!this.canisters[name]) {
throw new Error(`Canister ${name} not found`);
}
return this.canisters[name]?.actor;
}

getCanister(name : string) {
return this.canisters[name];
}

getCanisterId(name : string) : string {
return this.canisters[name]?.canisterId || '';
}

getCanisterStream(canisterId : string) : PassThrough | null {
for (let canister of Object.values(this.canisters)) {
if (canister.canisterId === canisterId) {
return canister.stream;
}
}
return null;
}

dfxJson(canisterName : string, wasmPath = 'canister.wasm', didPath = 'canister.did') {
let canisters : Record<string, any> = {};
if (canisterName) {
canisters[canisterName] = {
type: 'custom',
wasm: wasmPath,
candid: didPath,
};
}

return {
version: 1,
canisters,
defaults: {
build: {
packtool: 'mops sources',
},
},
networks: {
local: {
type: 'ephemeral',
bind: '127.0.0.1:4945',
},
},
};
}
}
Comment on lines +21 to +237
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review of the Replica class

The Replica class is well-structured and handles different types of replicas (dfx and pocket-ic). It includes methods for starting, stopping, deploying canisters, and handling logs. The use of TypeScript enhances type safety and readability.

However, there are several areas that could be improved:

  1. Error Handling: The methods could benefit from more robust error handling, especially in network requests and file operations.
  2. Logging: The logging could be made more consistent and configurable, especially in production environments.
  3. Configuration Management: The handling of configuration, especially around toolchain versions and paths, could be more robust to avoid potential issues with version mismatches or configuration errors.

Overall, the class provides a solid foundation but could be refined to improve robustness and maintainability.

Loading
Loading