diff --git a/docs/src/content/docs/book/compile.mdx b/docs/src/content/docs/book/compile.mdx index 64645844dc..3603da1c75 100644 --- a/docs/src/content/docs/book/compile.mdx +++ b/docs/src/content/docs/book/compile.mdx @@ -417,6 +417,45 @@ export class Playground implements Contract { ::: +### Test generation, `.stub.tests.ts` {#test-stubs} + +

+ +By default, Tact automatically generates test stubs for each compiled contract. These files provide a basic testing structure using TypeScript and the TON Blockchain sandbox, serving as a starting point for comprehensive contract testing. + +#### Generated file structure {#test-file-structure} + +Test stubs are saved to the `tests/` subdirectory inside the output folder specified in the [configuration](/book/config#projects-output). Each contract gets its test file named as `{project_name}_{contract_name}.stub.tests.ts`. + +For example: `build/tests/MyProject_Counter.stub.tests.ts`, where `build/` is the output folder. + +#### Test content {#test-stub-content} + +Generated test stubs include basic blockchain sandbox setup, contract deployment tests, and examples using the [TypeScript wrappers](#wrap-ts). They provide a foundation for testing contract functionality with proper imports and configuration. + +#### Configuration {#test-generation-config} + +Disable automatic test generation by adding the `skipTestGeneration` option to your [project configuration](/book/config): + +```json title="tact.config.json" +{ + "projects": [ + { + "name": "MyProject", + "path": "./contracts/contract.tact", + "output": "./build", + "options": { + "skipTestGeneration": true + } + } + ] +} +``` + +#### Using test stubs {#test-stub-usage} + +Read more in the dedicated section: [Using generated test stubs](/book/debug#tests-using-stubs). + [struct]: /book/structs-and-messages#structs [message]: /book/structs-and-messages#messages diff --git a/docs/src/content/docs/book/config.mdx b/docs/src/content/docs/book/config.mdx index e7cf472b3d..d9362e5907 100644 --- a/docs/src/content/docs/book/config.mdx +++ b/docs/src/content/docs/book/config.mdx @@ -498,6 +498,41 @@ If set to `true{:json}`, enables the generation of the `lazy_deployment_complete } ``` +#### `skipTestGeneration` {#options-skiptestgeneration} + +

+ +`false{:json}` by default. + +If set to `true{:json}`, disables automatic generation of test files. By default, Tact generates test stubs for all contracts in the `tests/` subdirectory of the output folder. + +```json title="tact.config.json" {8,14} +{ + "projects": [ + { + "name": "contract", + "path": "./contract.tact", + "output": "./output", + "options": { + "skipTestGeneration": true + } + }, + { + "name": "ContractUnderBlueprint", + "options": { + "skipTestGeneration": true + } + } + ] +} +``` + +:::note + + Read more about test generation: [Test generation](/book/compile#test-stubs). + +::: + ### `verbose` {#projects-verbose}

@@ -595,7 +630,8 @@ In [Blueprint][bp], `mode` is always set to `"full"{:json}` and cannot be overri "alwaysSaveContractData": true, "internalExternalReceiversOutsideMethodsMap": true }, - "enableLazyDeploymentCompletedGetter": true + "enableLazyDeploymentCompletedGetter": true, + "skipTestGeneration": false } } ] diff --git a/docs/src/content/docs/book/debug.mdx b/docs/src/content/docs/book/debug.mdx index 0501e42ec4..8be43b63ab 100644 --- a/docs/src/content/docs/book/debug.mdx +++ b/docs/src/content/docs/book/debug.mdx @@ -131,6 +131,30 @@ Whenever you create a new [Blueprint][bp] project or use the `blueprint create` Those files are placed in the `tests/` folder and executed with [Jest][jest]. By default, all tests run unless you specify a specific group or test closure. For other options, refer to the brief documentation in the Jest CLI: `jest --help`. +### Using generated test stubs {#tests-using-stubs} + +

