diff --git a/.github/workflows/js-sdk.yaml b/.github/workflows/js-sdk.yaml new file mode 100644 index 000000000..be17c9a30 --- /dev/null +++ b/.github/workflows/js-sdk.yaml @@ -0,0 +1,33 @@ +name: JavaScript SDK + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + test: + runs-on: namespace-profile-standard-ubuntu22-amd64 + steps: + - name: clone code + uses: actions/checkout@v4 + with: + submodules: recursive + - name: install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: nightly-2023-11-16 + override: true + - uses: oven-sh/setup-bun@v1 + with: + bun-version: latest + - name: install deps + working-directory: ./js/node-api + run: bun install + - name: build Node API + working-directory: ./js/node-api + run: bun run build + - name: test Node API + working-directory: ./js/node-api + run: bun test diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml index de79f9354..aff03d5d7 100644 --- a/.github/workflows/python-tests.yml +++ b/.github/workflows/python-tests.yml @@ -12,7 +12,7 @@ on: jobs: test: - runs-on: "nscloud-ubuntu-22.04-amd64-4x16" + runs-on: namespace-profile-standard-ubuntu22-amd64 strategy: matrix: python-version: ["3.10"] diff --git a/Cargo.lock b/Cargo.lock index c52c8c5a4..a1a56dfe2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1433,6 +1433,19 @@ dependencies = [ "regex-syntax 0.8.2", ] +[[package]] +name = "grit-node-api" +version = "0.0.0" +dependencies = [ + "anyhow", + "marzano-core", + "marzano-util", + "napi", + "napi-build", + "napi-derive", + "serde_json", +] + [[package]] name = "grit-pattern-matcher" version = "0.3.0" @@ -2595,6 +2608,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "napi-build" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c0f5d67ee408a4685b61f5ab7e58605c8ae3f2b4189f0127d804ff13d5560a" + [[package]] name = "napi-derive" version = "2.16.3" diff --git a/Cargo.toml b/Cargo.toml index 8810ff029..6e3fb731d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ members = [ "crates/wasm-bindings", "crates/marzano_messenger", "crates/cli_bin", + "js/node-api", ] exclude = [ "resources", diff --git a/js/.gitignore b/js/.gitignore new file mode 100644 index 000000000..1f68e8c58 --- /dev/null +++ b/js/.gitignore @@ -0,0 +1,182 @@ +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +### macOS ### +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two +Icon + + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +### macOS Patch ### +# iCloud generated files +*.icloud + +### Windows ### +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +#Added by cargo + +/target +Cargo.lock + +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +*.node diff --git a/js/node-api/Cargo.toml b/js/node-api/Cargo.toml new file mode 100644 index 000000000..7bdc67ad8 --- /dev/null +++ b/js/node-api/Cargo.toml @@ -0,0 +1,28 @@ +[package] +edition = "2021" +name = "grit-node-api" +version = "0.0.0" + +[lib] +crate-type = ["cdylib"] + +[dependencies] +# Default enable napi4 feature, see https://nodejs.org/api/n-api.html#node-api-version-matrix +napi = { version = "2.16.4", default-features = false, features = [ + "napi8", + "async", +] } +napi-derive = "2.12.2" +anyhow = "1.0.70" +serde_json = "1.0.113" + +marzano-util = { path = "../../crates/util", features = ["napi"] } +marzano-core = { path = "../../crates/core", features = [ + "network_requests", + "non_wasm", + "napi", + "language-parsers", +], default-features = false } + +[build-dependencies] +napi-build = "2.0.1" diff --git a/js/node-api/__generated__/index.d.ts b/js/node-api/__generated__/index.d.ts new file mode 100644 index 000000000..23c4fe1ad --- /dev/null +++ b/js/node-api/__generated__/index.d.ts @@ -0,0 +1,68 @@ +/* tslint:disable */ +/* eslint-disable */ + +/* auto-generated by NAPI-RS */ + +export interface Position { + /** 1-based line number in the source file. */ + line: number + /** 1-based column number in the source file. */ + column: number +} +export interface RangeWithoutByte { + start: Position + end: Position +} +export interface RangePair { + before: RangeWithoutByte + after: RangeWithoutByte +} +export interface FileDiff { + oldPath?: string + newPath?: string + ranges: Array +} +export interface RichFile { + path: string + content: string +} +export function sum(a: number, b: number): number +/** We need this to make sure the builder is actually exposed to JS */ +export function debugBuilder(builder: UncompiledPatternBuilder): string +export class UncompiledPatternBuilder { + static new_snippet(text: string): UncompiledPatternBuilder + /** Filter this pattern to only match instances that contain the other pattern */ + contains(other: UncompiledPatternBuilder): UncompiledPatternBuilder + /** Filter the pattern to only match instances that match a provided callback */ + filter(callback: (arg: ResultBinding) => any): UncompiledPatternBuilder + /** Run the pattern on a list of files and return the number of matching files found */ + runOnFiles(files: Array): Promise + /** + * Apply the query to a single file and return the modified file (if any) + * @param file The file to apply the query to + * @returns The modified file (if it was modified) + */ + runOnFile(file: RichFile): Promise +} +/** + * Resolved bindings provide hooks for JavaScript code to interact with a bit of code that has been matched by a pattern. + * The binding corresponds to a specific piece of text, which can be further filtered against or modified inside a JavaScript callback. + */ +export class ResultBinding { + /** Retrieves the stringified representation of the binding (ie. the actual source code) */ + text(): string + /** If the binding was found in a source file, return the position of the binding. */ + range(): RangeWithoutByte | null + /** Retrieves the absolute file name of the file containing the current binding. */ + filename(): string + /** + * Inserts the provided text after the binding. + * The GritQL engine handles the insertion of the text in transformed output code. + */ + append(text: string): void + /** + * Retrieve a variable's text from the current scope. + * @hidden This API is not stable yet. + */ + findVarText(name: string): string | null +} diff --git a/js/node-api/__generated__/index.js b/js/node-api/__generated__/index.js new file mode 100644 index 000000000..229b9e72b --- /dev/null +++ b/js/node-api/__generated__/index.js @@ -0,0 +1,318 @@ +/* tslint:disable */ +/* eslint-disable */ +/* prettier-ignore */ + +/* auto-generated by NAPI-RS */ + +const { existsSync, readFileSync } = require('fs') +const { join } = require('path') + +const { platform, arch } = process + +let nativeBinding = null +let localFileExisted = false +let loadError = null + +function isMusl() { + // For Node 10 + if (!process.report || typeof process.report.getReport !== 'function') { + try { + const lddPath = require('child_process').execSync('which ldd').toString().trim() + return readFileSync(lddPath, 'utf8').includes('musl') + } catch (e) { + return true + } + } else { + const { glibcVersionRuntime } = process.report.getReport().header + return !glibcVersionRuntime + } +} + +switch (platform) { + case 'android': + switch (arch) { + case 'arm64': + localFileExisted = existsSync(join(__dirname, 'grit-node-api.android-arm64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.android-arm64.node') + } else { + nativeBinding = require('@getgrit/node-api-android-arm64') + } + } catch (e) { + loadError = e + } + break + case 'arm': + localFileExisted = existsSync(join(__dirname, 'grit-node-api.android-arm-eabi.node')) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.android-arm-eabi.node') + } else { + nativeBinding = require('@getgrit/node-api-android-arm-eabi') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Android ${arch}`) + } + break + case 'win32': + switch (arch) { + case 'x64': + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.win32-x64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.win32-x64-msvc.node') + } else { + nativeBinding = require('@getgrit/node-api-win32-x64-msvc') + } + } catch (e) { + loadError = e + } + break + case 'ia32': + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.win32-ia32-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.win32-ia32-msvc.node') + } else { + nativeBinding = require('@getgrit/node-api-win32-ia32-msvc') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.win32-arm64-msvc.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.win32-arm64-msvc.node') + } else { + nativeBinding = require('@getgrit/node-api-win32-arm64-msvc') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Windows: ${arch}`) + } + break + case 'darwin': + localFileExisted = existsSync(join(__dirname, 'grit-node-api.darwin-universal.node')) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.darwin-universal.node') + } else { + nativeBinding = require('@getgrit/node-api-darwin-universal') + } + break + } catch {} + switch (arch) { + case 'x64': + localFileExisted = existsSync(join(__dirname, 'grit-node-api.darwin-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.darwin-x64.node') + } else { + nativeBinding = require('@getgrit/node-api-darwin-x64') + } + } catch (e) { + loadError = e + } + break + case 'arm64': + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.darwin-arm64.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.darwin-arm64.node') + } else { + nativeBinding = require('@getgrit/node-api-darwin-arm64') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on macOS: ${arch}`) + } + break + case 'freebsd': + if (arch !== 'x64') { + throw new Error(`Unsupported architecture on FreeBSD: ${arch}`) + } + localFileExisted = existsSync(join(__dirname, 'grit-node-api.freebsd-x64.node')) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.freebsd-x64.node') + } else { + nativeBinding = require('@getgrit/node-api-freebsd-x64') + } + } catch (e) { + loadError = e + } + break + case 'linux': + switch (arch) { + case 'x64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-x64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-x64-musl.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-x64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-x64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-x64-gnu.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-x64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-arm64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-arm64-musl.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-arm64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-arm64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-arm64-gnu.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-arm64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 'arm': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-arm-musleabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-arm-musleabihf.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-arm-musleabihf') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-arm-gnueabihf.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-arm-gnueabihf.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-arm-gnueabihf') + } + } catch (e) { + loadError = e + } + } + break + case 'riscv64': + if (isMusl()) { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-riscv64-musl.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-riscv64-musl.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-riscv64-musl') + } + } catch (e) { + loadError = e + } + } else { + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-riscv64-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-riscv64-gnu.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-riscv64-gnu') + } + } catch (e) { + loadError = e + } + } + break + case 's390x': + localFileExisted = existsSync( + join(__dirname, 'grit-node-api.linux-s390x-gnu.node') + ) + try { + if (localFileExisted) { + nativeBinding = require('./grit-node-api.linux-s390x-gnu.node') + } else { + nativeBinding = require('@getgrit/node-api-linux-s390x-gnu') + } + } catch (e) { + loadError = e + } + break + default: + throw new Error(`Unsupported architecture on Linux: ${arch}`) + } + break + default: + throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`) +} + +if (!nativeBinding) { + if (loadError) { + throw loadError + } + throw new Error(`Failed to load native binding`) +} + +const { UncompiledPatternBuilder, ResultBinding, sum, debugBuilder } = nativeBinding + +module.exports.UncompiledPatternBuilder = UncompiledPatternBuilder +module.exports.ResultBinding = ResultBinding +module.exports.sum = sum +module.exports.debugBuilder = debugBuilder diff --git a/js/node-api/build.rs b/js/node-api/build.rs new file mode 100644 index 000000000..9fc236788 --- /dev/null +++ b/js/node-api/build.rs @@ -0,0 +1,5 @@ +extern crate napi_build; + +fn main() { + napi_build::setup(); +} diff --git a/js/node-api/package.json b/js/node-api/package.json new file mode 100644 index 000000000..f2d35bb2e --- /dev/null +++ b/js/node-api/package.json @@ -0,0 +1,22 @@ +{ + "name": "@getgrit/node-api", + "version": "0.0.0", + "main": "__generated__/index.js", + "types": "__generated__/index.d.ts", + "napi": { + "name": "grit-node-api", + "triples": {} + }, + "license": "UNLICENSED", + "devDependencies": { + "@napi-rs/cli": "^2.18.3" + }, + "engines": { + "node": ">= 10" + }, + "scripts": { + "build": "if [ -z \"$SKIP_BRIDGE\" ]; then RUSTFLAGS=\"-A warnings\" napi build --no-const-enum --platform --release --dts __generated__/index.d.ts --js __generated__/index.js && mv *.node __generated__; fi", + "test": "bun test", + "test:ci": "bun test --run" + } +} diff --git a/js/node-api/src/lib.rs b/js/node-api/src/lib.rs new file mode 100644 index 000000000..3b26bd651 --- /dev/null +++ b/js/node-api/src/lib.rs @@ -0,0 +1,23 @@ +#[macro_use] +extern crate napi_derive; + +use napi::bindgen_prelude::*; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +use anyhow::Context; + +// Exported API +pub use marzano_core::UncompiledPatternBuilder; + +#[napi] +pub fn sum(a: i32, b: i32) -> Result { + Ok(a + b) +} + +/// We need this to make sure the builder is actually exposed to JS +#[napi] +pub fn debug_builder(builder: &UncompiledPatternBuilder) -> Result { + let builder_str = format!("{:?}", builder); + Ok(builder_str) +} diff --git a/js/node-api/test/builder.spec.mts b/js/node-api/test/builder.spec.mts new file mode 100644 index 000000000..d28c43fd2 --- /dev/null +++ b/js/node-api/test/builder.spec.mts @@ -0,0 +1,163 @@ +import { expect, describe, it } from 'bun:test'; + +import { UncompiledPatternBuilder } from '../__generated__/index.js'; + +describe('Grit builder bindings', () => { + it('can save text from the repo', async () => { + const query = UncompiledPatternBuilder.new_snippet('console.log'); + const result = await query.runOnFiles([ + { path: 'test/file1.js', content: 'console.log("hello")' }, + { path: 'test/file2.js', content: 'throw new Error("oh no")' }, + ]); + console.log('result', result); + expect(result).toBe(1); + const result2 = await query.runOnFiles([ + { path: 'test/file1.js', content: 'console.error("hello")' }, + { path: 'test/file2.js', content: 'throw new Error("oh no")' }, + ]); + expect(result2).toBe(0); + }); + + it('can use nested contains', async () => { + const query = UncompiledPatternBuilder.new_snippet('console.log').contains( + UncompiledPatternBuilder.new_snippet('log'), + ); + const result = await query.runOnFiles([ + { path: 'test', content: 'console.log("hello")' }, + { path: 'not_a_file', content: 'throw new Error("oh no")' }, + ]); + expect(result).toBe(1); + // This one shoud not match, because there is not a fun inside; + const result2 = await UncompiledPatternBuilder.new_snippet('console.log($_)') + .contains(UncompiledPatternBuilder.new_snippet('fun')) + .runOnFiles([ + { path: 'test', content: 'console.log(food); fun' }, + { path: 'not_a_file', content: 'throw new Error("oh no")' }, + ]); + expect(result2).toBe(0); + }); + + it('can use a callback', async () => { + let callbackCalledCounter = { + value: 0, + }; + const query = UncompiledPatternBuilder.new_snippet('console.log').filter((data) => { + console.log('The callback was called'); + callbackCalledCounter.value++; + return true; + }); + const result = await query.runOnFiles([ + { + path: 'test', + content: 'function foo() { console.log("hello"); console.log("world"); } foo();', + }, + { path: 'not_a_file', content: 'throw new Error("oh no")' }, + ]); + expect(result).toBe(1); + expect(callbackCalledCounter.value).toBe(2); + }); + + it('can apply a rewrite with a pattern', async () => { + const query = UncompiledPatternBuilder.new_snippet('import $_').filter((arg: any) => { + arg.append(`<<>>\n`); + return true; + }); + + const modified = await query.runOnFile({ + path: 'test.js', + content: `import { foo } from 'bar'; +import { baz } from 'qux'; +import { quux } from 'corge'; + +function foo() { + console.log('foo'); +}`, + }); + expect(modified).toBeDefined(); + expect(modified!.content).toBe(`import { foo } from 'bar'; +<<>> + +import { baz } from 'qux'; +<<>> + +import { quux } from 'corge'; +<<>> + + +function foo() { + console.log('foo'); +}`); + }); + + it('can filter content correctly in callbacks', async () => { + const query = UncompiledPatternBuilder.new_snippet('import $_') + .filter((arg: any) => { + const text = arg.text(); + if (text.includes('baz')) { + return true; + } + return false; + }) + .filter((arg: any) => { + arg.append(`<<>>\n`); + return true; + }); + + const modified = await query.runOnFile({ + path: 'test.js', + content: `import { foo } from 'bar'; +import { baz } from 'qux'; +import { quux } from 'corge'; + +function foo() { + console.log('foo'); +}`, + }); + expect(modified).toBeDefined(); + expect(modified!.content).toBe(`import { foo } from 'bar'; +import { baz } from 'qux'; +<<>> + +import { quux } from 'corge'; + +function foo() { + console.log('foo'); +}`); + }); + + it('can find variables inside the context ', async () => { + const query = UncompiledPatternBuilder.new_snippet('import { $items } from "$source"') + .filter((arg: any) => { + const source = arg.findVarText('$source'); + if (source === 'qux') { + return true; + } + return false; + }) + .filter((arg: any) => { + arg.append(`<<>>\n`); + return true; + }); + + const modified = await query.runOnFile({ + path: 'test.js', + content: `import { foo } from 'bar'; +import { baz } from 'qux'; +import { quux } from 'corge'; + +function foo() { + console.log('foo'); +}`, + }); + expect(modified).toBeDefined(); + expect(modified!.content).toBe(`import { foo } from 'bar'; +import { baz } from 'qux'; +<<>> + +import { quux } from 'corge'; + +function foo() { + console.log('foo'); +}`); + }); +}); diff --git a/js/node-api/test/sum.spec.mts b/js/node-api/test/sum.spec.mts new file mode 100644 index 000000000..539cf7118 --- /dev/null +++ b/js/node-api/test/sum.spec.mts @@ -0,0 +1,10 @@ +import { expect, describe, it } from 'bun:test'; + +import { sum } from '../__generated__/index.js'; + +describe('Node bindings', () => { + it('work with a basic function', async () => { + const result = sum(1, 2); + expect(result).toBe(3); + }); +});