diff --git a/src/commands/contract/compile.ts b/src/commands/contract/compile.ts index 56129c01..e56f1638 100644 --- a/src/commands/contract/compile.ts +++ b/src/commands/contract/compile.ts @@ -1,9 +1,9 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { ensureCargoContractVersionCompatibility, extractCargoContractVersion, generateTypes, Spinner, storeArtifacts } from "../../lib/index.js"; import { spawn } from "node:child_process"; import { pathExists } from "fs-extra/esm"; import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ensureCargoContractVersionCompatibility, extractCargoContractVersion, Spinner, storeArtifacts } from "../../lib/index.js"; import { ConfigError, InputError, ProcessError } from "../../lib/errors.js"; export class CompileContract extends SwankyCommand { @@ -126,12 +126,6 @@ export class CompileContract extends SwankyCommand { return storeArtifacts(artifactsPath, contractInfo.name, contractInfo.moduleName); }, "Moving artifacts"); - await spinner.runCommand( - async () => await generateTypes(contractInfo.name), - `Generating ${contractName} contract ts types`, - `${contractName} contract's TS types Generated successfully`, - ); - this.swankyConfig.contracts[contractName].build = { timestamp: Date.now(), artifactsPath, diff --git a/src/commands/contract/new.ts b/src/commands/contract/new.ts index 0936663f..c671886f 100644 --- a/src/commands/contract/new.ts +++ b/src/commands/contract/new.ts @@ -1,11 +1,12 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { ensureDir, pathExists, writeJSON } from "fs-extra/esm"; +import { ensureDir, pathExists, pathExistsSync, writeJSON } from "fs-extra/esm"; import { checkCliDependencies, copyContractTemplateFiles, processTemplates, getTemplates, + prepareTestFiles, } from "../../lib/index.js"; import { email, name, pickTemplate } from "../../lib/prompts.js"; import { paramCase, pascalCase, snakeCase } from "change-case"; @@ -78,6 +79,18 @@ export class NewContract extends SwankyCommand { "Copying contract template files" ); + if (contractTemplate === "psp22") { + const e2eTestHelpersPath = path.resolve(projectPath, "tests", "test_helpers"); + if (!pathExistsSync(e2eTestHelpersPath)) { + await this.spinner.runCommand( + () => prepareTestFiles("e2e", path.resolve(templates.templatesPath), projectPath), + "Copying e2e test helpers" + ); + } else { + console.log("e2e test helpers already exist. No files were copied."); + } + } + await this.spinner.runCommand( () => processTemplates(projectPath, { @@ -93,7 +106,6 @@ export class NewContract extends SwankyCommand { ); await ensureDir(path.resolve(projectPath, "artifacts", args.contractName)); - await ensureDir(path.resolve(projectPath, "tests", args.contractName)); await this.spinner.runCommand(async () => { this.swankyConfig.contracts[args.contractName] = { diff --git a/src/commands/contract/test.ts b/src/commands/contract/test.ts index 216eb6b0..3ef72bfe 100644 --- a/src/commands/contract/test.ts +++ b/src/commands/contract/test.ts @@ -3,11 +3,13 @@ import { Flags, Args } from "@oclif/core"; import path from "node:path"; import { globby } from "globby"; import Mocha from "mocha"; -import { emptyDir } from "fs-extra/esm"; +import { emptyDir, pathExistsSync } from "fs-extra/esm"; import shell from "shelljs"; import { Contract } from "../../lib/contract.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; -import { ConfigError, FileError, InputError, TestError } from "../../lib/errors.js"; +import { ConfigError, FileError, InputError, ProcessError, TestError } from "../../lib/errors.js"; +import { spawn } from "node:child_process"; +import { Spinner } from "../../lib/index.js"; declare global { var contractTypesPath: string; // eslint-disable-line no-var @@ -20,7 +22,11 @@ export class TestContract extends SwankyCommand { all: Flags.boolean({ default: false, char: "a", - description: "Set all to true to compile all contracts", + description: "Run tests for all contracts", + }), + mocha: Flags.boolean({ + default: false, + description: "Run tests with mocha", }), }; @@ -43,7 +49,7 @@ export class TestContract extends SwankyCommand { ? Object.keys(this.swankyConfig.contracts) : [args.contractName]; - const testDir = path.resolve("tests"); + const spinner = new Spinner(); for (const contractName of contractNames) { const contractRecord = this.swankyConfig.contracts[contractName]; @@ -61,54 +67,119 @@ export class TestContract extends SwankyCommand { ); } - const artifactsCheck = await contract.artifactsExist(); + console.log(`Testing contract: ${contractName}`); - if (!artifactsCheck.result) { - throw new FileError( - `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + if (!flags.mocha) { + await spinner.runCommand( + async () => { + return new Promise((resolve, reject) => { + const compileArgs = [ + "test", + "--features", + "e2e-tests", + "--manifest-path", + `contracts/${contractName}/Cargo.toml`, + "--release" + ]; + + const compile = spawn("cargo", compileArgs); + this.logger.info(`Running e2e-tests command: [${JSON.stringify(compile.spawnargs)}]`); + let outputBuffer = ""; + let errorBuffer = ""; + + compile.stdout.on("data", (data) => { + outputBuffer += data.toString(); + spinner.ora.clear(); + }); + compile.stdout.pipe(process.stdout); + + compile.stderr.on("data", (data) => { + errorBuffer += data; + }); + + compile.on("exit", (code) => { + if (code === 0) { + const regex = /test result: (.*)/; + const match = outputBuffer.match(regex); + if (match) { + this.logger.info(`Contract ${contractName} e2e-testing done.`); + resolve(match[1]); + } + } else { + reject(new ProcessError(errorBuffer)); + } + }); + }); + }, + `Testing ${contractName} contract`, + `${contractName} testing finished successfully` ); - } + } else { - console.log(`Testing contract: ${contractName}`); + const testDir = path.resolve("tests"); - const reportDir = path.resolve(testDir, contract.name, "testReports"); - - await emptyDir(reportDir); - - const mocha = new Mocha({ - timeout: 200000, - reporter: "mochawesome", - reporterOptions: { - reportDir, - charts: true, - reportTitle: `${contractName} test report`, - quiet: true, - json: false, - }, - }); - - const tests = await globby(`${path.resolve(testDir, contractName)}/*.test.ts`); - - tests.forEach((test) => { - mocha.addFile(test); - }); - - global.contractTypesPath = path.resolve(testDir, contractName, "typedContract"); - - shell.cd(`${testDir}/${contractName}`); - try { - await new Promise((resolve, reject) => { - mocha.run((failures) => { - if (failures) { - reject(`At least one of the tests failed. Check report for details: ${reportDir}`); - } else { - this.log(`All tests passing. Check the report for details: ${reportDir}`); - resolve(); - } - }); + if (!pathExistsSync(testDir)) { + throw new FileError(`Tests folder does not exist: ${testDir}`); + } + + const artifactsCheck = await contract.artifactsExist(); + + if (!artifactsCheck.result) { + throw new FileError( + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + ); + } + + const artifactPath = path.resolve("typedContracts", `${contractName}`); + const typedContractCheck = await contract.typedContractExists(contractName); + + this.log(`artifactPath: ${artifactPath}`); + + if (!typedContractCheck.result) { + throw new FileError( + `No typed contract found at path: ${typedContractCheck.missingPaths.toString()}` + ); + } + + const reportDir = path.resolve(testDir, contract.name, "testReports"); + + await emptyDir(reportDir); + + const mocha = new Mocha({ + timeout: 200000, + reporter: "mochawesome", + reporterOptions: { + reportDir, + charts: true, + reportTitle: `${contractName} test report`, + quiet: true, + json: false, + }, }); - } catch (cause) { - throw new TestError("Error in test", { cause }); + + const tests = await globby(`${path.resolve(testDir, contractName)}/*.test.ts`); + + tests.forEach((test) => { + mocha.addFile(test); + }); + + global.contractTypesPath = path.resolve(testDir, contractName, "typedContract"); + + shell.cd(`${testDir}/${contractName}`); + try { + await new Promise((resolve, reject) => { + mocha.run((failures) => { + if (failures) { + reject(`At least one of the tests failed. Check report for details: ${reportDir}`); + } else { + this.log(`All tests passing. Check the report for details: ${reportDir}`); + resolve(); + } + }); + }); + } catch (cause) { + throw new TestError("Error in test", { cause }); + } } } } diff --git a/src/commands/generate/tests.ts b/src/commands/generate/tests.ts new file mode 100644 index 00000000..09af4ead --- /dev/null +++ b/src/commands/generate/tests.ts @@ -0,0 +1,162 @@ +import { Args, Flags } from "@oclif/core"; +import { getTemplates, prepareTestFiles, processTemplates } from "../../lib/index.js"; +import { Contract } from "../../lib/contract.js"; +import { SwankyCommand } from "../../lib/swankyCommand.js"; +import { ConfigError, FileError, InputError } from "../../lib/errors.js"; +import path from "node:path"; +import { existsSync } from "node:fs"; +import inquirer from "inquirer"; +import { paramCase, pascalCase } from "change-case"; +import { TestType } from "../../index.js"; + +export class GenerateTests extends SwankyCommand { + static description = "Generate test files for the specified contract"; + + static args = { + contractName: Args.string({ + name: "contractName", + required: false, + description: "Name of the contract", + }), + }; + + static flags = { + template: Flags.string({ + options: getTemplates().contractTemplatesList, + }), + mocha: Flags.boolean({ + default: false, + description: "Generate mocha test files", + }), + }; + + async run(): Promise { + const { args, flags } = await this.parse(GenerateTests); + + if (flags.mocha) { + if (!args.contractName) { + throw new InputError("The 'contractName' argument is required to generate mocha tests."); + } + + await this.checkContract(args.contractName) + } + + const testType: TestType = flags.mocha ? "mocha" : "e2e"; + const testsFolderPath = path.resolve("tests"); + const testPath = this.getTestPath(testType, testsFolderPath, args.contractName); + + const templates = getTemplates(); + const templateName = await this.resolveTemplateName(flags, templates.contractTemplatesList); + + const overwrite = await this.checkOverwrite(testPath, testType, args.contractName); + if (!overwrite) return; + + await this.generateTests( + testType, + templates.templatesPath, + process.cwd(), + args.contractName, + templateName + ); + } + + async checkContract(name: string) { + const contractRecord = this.swankyConfig.contracts[name]; + if (!contractRecord) { + throw new ConfigError( + `Cannot find a contract named ${name} in swanky.config.json` + ); + } + + const contract = new Contract(contractRecord); + if (!(await contract.pathExists())) { + throw new FileError( + `Path to contract ${name} does not exist: ${contract.contractPath}` + ); + } + + const artifactsCheck = await contract.artifactsExist(); + if (!artifactsCheck.result) { + throw new FileError( + `No artifact file found at path: ${artifactsCheck.missingPaths.toString()}` + ); + } + } + + async checkOverwrite( + testPath: string, + testType: TestType, + contractName?: string + ): Promise { + if (!existsSync(testPath)) return true; // No need to overwrite + const message = + testType === "e2e" + ? "Test helpers already exist. Overwrite?" + : `Mocha tests for ${contractName} already exist. Overwrite?`; + + const { overwrite } = await inquirer.prompt({ + type: "confirm", + name: "overwrite", + message, + default: false, + }); + + return overwrite; + } + + getTestPath(testType: TestType, testsPath: string, contractName?: string): string { + if (testType === "e2e") { + return path.resolve(testsPath, "test_helpers"); + } else if (testType === "mocha" && contractName) { + return path.resolve(testsPath, contractName, "index.test.ts"); + } else { + throw new InputError("The 'contractName' argument is required to generate mocha tests."); + } + } + + async resolveTemplateName(flags: any, templates: any): Promise { + if (flags.mocha && !flags.template) { + if (!templates?.length) throw new ConfigError("Template list is empty!"); + const response = await inquirer.prompt([ + { + type: "list", + name: "template", + message: "Choose a contract template:", + choices: templates, + }, + ]); + return response.template; + } + return flags.template; + } + + async generateTests( + testType: TestType, + templatesPath: string, + projectPath: string, + contractName?: string, + templateName?: string + ): Promise { + if (testType === "e2e") { + await this.spinner.runCommand( + () => prepareTestFiles("e2e", templatesPath, projectPath), + "Generating e2e test helpers" + ); + } else { + await this.spinner.runCommand( + () => prepareTestFiles("mocha", templatesPath, projectPath, templateName, contractName), + `Generating tests for ${contractName} with mocha` + ); + } + await this.spinner.runCommand( + () => + processTemplates(projectPath, { + project_name: paramCase(this.config.pjson.name), + swanky_version: this.config.pjson.version, + contract_name: contractName ?? "", + contract_name_pascal: contractName ? pascalCase(contractName) : "", + }), + "Processing templates" + ); + } +} diff --git a/src/commands/contract/typegen.ts b/src/commands/generate/types.ts similarity index 91% rename from src/commands/contract/typegen.ts rename to src/commands/generate/types.ts index 391e0a3c..79cf6134 100644 --- a/src/commands/contract/typegen.ts +++ b/src/commands/generate/types.ts @@ -4,7 +4,7 @@ import { Contract } from "../../lib/contract.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; import { ConfigError, FileError } from "../../lib/errors.js"; -export class TypegenCommand extends SwankyCommand { +export class GenerateTypes extends SwankyCommand { static description = "Generate types from compiled contract metadata"; static args = { @@ -16,7 +16,7 @@ export class TypegenCommand extends SwankyCommand { }; async run(): Promise { - const { args } = await this.parse(TypegenCommand); + const { args } = await this.parse(GenerateTypes); const contractRecord = this.swankyConfig.contracts[args.contractName]; if (!contractRecord) { diff --git a/src/commands/init/index.ts b/src/commands/init/index.ts index 21712eba..226cdacf 100644 --- a/src/commands/init/index.ts +++ b/src/commands/init/index.ts @@ -1,21 +1,23 @@ import { Args, Flags } from "@oclif/core"; import path from "node:path"; -import { ensureDir, writeJSON, pathExists, copy, outputFile, readJSON, remove } from "fs-extra/esm"; -import { stat, readdir, readFile } from "fs/promises"; +import { copy, ensureDir, outputFile, pathExists, readJSON, remove, writeJSON } from "fs-extra/esm"; +import { readdir, readFile, stat } from "fs/promises"; import { execaCommand, execaCommandSync } from "execa"; import { paramCase, pascalCase, snakeCase } from "change-case"; import inquirer from "inquirer"; import TOML from "@iarna/toml"; import { choice, email, name, pickNodeVersion, pickTemplate } from "../../lib/prompts.js"; import { + ChainAccount, checkCliDependencies, copyCommonTemplateFiles, copyContractTemplateFiles, downloadNode, + getTemplates, installDeps, - ChainAccount, + prepareTestFiles, processTemplates, - getTemplates, swankyNodeVersions, + swankyNodeVersions } from "../../lib/index.js"; import { ALICE_URI, BOB_URI, @@ -26,7 +28,7 @@ import { } from "../../lib/consts.js"; import { SwankyCommand } from "../../lib/swankyCommand.js"; import { InputError, UnknownError } from "../../lib/errors.js"; -import { GlobEntry, globby } from "globby"; +import { globby, GlobEntry } from "globby"; import { merge } from "lodash-es"; import inquirerFuzzyPath from "inquirer-fuzzy-path"; import { SwankyConfig } from "../../types/index.js"; @@ -91,6 +93,7 @@ export class Init extends SwankyCommand { super(argv, config); (this.constructor as typeof SwankyCommand).ENSURE_SWANKY_CONFIG = false; } + projectPath = ""; @@ -206,7 +209,6 @@ export class Init extends SwankyCommand { Object.keys(this.configBuilder.contracts!).forEach(async (contractName) => { await ensureDir(path.resolve(this.projectPath, "artifacts", contractName)); - await ensureDir(path.resolve(this.projectPath, "tests", contractName)); }); this.taskQueue.push({ @@ -232,7 +234,7 @@ export class Init extends SwankyCommand { runningMessage, successMessage, failMessage, - shouldExitOnError + shouldExitOnError, ); if (result && callback) { callback(result as string); @@ -287,6 +289,14 @@ export class Init extends SwankyCommand { runningMessage: "Copying contract template files", }); + if (contractTemplate === "psp22") { + this.taskQueue.push({ + task: prepareTestFiles, + args: ["e2e", path.resolve(templates.templatesPath), this.projectPath], + runningMessage: "Copying test helpers", + }); + } + this.taskQueue.push({ task: processTemplates, args: [ @@ -324,7 +334,7 @@ export class Init extends SwankyCommand { } catch (cause) { throw new InputError( `Error reading target directory [${chalk.yellowBright(pathToExistingProject)}]`, - { cause } + { cause }, ); } @@ -344,7 +354,7 @@ export class Init extends SwankyCommand { const candidatesList: CopyCandidates = await getCopyCandidatesList( pathToExistingProject, - copyGlobsList + copyGlobsList, ); const testDir = await detectTests(pathToExistingProject); @@ -512,10 +522,10 @@ async function confirmCopyList(candidatesList: CopyCandidates) { ( item: PathEntry & { group: "contracts" | "crates" | "tests"; - } + }, ) => { resultingList[item.group]?.push(item); - } + }, ); return resultingList; } @@ -542,7 +552,7 @@ async function detectTests(pathToExistingProject: string): Promise { const { selectedDirectory } = await inquirer.prompt([ { @@ -613,7 +623,7 @@ async function getCopyCandidatesList( pathsToCopy: { contractsDirectories: string[]; cratesDirectories: string[]; - } + }, ) { const detectedPaths = { contracts: await getDirsAndFiles(projectPath, pathsToCopy.contractsDirectories), @@ -634,7 +644,7 @@ async function getGlobPaths(projectPath: string, globList: string[], isDirOnly: onlyDirectories: isDirOnly, deep: 1, objectMode: true, - } + }, ); } diff --git a/src/lib/contract.ts b/src/lib/contract.ts index 7d9d85ab..fb102450 100644 --- a/src/lib/contract.ts +++ b/src/lib/contract.ts @@ -41,6 +41,19 @@ export class Contract { return result; } + async typedContractExists(contractName: string) { + const result: { result: boolean; missingPaths: string[] } = { + result: true, + missingPaths: [], + }; + const artifactPath = path.resolve("typedContracts", `${contractName}`); + if(!(await pathExists(artifactPath))) { + result.result = false; + result.missingPaths.push(artifactPath); + } + return result; + } + async getABI(): Promise { const check = await this.artifactsExist(); if (!check.result && check.missingTypes.includes(".json")) { diff --git a/src/lib/tasks.ts b/src/lib/tasks.ts index 2705ac42..b3eb3a1f 100644 --- a/src/lib/tasks.ts +++ b/src/lib/tasks.ts @@ -6,12 +6,12 @@ import { globby } from "globby"; import handlebars from "handlebars"; import { DownloadEndedStats, DownloaderHelper } from "node-downloader-helper"; import process from "node:process"; +import semver from "semver"; import { nodeInfo } from "./nodeInfo.js"; import decompress from "decompress"; import { Spinner } from "./spinner.js"; -import { SupportedPlatforms, SupportedArch } from "../types/index.js"; +import { SupportedPlatforms, SupportedArch, TestType } from "../types/index.js"; import { ConfigError, NetworkError, ProcessError } from "./errors.js"; -import semver from "semver"; import { commandStdoutOrNull } from "./command-utils.js"; export async function checkCliDependencies(spinner: Spinner) { @@ -55,10 +55,41 @@ export async function copyContractTemplateFiles( path.resolve(contractTemplatePath, "contract"), path.resolve(projectPath, "contracts", contractName) ); - await copy( - path.resolve(contractTemplatePath, "test"), - path.resolve(projectPath, "tests", contractName) - ); +} + +export async function prepareTestFiles( + testType: TestType, + templatePath: string, + projectPath: string, + templateName?: string, + contractName?: string +) { + switch (testType) { + case "e2e": { + await copy( + path.resolve(templatePath, "test_helpers"), + path.resolve(projectPath, "tests", "test_helpers") + ); + break; + } + case "mocha": { + if (!templateName) { + throw new ProcessError("'templateName' argument is required for mocha tests"); + } + if (!contractName) { + throw new ProcessError("'contractName' argument is required for mocha tests"); + } + await copy( + path.resolve(templatePath, "contracts", templateName, "test"), + path.resolve(projectPath, "tests", contractName) + ); + break; + } + default: { + // This case will make the switch exhaustive + throw new ProcessError("Unhandled test type"); + } + } } export async function processTemplates(projectPath: string, templateData: Record) { diff --git a/src/templates/contracts/flipper/contract/Cargo.toml.hbs b/src/templates/contracts/flipper/contract/Cargo.toml.hbs index de90dba7..2de5449d 100644 --- a/src/templates/contracts/flipper/contract/Cargo.toml.hbs +++ b/src/templates/contracts/flipper/contract/Cargo.toml.hbs @@ -10,6 +10,9 @@ ink = { version = "4.2.1", default-features = false } scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true } +[dev-dependencies] +ink_e2e = "4.2.1" + [lib] name = "{{contract_name_snake}}" path = "src/lib.rs" @@ -22,3 +25,4 @@ std = [ "scale-info/std", ] ink-as-dependency = [] +e2e-tests = [] diff --git a/src/templates/contracts/flipper/contract/src/lib.rs.hbs b/src/templates/contracts/flipper/contract/src/lib.rs.hbs index a5137e64..0681c362 100644 --- a/src/templates/contracts/flipper/contract/src/lib.rs.hbs +++ b/src/templates/contracts/flipper/contract/src/lib.rs.hbs @@ -57,17 +57,90 @@ mod {{contract_name_snake}} { /// We test if the default constructor does its job. #[ink::test] fn default_works() { - let {{contract_name_snake}} = {{contract_name_pascal}}::default(); - assert_eq!({{contract_name_snake}}.get(), false); + let flipper = {{contract_name_pascal}}::default(); + assert_eq!(flipper.get(), false); } /// We test a simple use case of our contract. #[ink::test] fn it_works() { - let mut {{contract_name_snake}} = {{contract_name_pascal}}::new(false); - assert_eq!({{contract_name_snake}}.get(), false); - {{contract_name_snake}}.flip(); - assert_eq!({{contract_name_snake}}.get(), true); + let mut flipper = {{contract_name_pascal}}::new(false); + assert_eq!(flipper.get(), false); + flipper.flip(); + assert_eq!(flipper.get(), true); + } + } + + + /// This is how you'd write end-to-end (E2E) or integration tests for ink! contracts. + /// + /// When running these you need to make sure that you: + /// - Compile the tests with the `e2e-tests` feature flag enabled (`--features e2e-tests`) + /// - Are running a Substrate node which contains `pallet-contracts` in the background + #[cfg(all(test, feature = "e2e-tests"))] + mod e2e_tests { + /// Imports all the definitions from the outer scope so we can use them here. + use super::*; + + /// A helper function used for calling contract messages. + use ink_e2e::build_message; + + /// The End-to-End test `Result` type. + type E2EResult = std::result::Result>; + + /// We test that we can upload and instantiate the contract using its default constructor. + #[ink_e2e::test] + async fn default_works(mut client: ink_e2e::Client) -> E2EResult<()> { + // Given + let constructor = {{contract_name_pascal}}Ref::default(); + + // When + let contract_account_id = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + // Then + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::alice(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), false)); + + Ok(()) + } + + /// We test that we can read and write a value from the on-chain contract contract. + #[ink_e2e::test] + async fn it_works(mut client: ink_e2e::Client) -> E2EResult<()> { + // Given + let constructor = {{contract_name_pascal}}Ref::new(false); + let contract_account_id = client + .instantiate("{{contract_name_snake}}", &ink_e2e::bob(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::bob(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), false)); + + // When + let flip = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.flip()); + let _flip_result = client + .call(&ink_e2e::bob(), flip, 0, None) + .await + .expect("flip failed"); + + // Then + let get = build_message::<{{contract_name_pascal}}Ref>(contract_account_id.clone()) + .call(|flipper| flipper.get()); + let get_result = client.call_dry_run(&ink_e2e::bob(), &get, 0, None).await; + assert!(matches!(get_result.return_value(), true)); + + Ok(()) } } } diff --git a/src/templates/contracts/psp22/contract/Cargo.toml.hbs b/src/templates/contracts/psp22/contract/Cargo.toml.hbs index a2c880f3..0cf1e1d6 100644 --- a/src/templates/contracts/psp22/contract/Cargo.toml.hbs +++ b/src/templates/contracts/psp22/contract/Cargo.toml.hbs @@ -10,7 +10,11 @@ ink = { version = "4.2.1", default-features = false} scale = { package = "parity-scale-codec", version = "3", default-features = false, features = ["derive"] } scale-info = { version = "2.6", default-features = false, features = ["derive"], optional = true } -openbrush = { git = "https://github.com/Brushfam/openbrush-contracts", tag = "4.0.0-beta", default-features = false, features = ["psp22"] } +openbrush = { git = "https://github.com/Brushfam/openbrush-contracts", tag = "4.0.0", default-features = false, features = ["psp22"] } + +[dev-dependencies] +ink_e2e = "4.2.1" +test_helpers = { path = "../../tests/test_helpers", default-features = false } [lib] name = "{{contract_name_snake}}" @@ -25,6 +29,7 @@ std = [ "openbrush/std", ] ink-as-dependency = [] +e2e-tests = [] [profile.dev] codegen-units = 16 diff --git a/src/templates/contracts/psp22/contract/src/lib.rs.hbs b/src/templates/contracts/psp22/contract/src/lib.rs.hbs index 4b85fb96..88b50918 100644 --- a/src/templates/contracts/psp22/contract/src/lib.rs.hbs +++ b/src/templates/contracts/psp22/contract/src/lib.rs.hbs @@ -69,4 +69,89 @@ pub mod {{contract_name_snake}} { PSP22::total_supply(self) } } + + #[cfg(all(test, feature = "e2e-tests"))] + pub mod tests { + use super::*; + use ink_e2e::{ + build_message, + }; + use openbrush::contracts::psp22::psp22_external::PSP22; + use test_helpers::{ + address_of, + balance_of, + }; + + type E2EResult = Result>; + + #[ink_e2e::test] + async fn assigns_initial_balance(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.balance_of(address_of!(Alice))); + client.call_dry_run(&ink_e2e::alice(), &_msg, 0, None).await + }; + + assert!(matches!(result.return_value(), 100)); + + Ok(()) + } + + #[ink_e2e::test] + async fn transfer_adds_amount_to_destination_account(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.transfer(address_of!(Bob), 50, vec![])); + client + .call(&ink_e2e::alice(), _msg, 0, None) + .await + .expect("transfer failed") + }; + + assert!(matches!(result.return_value(), Ok(()))); + + let balance_of_alice = balance_of!({{contract_name_pascal}}Ref, client, address, Alice); + + let balance_of_bob = balance_of!({{contract_name_pascal}}Ref, client, address, Bob); + + assert_eq!(balance_of_bob, 50, "Bob should have 50 tokens"); + assert_eq!(balance_of_alice, 50, "Alice should have 50 tokens"); + + Ok(()) + } + + #[ink_e2e::test] + async fn cannot_transfer_above_the_amount(mut client: ink_e2e::Client) -> E2EResult<()> { + let constructor = {{contract_name_pascal}}Ref::new(100); + let address = client + .instantiate("{{contract_name_snake}}", &ink_e2e::alice(), constructor, 0, None) + .await + .expect("instantiate failed") + .account_id; + + let result = { + let _msg = build_message::<{{contract_name_pascal}}Ref>(address.clone()) + .call(|contract| contract.transfer(address_of!(Bob), 101, vec![])); + client.call_dry_run(&ink_e2e::alice(), &_msg, 0, None).await + }; + + assert!(matches!(result.return_value(), Err(PSP22Error::InsufficientBalance))); + + Ok(()) + } + } } diff --git a/src/templates/contracts/psp22/test/index.test.ts.hbs b/src/templates/contracts/psp22/test/index.test.ts.hbs index 5e018a64..c17776a3 100644 --- a/src/templates/contracts/psp22/test/index.test.ts.hbs +++ b/src/templates/contracts/psp22/test/index.test.ts.hbs @@ -79,47 +79,4 @@ describe("{{contract_name}} test", () => { }) ).to.eventually.be.rejected; }); - - it("Can not transfer to hated account", async () => { - const hated_account = wallet2; - const transferredAmount = 10; - const { gasRequired } = await contract - .withSigner(deployer) - .query.transfer(wallet1.address, transferredAmount, []); - // Check that we can transfer money while account is not hated - await expect( - contract.tx.transfer(hated_account.address, 10, [], { - gasLimit: gasRequired, - }) - ).to.eventually.be.fulfilled; - - const result = await contract.query.balanceOf(hated_account.address); - expect(result.value.ok?.toNumber()).to.equal(transferredAmount); - - expect((await contract.query.getHatedAccount()).value.ok).to.equal( - EMPTY_ADDRESS - ); - - // Hate account - await expect( - contract.tx.setHatedAccount(hated_account.address, { - gasLimit: gasRequired, - }) - ).to.eventually.be.ok; - expect((await contract.query.getHatedAccount()).value.ok).to.equal( - hated_account.address - ); - - // Transfer must fail - expect( - contract.tx.transfer(hated_account.address, 10, [], { - gasLimit: gasRequired, - }) - ).to.eventually.be.rejected; - - // Amount of tokens must be the same - expect( - (await contract.query.balanceOf(hated_account.address)).value.ok?.toNumber() - ).to.equal(10); - }); -}); +}); \ No newline at end of file diff --git a/src/templates/test_helpers/Cargo.toml.hbs b/src/templates/test_helpers/Cargo.toml.hbs new file mode 100644 index 00000000..e0724a21 --- /dev/null +++ b/src/templates/test_helpers/Cargo.toml.hbs @@ -0,0 +1,12 @@ +[package] +name = "test_helpers" +version= "0.1.0" +authors = ["{{author_name}}"] +edition = "2021" + +[lib] +name = "test_helpers" +path = "lib.rs" + +[profile.dev] +codegen-units = 16 \ No newline at end of file diff --git a/src/templates/test_helpers/lib.rs.hbs b/src/templates/test_helpers/lib.rs.hbs new file mode 100644 index 00000000..b45d2688 --- /dev/null +++ b/src/templates/test_helpers/lib.rs.hbs @@ -0,0 +1,18 @@ +#[macro_export] +macro_rules! address_of { + ($account:ident) => { + ::ink_e2e::account_id(::ink_e2e::AccountKeyring::$account) + }; +} + +#[macro_export] +macro_rules! balance_of { + ($contract_ref:ident, $client:ident, $address:ident, $account:ident) => \{{ + let _msg = + build_message::<$contract_ref>($address.clone()).call(|contract| contract.balance_of(address_of!($account))); + $client + .call_dry_run(&::ink_e2e::alice(), &_msg, 0, None) + .await + .return_value() + }}; +} \ No newline at end of file diff --git a/src/types/index.ts b/src/types/index.ts index 542a1d7f..aa574dda 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -53,3 +53,5 @@ export interface SwankyConfig { export type SupportedPlatforms = "darwin" | "linux"; export type SupportedArch = "arm64" | "x64"; + +export type TestType = "e2e" | "mocha";