+ +Tact automatically generates test stubs for each compiled contract during the [compilation process](/book/compile#test-stubs). These generated test files serve as excellent starting points for writing comprehensive tests. + +To use the generated test stubs: + +1. Copy them from the `tests/` subdirectory inside the output directory specified in your [`tact.config.json`](/book/config#projects-output). + +2. Customize them according to the specific needs of your contract. The generated stubs include: + + * Basic [Sandbox][sb] setup + * Contract deployment tests + * Example use of [TypeScript wrappers](#tests-wrappers) + +3. Extend with additional test cases. The generated tests are intended as a starting point, not a complete test suite. + +:::caution + + Since generated test files are overwritten on each compilation, always copy them to a separate location **before customizing**. This ensures your test modifications are preserved. + +::: + ### Structure of test files {#tests-structure} Let's say we have a contract named `Playground`, written in the `contracts/playground.tact` file. If we've created that contract through [Blueprint][bp], it also created a `tests/Playground.spec.ts` test suite file for us. diff --git a/package.json b/package.json index 253d2808b8..8e6eb2c4bb 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,8 @@ ], "scripts": { "compare": "ts-node src/logs/compare-logs.infra.ts", - "build:fast": "yarn clean && yarn gen:grammar && yarn gen:stdlib && yarn gen:func-js && tsc --project tsconfig.fast.json && yarn copy:stdlib && yarn copy:func && yarn to-relative", - "build": "cross-env NODE_OPTIONS=--max_old_space_size=5120 tsc && yarn copy:stdlib && yarn copy:func && yarn to-relative", + "build:fast": "yarn clean && yarn gen:grammar && yarn gen:stdlib && yarn gen:func-js && tsc --project tsconfig.fast.json && yarn copy:stdlib && yarn copy:func && yarn copy:templates && yarn to-relative", + "build": "cross-env NODE_OPTIONS=--max_old_space_size=5120 tsc && yarn copy:stdlib && yarn copy:func && yarn copy:templates && yarn to-relative", "gen:config": "ts-to-zod -k --skipValidation src/config/config.ts src/config/config.zod.ts", "gen:grammar": "pgen src/grammar/grammar.peggy -o src/grammar/grammar.ts", "gen:stdlib": "ts-node src/stdlib/stdlib.build.ts", @@ -39,6 +39,7 @@ "cleanall": "rimraf dist node_modules", "copy:stdlib": "ts-node src/stdlib/copy.build.ts", "copy:func": "ts-node src/func/copy.build.ts", + "copy:templates": "ts-node src/bindings/copy.build.ts", "to-absolute": "ts-node src/to-absolute.build.ts", "to-relative": "ts-node src/to-relative.build.ts", "test": "jest", @@ -98,6 +99,7 @@ "blockstore-core": "1.0.5", "glob": "^8.1.0", "ipfs-unixfs-importer": "9.0.10", + "mustache": "^4.2.0", "path-normalize": "^6.0.13", "yaml": "^2.7.1", "zod": "^3.22.4" @@ -118,6 +120,7 @@ "@types/diff": "^7.0.0", "@types/glob": "^8.1.0", "@types/jest": "^29.5.12", + "@types/mustache": "^4.2.6", "@types/node": "^22.5.0", "@typescript-eslint/eslint-plugin": "^8.21.0", "@typescript-eslint/parser": "^8.21.0", diff --git a/src/bindings/copy.build.ts b/src/bindings/copy.build.ts new file mode 100644 index 0000000000..0364c1f9c2 --- /dev/null +++ b/src/bindings/copy.build.ts @@ -0,0 +1,30 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import * as glob from "glob"; + +const cp = async (fromGlob: string, toPath: string) => { + for (const file of glob.sync(path.join(fromGlob, "**/*"), { + windowsPathsNoEscape: true, + })) { + const relPath = path.relative(fromGlob, file); + const pathTo = path.join(toPath, relPath); + const stat = await fs.stat(file); + if (stat.isDirectory()) { + await fs.mkdir(pathTo, { recursive: true }); + } else { + await fs.mkdir(path.dirname(pathTo), { recursive: true }); + await fs.copyFile(file, pathTo); + } + } +}; + +const main = async () => { + try { + await cp("./src/bindings/templates/", "./dist/bindings/templates/"); + } catch (e) { + console.error(e); + process.exit(1); + } +}; + +void main(); diff --git a/src/bindings/templates/test.mustache b/src/bindings/templates/test.mustache new file mode 100644 index 0000000000..91ca5665b4 --- /dev/null +++ b/src/bindings/templates/test.mustache @@ -0,0 +1,48 @@ +// !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!! +// https://docs.tact-lang.org/book/debug/ + +{{#imports}} +import {{{.}}}; +{{/imports}} + +export const test{{contractName}} = () => { + describe("{{contractName}} Contract", () => { + // Test receivers +{{#receivers}} + test{{.}}(); +{{/receivers}} + // Test getters +{{#getters}} + getterTest{{.}}(); +{{/getters}} + }); +}; + +const globalSetup = async () => { + const blockchain = await Blockchain.create(); + // @ts-ignore + const contract = await blockchain.openContract(await {{contractName}}.fromInit( + // TODO: implement default values + )); + + // Universal method for deploy contract without sending message + await blockchain.setShardAccount(contract.address, createShardAccount({ + address: contract.address, + code: contract.init!.code, + data: contract.init!.data, + balance: 0n, + workchain: 0 + })); + + const owner = await blockchain.treasury("owner"); + const notOwner = await blockchain.treasury("notOwner"); + + return { blockchain, contract, owner, notOwner }; +}; + +{{#receiverBlocks}} +{{{.}}} +{{/receiverBlocks}} +{{#getterBlocks}} +{{{.}}} +{{/getterBlocks}} \ No newline at end of file diff --git a/src/bindings/writeTests.ts b/src/bindings/writeTests.ts new file mode 100644 index 0000000000..b9a52f71d3 --- /dev/null +++ b/src/bindings/writeTests.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; +import Mustache from "mustache"; +import type { ABIArgument, ContractABI, ABIReceiver } from "@ton/core"; +import type { WrappersConstantDescription } from "@/bindings/writeTypescript"; +import type { CompilerContext } from "@/context/context"; +import type { TypeDescription } from "@/types/types"; + +function getReceiverFunctionName(receiver: ABIReceiver): string { + const receiverType = receiver.receiver; // 'internal' or 'external' + const messageKind = receiver.message.kind; // 'empty', 'typed', 'text', 'any' + + let name = receiverType.charAt(0).toUpperCase() + receiverType.slice(1); // Internal or External + + switch (messageKind) { + case "empty": + name += "Empty"; + break; + case "typed": + name += "Message"; + name += receiver.message.type ?? "Typed"; + break; + case "text": + name += "Text"; + if (receiver.message.text) { + const cleanText = receiver.message.text.replace( + /[^a-zA-Z0-9]/g, + "", + ); + name += cleanText.charAt(0).toUpperCase() + cleanText.slice(1); + } + break; + case "any": + name += "Any"; + break; + default: + name += "Unknown"; + } + + return name; +} + +export function writeTests( + abi: ContractABI, + _ctx: CompilerContext, + _constants: readonly WrappersConstantDescription[], + _contract: undefined | TypeDescription, + generatedContractPath: string, + _init?: { + code: string; + system: string | null; + args: ABIArgument[]; + prefix?: + | { + value: number; + bits: number; + } + | undefined; + }, +) { + const contractName = abi.name ?? "Contract"; + + const templateData = { + contractName, + imports: [ + `{ ${contractName} } from '../${generatedContractPath}'`, + '{ Blockchain, createShardAccount } from "@ton/sandbox"', + ], + receivers: abi.receivers?.map(getReceiverFunctionName) ?? [], + getters: abi.getters?.map((g) => g.name) ?? [], + receiverBlocks: (abi.receivers ?? []).map((r) => { + const fn = getReceiverFunctionName(r); + return `const test${fn} = async () => { + describe("${fn}", () => { + const setup = async () => { + return await globalSetup(); + }; + + // !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!! + // TODO: You can write tests for ${fn} here + + it("should perform correctly", async () => { + const { blockchain, contract, owner, notOwner } = await setup(); + }); + }); +}; +`; + }), + getterBlocks: (abi.getters ?? []).map((g) => { + const fn = g.name; + return `const getterTest${fn} = async () => { + describe("${fn}", () => { + const setup = async () => { + return await globalSetup(); + }; + + // !!THIS FILE IS GENERATED BY TACT. THIS FILE IS REGENERATED EVERY TIME, COPY IT TO YOUR PROJECT MANUALLY!! + // TODO: You can write tests for ${fn} here + + it("should perform correctly", async () => { + const { blockchain, contract, owner, notOwner } = await setup(); + }); + }); +}; +`; + }), + }; + + const templatePath = resolve(__dirname, "templates", "test.mustache"); + const template = readFileSync(templatePath, "utf-8"); + const rendered = Mustache.render(template, templateData); + + return rendered; +} diff --git a/src/config/config.ts b/src/config/config.ts index d474f24e40..9f94ffd4a9 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -77,6 +77,10 @@ export type Options = { * Does nothing if contract parameters are declared. */ readonly enableLazyDeploymentCompletedGetter?: boolean; + /** + * If set to true, disables generation of test files. + */ + readonly skipTestGeneration?: boolean; }; export type Mode = "fullWithDecompilation" | "full" | "funcOnly" | "checkOnly"; diff --git a/src/config/config.zod.ts b/src/config/config.zod.ts index 231098216a..4a76f5c3c3 100644 --- a/src/config/config.zod.ts +++ b/src/config/config.zod.ts @@ -68,6 +68,10 @@ export const optionsSchema: z.ZodType = z.object({ * Does nothing if contract parameters are declared. */ enableLazyDeploymentCompletedGetter: z.boolean().optional(), + /** + * If set to true, disables generation of test files. + */ + skipTestGeneration: z.boolean().optional(), }); export const modeSchema: z.ZodType = z.union([ diff --git a/src/config/configSchema.json b/src/config/configSchema.json index 1dc1578e73..b76aafd271 100644 --- a/src/config/configSchema.json +++ b/src/config/configSchema.json @@ -93,6 +93,11 @@ "type": "boolean", "default": false, "description": "False by default. If set to true, enables generation of `lazy_deployment_completed()` getter. Does nothing if contract parameters are declared." + }, + "skipTestGeneration": { + "type": "boolean", + "default": false, + "description": "False by default. If set to true, disables generation of test files." } } }, diff --git a/src/pipeline/build.ts b/src/pipeline/build.ts index 47065baf3e..92e808f122 100644 --- a/src/pipeline/build.ts +++ b/src/pipeline/build.ts @@ -14,6 +14,7 @@ import type { TypeDescription } from "@/types/types"; import { doPackaging } from "@/pipeline/packaging"; import { doBindings } from "@/pipeline/bindings"; import { doReports } from "@/pipeline/reports"; +import { doTests } from "@/pipeline/tests"; import { createVirtualFileSystem } from "@/vfs/createVirtualFileSystem"; import * as Stdlib from "@/stdlib/stdlib"; @@ -144,6 +145,13 @@ export async function build(args: { return BuildFail(bCtx.errorMessages); } + if (!bCtx.config.options?.skipTestGeneration) { + const testsRes = doTests(bCtx, packages); + if (!testsRes) { + return BuildFail(bCtx.errorMessages); + } + } + const reportsRes = doReports(bCtx, packages); if (!reportsRes) { return BuildFail(bCtx.errorMessages); diff --git a/src/pipeline/tests.ts b/src/pipeline/tests.ts new file mode 100644 index 0000000000..9c045c3f36 --- /dev/null +++ b/src/pipeline/tests.ts @@ -0,0 +1,50 @@ +import { writeTests } from "@/bindings/writeTests"; +import type { BuildContext } from "@/pipeline/build"; +import type { Packages } from "@/pipeline/packaging"; + +export function doTests(bCtx: BuildContext, packages: Packages): boolean { + const { project, config, logger } = bCtx; + + logger.info(" > Tests"); + + for (const pkg of packages) { + logger.info(` > ${pkg.name}`); + + if (pkg.init.deployment.kind !== "system-cell") { + const message = ` > ${pkg.name}: unsupported deployment kind ${pkg.init.deployment.kind}`; + logger.error(message); + bCtx.errorMessages.push(new Error(message)); + return false; + } + + try { + const testsCode = writeTests( + JSON.parse(pkg.abi), + bCtx.ctx, + bCtx.built[pkg.name]?.constants ?? [], + bCtx.built[pkg.name]?.contract, + config.name + "_" + pkg.name, + { + code: pkg.code, + prefix: pkg.init.prefix, + system: pkg.init.deployment.system, + args: pkg.init.args, + }, + ); + const testsPath = project.resolve( + config.output, + "tests", + config.name + "_" + pkg.name + ".stub.tests.ts", + ); + project.writeFile(testsPath, testsCode); + } catch (e) { + const error = e as Error; + error.message = `Tests generator crashed: ${error.message}`; + logger.error(error); + bCtx.errorMessages.push(error); + return false; + } + } + + return true; +} diff --git a/yarn.lock b/yarn.lock index 22de928e53..21c47b03c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1565,6 +1565,11 @@ resolved "https://npm.dev-internal.org/@types/minimatch/-/minimatch-5.1.2.tgz" integrity sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA== +"@types/mustache@^4.2.6": + version "4.2.6" + resolved "https://npm.dev-internal.org/@types/mustache/-/mustache-4.2.6.tgz#9d4f903f4ad373699b253aa1369727bc5042811f" + integrity sha512-t+8/QWTAhOFlrF1IVZqKnMRJi84EgkIK5Kh0p2JV4OLywUvCwJPFxbJAl7XAow7DVIHsF+xW9f1MVzg0L6Szjw== + "@types/node@*", "@types/node@>=13.7.0", "@types/node@^22.5.0": version "22.15.29" resolved "https://npm.dev-internal.org/@types/node/-/node-22.15.29.tgz#c75999124a8224a3f79dd8b6ccfb37d74098f678" @@ -4638,6 +4643,11 @@ murmurhash3js-revisited@^3.0.0: resolved "https://npm.dev-internal.org/murmurhash3js-revisited/-/murmurhash3js-revisited-3.0.0.tgz" integrity sha512-/sF3ee6zvScXMb1XFJ8gDsSnY+X8PbOyjIuBhtgis10W2Jx4ZjIhikUCIF9c4gpJxVnQIsPAFrSwTCuAjicP6g== +mustache@^4.2.0: + version "4.2.0" + resolved "https://npm.dev-internal.org/mustache/-/mustache-4.2.0.tgz#e5892324d60a12ec9c2a73359edca52972bf6f64" + integrity sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ== + mute-stream@0.0.8: version "0.0.8" resolved "https://npm.dev-internal.org/mute-stream/-/mute-stream-0.0.8.tgz#1630c42b2251ff81e2a283de96a5497ea92e5e0d"