diff --git a/.changeset/famous-olives-shake.md b/.changeset/famous-olives-shake.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/famous-olives-shake.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/large-masks-warn.md b/.changeset/large-masks-warn.md new file mode 100644 index 00000000000..5495efd7c14 --- /dev/null +++ b/.changeset/large-masks-warn.md @@ -0,0 +1,5 @@ +--- +"@fuel-ts/abi-typegen": patch +--- + +feat: support `Result` type in typegen diff --git a/.changeset/shaggy-cougars-invent.md b/.changeset/shaggy-cougars-invent.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/shaggy-cougars-invent.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/twelve-insects-act.md b/.changeset/twelve-insects-act.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/twelve-insects-act.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 213a1ba2a1f..8b1fa2d026c 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -3,7 +3,8 @@ updates: - package-ecosystem: "npm" directory: "/" schedule: - interval: "daily" + interval: "weekly" + day: "sunday" open-pull-requests-limit: 10 labels: - "chore" diff --git a/.github/workflows/pr-lint.yaml b/.github/workflows/pr-lint.yaml index 879713e5e85..8de956208fe 100644 --- a/.github/workflows/pr-lint.yaml +++ b/.github/workflows/pr-lint.yaml @@ -79,6 +79,7 @@ jobs: # e.g. when a script is added in the scripts folder exit 0 fi + echo "CHANGESET_FILE=$(echo "$CHANGESET_FILE")" >> $GITHUB_ENV AFFECTED_PACKAGES=$(sed -n '/---/,/---/p' "$CHANGESET_FILE" | sed '/---/d') @@ -104,6 +105,22 @@ jobs: env: PR_TITLE: ${{ github.event.pull_request.title }} + - name: Validate added changeset will be deleted + if: ${{ env.CHANGESET_FILE != '' }} + run: | + pnpm changeset version + + if git status --porcelain .changeset | grep -q "D $CHANGESET_FILE"; then + git reset --hard + exit 0 + fi + + # Throw if changeset not in deleted changesets + echo "Changeset file $CHANGESET_FILE will not get deleted in the changesets PR. Check its affected packages." + exit 1 + env: + CHANGESET_FILE: ${{ env.CHANGESET_FILE }} + - name: Validate that there are only patch changes if: startsWith(github.base_ref, 'release/') run: | diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 70187f30426..66e319f9164 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -71,6 +71,8 @@ jobs: e2e: runs-on: ubuntu-latest timeout-minutes: 10 + needs: [environments] + if: ${{ !github.event.pull_request.draft }} steps: - name: Checkout uses: actions/checkout@v3 diff --git a/packages/abi-typegen/src/abi/Abi.ts b/packages/abi-typegen/src/abi/Abi.ts index 19f2c0841ee..84a8a29e9f4 100644 --- a/packages/abi-typegen/src/abi/Abi.ts +++ b/packages/abi-typegen/src/abi/Abi.ts @@ -102,6 +102,7 @@ export class Abi { option: 'Option', enum: 'Enum', vector: 'Vec', + result: 'Result', }; this.commonTypesInUse = []; diff --git a/packages/abi-typegen/src/abi/types/EnumType.ts b/packages/abi-typegen/src/abi/types/EnumType.ts index 20dae73837c..b2d58a7ea88 100644 --- a/packages/abi-typegen/src/abi/types/EnumType.ts +++ b/packages/abi-typegen/src/abi/types/EnumType.ts @@ -13,7 +13,7 @@ export class EnumType extends AType implements IType { public name = 'enum'; static MATCH_REGEX: RegExp = /^enum (.+)$/m; - static IGNORE_REGEX: RegExp = /^enum Option$/m; + static IGNORE_REGEX: RegExp = /^enum (Option|Result)$/m; static isSuitableFor(params: { type: string }) { const isAMatch = EnumType.MATCH_REGEX.test(params.type); diff --git a/packages/abi-typegen/src/abi/types/ResultType.test.ts b/packages/abi-typegen/src/abi/types/ResultType.test.ts new file mode 100644 index 00000000000..14e76338799 --- /dev/null +++ b/packages/abi-typegen/src/abi/types/ResultType.test.ts @@ -0,0 +1,40 @@ +import { + AbiTypegenProjectsEnum, + getTypegenForcProject, +} from '../../../test/fixtures/forc-projects/index'; +import type { IRawAbiTypeRoot } from '../../index'; +import { parseTypes } from '../../utils/parseTypes'; + +import { EnumType } from './EnumType'; +import { ResultType } from './ResultType'; + +/** + * @group node + */ +describe('ResultType.ts', () => { + /* + Test helpers + */ + function getResultType() { + const project = getTypegenForcProject(AbiTypegenProjectsEnum.FULL); + const rawTypes = project.abiContents.types as IRawAbiTypeRoot[]; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return parseTypes({ rawAbiTypes: [rawTypes.find((t) => t.type === 'enum Result')!] })[0]; + } + + test('should properly evaluate type suitability', () => { + const suitableForResult = ResultType.isSuitableFor({ type: ResultType.swayType }); + const suitableForEnum = ResultType.isSuitableFor({ type: EnumType.swayType }); + + expect(suitableForResult).toEqual(true); + expect(suitableForEnum).toEqual(false); + }); + + test('should properly parse type attributes', () => { + const type = getResultType(); + + expect(type.attributes.inputLabel).toEqual('Result'); + expect(type.attributes.outputLabel).toEqual('Result'); + expect(type.requiredFuelsMembersImports).toStrictEqual([]); + }); +}); diff --git a/packages/abi-typegen/src/abi/types/ResultType.ts b/packages/abi-typegen/src/abi/types/ResultType.ts new file mode 100644 index 00000000000..9f97a48fffb --- /dev/null +++ b/packages/abi-typegen/src/abi/types/ResultType.ts @@ -0,0 +1,23 @@ +import type { IType } from '../../types/interfaces/IType'; + +import { AType } from './AType'; + +export class ResultType extends AType implements IType { + public static swayType = 'enum Result'; + + public name = 'result'; + + static MATCH_REGEX: RegExp = /^enum Result$/m; + + static isSuitableFor(params: { type: string }) { + return ResultType.MATCH_REGEX.test(params.type); + } + + public parseComponentsAttributes(_params: { types: IType[] }) { + this.attributes = { + inputLabel: `Result`, + outputLabel: `Result`, + }; + return this.attributes; + } +} diff --git a/packages/abi-typegen/src/templates/common/common.hbs b/packages/abi-typegen/src/templates/common/common.hbs index 81fbdbe9080..2569534cf90 100644 --- a/packages/abi-typegen/src/templates/common/common.hbs +++ b/packages/abi-typegen/src/templates/common/common.hbs @@ -15,3 +15,9 @@ export type Enum = { export type Option = T | undefined; export type Vec = T[]; + +/** + * Mimics Sway Result enum type. + * Ok represents the success case, while Err represents the error case. + */ + export type Result = Enum<{Ok: T, Err: E}>; \ No newline at end of file diff --git a/packages/abi-typegen/src/utils/supportedTypes.test.ts b/packages/abi-typegen/src/utils/supportedTypes.test.ts index ea5df9ddda8..9930402cd82 100644 --- a/packages/abi-typegen/src/utils/supportedTypes.test.ts +++ b/packages/abi-typegen/src/utils/supportedTypes.test.ts @@ -5,6 +5,6 @@ import { supportedTypes } from './supportedTypes'; */ describe('supportedTypes.ts', () => { test('should export all supported types', () => { - expect(supportedTypes.length).toEqual(23); + expect(supportedTypes.length).toEqual(24); }); }); diff --git a/packages/abi-typegen/src/utils/supportedTypes.ts b/packages/abi-typegen/src/utils/supportedTypes.ts index 24d2985ec7a..7b08e32f68c 100644 --- a/packages/abi-typegen/src/utils/supportedTypes.ts +++ b/packages/abi-typegen/src/utils/supportedTypes.ts @@ -10,6 +10,7 @@ import { GenericType } from '../abi/types/GenericType'; import { OptionType } from '../abi/types/OptionType'; import { RawUntypedPtr } from '../abi/types/RawUntypedPtr'; import { RawUntypedSlice } from '../abi/types/RawUntypedSlice'; +import { ResultType } from '../abi/types/ResultType'; import { StdStringType } from '../abi/types/StdStringType'; import { StrSliceType } from '../abi/types/StrSliceType'; import { StrType } from '../abi/types/StrType'; @@ -46,4 +47,5 @@ export const supportedTypes = [ U8Type, VectorType, EvmAddressType, + ResultType, ]; diff --git a/packages/abi-typegen/test/fixtures/forc-projects/full/src/main.sw b/packages/abi-typegen/test/fixtures/forc-projects/full/src/main.sw index cd20dd93a68..39cff218c3c 100644 --- a/packages/abi-typegen/test/fixtures/forc-projects/full/src/main.sw +++ b/packages/abi-typegen/test/fixtures/forc-projects/full/src/main.sw @@ -24,6 +24,18 @@ struct StructWithSingleOption { single: Option, } +enum MyContractError { + DivisionByZero: (), +} + +fn divide(numerator: u64, denominator: u64) -> Result { + if (denominator == 0) { + return Err(MyContractError::DivisionByZero); + } else { + Ok(numerator / denominator) + } +} + abi MyContract { fn types_empty(x: ()) -> (); fn types_empty_then_value(x: (), y: u8) -> (); @@ -52,6 +64,7 @@ abi MyContract { fn types_bytes(x: Bytes) -> Bytes; fn types_raw_slice(x: raw_slice) -> raw_slice; fn types_std_string(x: String) -> String; + fn types_result(x: Result) -> Result; } impl MyContract for Contract { @@ -137,4 +150,15 @@ impl MyContract for Contract { fn types_std_string(x: String) -> String { x } + fn types_result(x: Result) -> Result { + if (x.is_err()) { + return Err(__to_str_array("InputError")); + } + + let result = divide(20, x.unwrap()); + match result { + Ok(value) => Ok(value), + Err(MyContractError::DivisionByZero) => Err(__to_str_array("DivisError")), + } + } } diff --git a/packages/abi-typegen/test/fixtures/templates/contract/dts.hbs b/packages/abi-typegen/test/fixtures/templates/contract/dts.hbs index 2e708d1e8f1..ed80884cec7 100644 --- a/packages/abi-typegen/test/fixtures/templates/contract/dts.hbs +++ b/packages/abi-typegen/test/fixtures/templates/contract/dts.hbs @@ -24,7 +24,7 @@ import type { StdString, } from 'fuels'; -import type { Option, Enum, Vec } from "./common"; +import type { Option, Enum, Vec, Result } from "./common"; export enum MyEnumInput { Checked = 'Checked', Pending = 'Pending' }; export enum MyEnumOutput { Checked = 'Checked', Pending = 'Pending' }; @@ -53,6 +53,7 @@ interface MyContractAbiInterface extends Interface { types_option: FunctionFragment; types_option_geo: FunctionFragment; types_raw_slice: FunctionFragment; + types_result: FunctionFragment; types_std_string: FunctionFragment; types_str: FunctionFragment; types_struct: FunctionFragment; @@ -82,6 +83,7 @@ interface MyContractAbiInterface extends Interface { encodeFunctionData(functionFragment: 'types_option', values: [Option]): Uint8Array; encodeFunctionData(functionFragment: 'types_option_geo', values: [Option]): Uint8Array; encodeFunctionData(functionFragment: 'types_raw_slice', values: [RawSlice]): Uint8Array; + encodeFunctionData(functionFragment: 'types_result', values: [Result]): Uint8Array; encodeFunctionData(functionFragment: 'types_std_string', values: [StdString]): Uint8Array; encodeFunctionData(functionFragment: 'types_str', values: [string]): Uint8Array; encodeFunctionData(functionFragment: 'types_struct', values: [MyStructInput]): Uint8Array; @@ -110,6 +112,7 @@ interface MyContractAbiInterface extends Interface { decodeFunctionData(functionFragment: 'types_option', data: BytesLike): DecodedValue; decodeFunctionData(functionFragment: 'types_option_geo', data: BytesLike): DecodedValue; decodeFunctionData(functionFragment: 'types_raw_slice', data: BytesLike): DecodedValue; + decodeFunctionData(functionFragment: 'types_result', data: BytesLike): DecodedValue; decodeFunctionData(functionFragment: 'types_std_string', data: BytesLike): DecodedValue; decodeFunctionData(functionFragment: 'types_str', data: BytesLike): DecodedValue; decodeFunctionData(functionFragment: 'types_struct', data: BytesLike): DecodedValue; @@ -142,6 +145,7 @@ export class MyContractAbi extends Contract { types_option: InvokeFunction<[x: Option], Option>; types_option_geo: InvokeFunction<[x: Option], Option>; types_raw_slice: InvokeFunction<[x: RawSlice], RawSlice>; + types_result: InvokeFunction<[x: Result], Result>; types_std_string: InvokeFunction<[x: StdString], StdString>; types_str: InvokeFunction<[x: string], string>; types_struct: InvokeFunction<[x: MyStructInput], MyStructOutput>; diff --git a/packages/create-fuels/src/cli.ts b/packages/create-fuels/src/cli.ts index 3c0dcd1061b..98d7c040949 100644 --- a/packages/create-fuels/src/cli.ts +++ b/packages/create-fuels/src/cli.ts @@ -6,15 +6,16 @@ import { existsSync, readFileSync, rmSync, writeFileSync } from 'fs'; import { cp, mkdir, rename } from 'fs/promises'; import ora from 'ora'; import { join } from 'path'; -import prompts from 'prompts'; import packageJson from '../package.json'; -import { checkIfFuelUpInstalled, installFuelUp } from './lib'; - -const log = (...data: unknown[]) => { - process.stdout.write(`${data.join(' ')}\n`); -}; +import { tryInstallFuelUp } from './lib'; +import { + promptForProgramsToInclude, + promptForPackageManager, + promptForProjectPath, +} from './prompts'; +import { error, log } from './utils/logger'; export type ProgramsToInclude = { contract: boolean; @@ -34,70 +35,6 @@ const processWorkspaceToml = (fileContents: string, programsToInclude: ProgramsT return toml.stringify(parsed); }; -async function promptForProjectPath() { - const res = await prompts( - { - type: 'text', - name: 'projectName', - message: 'What is the name of your project?', - initial: 'my-fuel-project', - }, - { onCancel: () => process.exit(0) } - ); - - return res.projectName as string; -} - -async function promptForPackageManager() { - const packageManagerInput = await prompts( - { - type: 'select', - name: 'packageManager', - message: 'Select a package manager', - choices: [ - { title: 'pnpm', value: 'pnpm' }, - { title: 'npm', value: 'npm' }, - ], - initial: 0, - }, - { onCancel: () => process.exit(0) } - ); - return packageManagerInput.packageManager as string; -} - -async function promptForProgramsToInclude({ - forceDisablePrompts = false, -}: { - forceDisablePrompts?: boolean; -}) { - if (forceDisablePrompts) { - return { - contract: false, - predicate: false, - script: false, - }; - } - const programsToIncludeInput = await prompts( - { - type: 'multiselect', - name: 'programsToInclude', - message: 'Which Sway programs do you want?', - choices: [ - { title: 'Contract', value: 'contract', selected: true }, - { title: 'Predicate', value: 'predicate', selected: true }, - { title: 'Script', value: 'script', selected: true }, - ], - instructions: false, - }, - { onCancel: () => process.exit(0) } - ); - return { - contract: programsToIncludeInput.programsToInclude.includes('contract'), - predicate: programsToIncludeInput.programsToInclude.includes('predicate'), - script: programsToIncludeInput.programsToInclude.includes('script'), - }; -} - function writeEnvFile(envFilePath: string, programsToInclude: ProgramsToInclude) { /* * Should be like: @@ -115,46 +52,6 @@ function writeEnvFile(envFilePath: string, programsToInclude: ProgramsToInclude) writeFileSync(envFilePath, newFileContents); } -async function promptForFuelUpInstall() { - const shouldInstallFuelUp = await prompts( - { - type: 'confirm', - name: 'shouldInstallFuelUp', - message: - "It seems you don't have `fuelup` installed. `fuelup` is required to manage the Fuel toolchain and is a prerequisite for using this template app. Do you want to install it now?", - initial: true, - }, - { onCancel: () => process.exit(0) } - ); - return shouldInstallFuelUp.shouldInstallFuelUp as boolean; -} - -async function tryInstallFuelup(isVerbose: boolean = false) { - const fuelUpSpinner = ora({ - text: 'Checking if fuelup is installed..', - color: 'green', - }).start(); - - if (checkIfFuelUpInstalled()) { - fuelUpSpinner.succeed('fuelup is already installed.'); - return; - } - - fuelUpSpinner.fail('fuelup not found.'); - - const shouldInstall = await promptForFuelUpInstall(); - - if (shouldInstall) { - installFuelUp(isVerbose); - } else { - log( - chalk.yellow( - 'Warning: You will need to install fuelup manually. See https://docs.fuel.network/guides/installation/#running-fuelup-init' - ) - ); - } -} - export const setupProgram = () => { const program = new Command(packageJson.name) .version(packageJson.version) @@ -187,15 +84,11 @@ export const runScaffoldCli = async ({ const verboseEnabled = program.opts().verbose ?? false; if (!process.env.VITEST) { - await tryInstallFuelup(verboseEnabled); + await tryInstallFuelUp(verboseEnabled); } while (existsSync(projectPath)) { - log( - chalk.red( - `A folder already exists at ${projectPath}. Please choose a different project name.` - ) - ); + error(`A folder already exists at ${projectPath}. Please choose a different project name.`); // Exit the program if we are testing to prevent hanging if (process.env.VITEST) { @@ -206,7 +99,7 @@ export const runScaffoldCli = async ({ } while (!projectPath) { - log(chalk.red('Please specify a project directory.')); + error('Please specify a project directory.'); // Exit the program if we are testing to prevent hanging if (process.env.VITEST) { @@ -246,7 +139,7 @@ export const runScaffoldCli = async ({ } while (!programsToInclude.contract && !programsToInclude.predicate && !programsToInclude.script) { - log(chalk.red('You must include at least one Sway program.')); + error('You must include at least one Sway program.'); // Exit the program if we are testing to prevent hanging if (process.env.VITEST) { diff --git a/packages/create-fuels/src/lib.ts b/packages/create-fuels/src/lib.ts index d16ad25df1b..381c39a7acb 100644 --- a/packages/create-fuels/src/lib.ts +++ b/packages/create-fuels/src/lib.ts @@ -1,32 +1,3 @@ -import { execSync } from 'child_process'; -import { log } from 'console'; -import ora from 'ora'; - -export const checkIfFuelUpInstalled = () => { - try { - execSync('fuelup --version', { stdio: 'pipe' }); - return true; - } catch (error) { - return false; - } -}; - -export const installFuelUp = (isVerbose: boolean = false) => { - const installFuelUpSpinner = ora({ - text: 'Installing fuelup..', - color: 'green', - }); - try { - execSync(`curl https://install.fuel.network | sh`, { stdio: 'inherit' }); - installFuelUpSpinner.succeed('Successfully installed fuelup!'); - } catch (error) { - if (isVerbose) { - log(error); - } - log( - installFuelUpSpinner.fail( - 'An error occurred while installing `fuelup`. Please try again, or try installing it manually. See https://docs.fuel.network/guides/installation/#running-fuelup-init for more information.' - ) - ); - } -}; +export * from './lib/createIfFuelUpInstalled'; +export * from './lib/installFuelUp'; +export * from './lib/tryInstallFuelUp'; diff --git a/packages/create-fuels/src/lib/createIfFuelUpInstalled.test.ts b/packages/create-fuels/src/lib/createIfFuelUpInstalled.test.ts new file mode 100644 index 00000000000..50f7d4ee6a2 --- /dev/null +++ b/packages/create-fuels/src/lib/createIfFuelUpInstalled.test.ts @@ -0,0 +1,50 @@ +import * as childProcessMod from 'child_process'; + +import { checkIfFuelUpInstalled } from './createIfFuelUpInstalled'; + +vi.mock('child_process', async () => { + const mod = await vi.importActual('child_process'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockAllDeps = (params: { version: string; shouldThrow: boolean }) => { + const { version, shouldThrow } = params; + + const throwError = vi.fn(() => { + throw new Error(''); + }); + + const execSync = vi + .spyOn(childProcessMod, 'execSync') + .mockImplementation(shouldThrow ? throwError : () => version); + + return { + execSync, + }; +}; + +/** + * @group node + */ +describe('createIfFuelUpInstalled', () => { + it('should check the version of fuelup', () => { + const { execSync } = mockAllDeps({ version: '1.0.0', shouldThrow: false }); + + const result = checkIfFuelUpInstalled(); + + expect(result).toEqual(true); + expect(execSync).toHaveBeenCalledWith('fuelup --version', { stdio: 'pipe' }); + }); + + it('should return false if fuelup is not installed', () => { + const { execSync } = mockAllDeps({ version: '1.0.0', shouldThrow: true }); + + const result = checkIfFuelUpInstalled(); + + expect(result).toEqual(false); + expect(execSync).toHaveBeenCalledWith('fuelup --version', { stdio: 'pipe' }); + }); +}); diff --git a/packages/create-fuels/src/lib/createIfFuelUpInstalled.ts b/packages/create-fuels/src/lib/createIfFuelUpInstalled.ts new file mode 100644 index 00000000000..e70d09c869e --- /dev/null +++ b/packages/create-fuels/src/lib/createIfFuelUpInstalled.ts @@ -0,0 +1,10 @@ +import { execSync } from 'child_process'; + +export const checkIfFuelUpInstalled = () => { + try { + execSync('fuelup --version', { stdio: 'pipe' }); + return true; + } catch (error) { + return false; + } +}; diff --git a/packages/create-fuels/src/lib/installFuelUp.test.ts b/packages/create-fuels/src/lib/installFuelUp.test.ts new file mode 100644 index 00000000000..d098c5cf47a --- /dev/null +++ b/packages/create-fuels/src/lib/installFuelUp.test.ts @@ -0,0 +1,80 @@ +import * as childProcessMod from 'child_process'; +import * as oraMod from 'ora'; + +import { installFuelUp } from './installFuelUp'; + +vi.mock('child_process', async () => { + const mod = await vi.importActual('child_process'); + return { + __esModule: true, + ...mod, + }; +}); + +vi.mock('ora', async () => { + const mod = await vi.importActual('ora'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockAllDeps = (params: { shouldThrow: boolean }) => { + const { shouldThrow } = params; + + const oraInstance = { + succeed: vi.fn(() => {}), + fail: vi.fn(() => {}), + } as unknown as oraMod.Ora; + const ora = vi.spyOn(oraMod, 'default').mockReturnValue(oraInstance); + + const throwError = vi.fn(() => { + throw new Error(''); + }); + + const execSync = vi + .spyOn(childProcessMod, 'execSync') + .mockImplementation(shouldThrow ? throwError : () => ''); + + return { + execSync, + ora, + oraInstance, + }; +}; + +/** + * @group node + */ +describe('installFuelUp', () => { + it('should install fuelup successfully', () => { + // Arrange + const { execSync, ora, oraInstance } = mockAllDeps({ shouldThrow: false }); + + // Act + installFuelUp(); + + // Assert + expect(ora).toHaveBeenCalledWith({ + text: 'Installing fuelup..', + color: 'green', + }); + expect(execSync).toHaveBeenCalledWith('curl https://install.fuel.network | sh', { + stdio: 'inherit', + }); + expect(oraInstance.succeed).toBeCalledWith('Successfully installed fuelup!'); + }); + + it('should gracefully fail when able to install fuelup', () => { + // Arrange + const { execSync, oraInstance } = mockAllDeps({ shouldThrow: true }); + + // Act + installFuelUp(); + + // Assert + expect(execSync).toBeCalledTimes(1); + expect(oraInstance.succeed).not.toBeCalled(); + expect(oraInstance.fail).toBeCalledTimes(1); + }); +}); diff --git a/packages/create-fuels/src/lib/installFuelUp.ts b/packages/create-fuels/src/lib/installFuelUp.ts new file mode 100644 index 00000000000..3a51c8900e4 --- /dev/null +++ b/packages/create-fuels/src/lib/installFuelUp.ts @@ -0,0 +1,24 @@ +import { execSync } from 'child_process'; +import ora from 'ora'; + +import { log, error } from '../utils/logger'; + +export const installFuelUp = (isVerbose: boolean = false) => { + const installFuelUpSpinner = ora({ + text: 'Installing fuelup..', + color: 'green', + }); + try { + execSync(`curl https://install.fuel.network | sh`, { stdio: 'inherit' }); + installFuelUpSpinner.succeed('Successfully installed fuelup!'); + } catch (e) { + if (isVerbose) { + error(e); + } + log( + installFuelUpSpinner.fail( + `An error occurred while installing 'fuelup'. Please try again, or try installing it manually. See https://docs.fuel.network/guides/installation/#running-fuelup-init for more information.` + ) + ); + } +}; diff --git a/packages/create-fuels/src/lib/tryInstallFuelUp.test.ts b/packages/create-fuels/src/lib/tryInstallFuelUp.test.ts new file mode 100644 index 00000000000..cb6155c93aa --- /dev/null +++ b/packages/create-fuels/src/lib/tryInstallFuelUp.test.ts @@ -0,0 +1,133 @@ +import * as oraMod from 'ora'; + +import { mockLogger } from '../../test/utils/mockLogger'; +import * as promptFuelUpInstallMod from '../prompts/promptFuelUpInstall'; + +import * as checkIfFuelUpInstalledMod from './createIfFuelUpInstalled'; +import * as installFuelUpMod from './installFuelUp'; +import { tryInstallFuelUp } from './tryInstallFuelUp'; + +vi.mock('ora', async () => { + const mod = await vi.importActual('ora'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockAllDeps = (params: { isFuelUpInstalled?: boolean; shouldInstallFuelUp?: boolean }) => { + const { isFuelUpInstalled, shouldInstallFuelUp } = params; + + const oraInstance = { + start: vi.fn(() => oraInstance), + succeed: vi.fn(() => {}), + fail: vi.fn(() => {}), + } as unknown as oraMod.Ora; + const ora = vi.spyOn(oraMod, 'default').mockReturnValue(oraInstance); + + const checkIfFuelUpInstalled = vi + .spyOn(checkIfFuelUpInstalledMod, 'checkIfFuelUpInstalled') + .mockReturnValue(isFuelUpInstalled ?? false); + + const promptFuelUpInstall = vi + .spyOn(promptFuelUpInstallMod, 'promptFuelUpInstall') + .mockResolvedValue(shouldInstallFuelUp ?? false); + + const installFuelUp = vi + .spyOn(installFuelUpMod, 'installFuelUp') + .mockImplementation(() => undefined); + + return { + ora, + oraInstance, + checkIfFuelUpInstalled, + promptFuelUpInstall, + installFuelUp, + }; +}; + +/** + * @group node + */ +describe('tryInstallFuelup', () => { + it('should display a message of installation to the user', async () => { + // Arrange + const { ora, oraInstance } = mockAllDeps({ + isFuelUpInstalled: false, + shouldInstallFuelUp: false, + }); + + // Act + await tryInstallFuelUp(); + + // Assert + expect(ora).toBeCalledWith({ + text: 'Checking if fuelup is installed..', + color: 'green', + }); + expect(oraInstance.start).toBeCalled(); + }); + + it('should display success message if fuelup installed', async () => { + // Arrange + const { oraInstance, checkIfFuelUpInstalled } = mockAllDeps({ + isFuelUpInstalled: true, + }); + + // Act + await tryInstallFuelUp(); + + // Assert + expect(checkIfFuelUpInstalled).toBeCalledTimes(1); + expect(oraInstance.succeed).toBeCalledWith('fuelup is already installed.'); + }); + + it('should prompt install if fuelup not installed', async () => { + // Arrange + const { oraInstance, checkIfFuelUpInstalled, promptFuelUpInstall } = mockAllDeps({ + isFuelUpInstalled: false, + }); + + // Act + await tryInstallFuelUp(); + + // Assert + expect(checkIfFuelUpInstalled).toBeCalledTimes(1); + expect(oraInstance.fail).toBeCalledWith('fuelup not found.'); + expect(promptFuelUpInstall).toBeCalledTimes(1); + }); + + it('should warn the user to install manually', async () => { + // Arrange + const { warn } = mockLogger(); + const { oraInstance, promptFuelUpInstall, installFuelUp } = mockAllDeps({ + isFuelUpInstalled: false, + shouldInstallFuelUp: false, + }); + + // Act + await tryInstallFuelUp(); + + // Assert + expect(oraInstance.fail).toBeCalledWith('fuelup not found.'); + expect(promptFuelUpInstall).toBeCalledTimes(1); + expect(warn.mock.calls[0][0]).toMatch(/You will need to install fuelup manually./g); + expect(installFuelUp).not.toBeCalled(); + }); + + it('should install fuelup if user wants to install', async () => { + // Arrange + const isVerbose = true; + const { promptFuelUpInstall, installFuelUp } = mockAllDeps({ + isFuelUpInstalled: false, + shouldInstallFuelUp: true, + }); + + // Act + await tryInstallFuelUp(isVerbose); + + // Assert + expect(promptFuelUpInstall).toBeCalledTimes(1); + expect(installFuelUp).toBeCalledWith(isVerbose); + }); +}); diff --git a/packages/create-fuels/src/lib/tryInstallFuelUp.ts b/packages/create-fuels/src/lib/tryInstallFuelUp.ts new file mode 100644 index 00000000000..4e535a53dd5 --- /dev/null +++ b/packages/create-fuels/src/lib/tryInstallFuelUp.ts @@ -0,0 +1,31 @@ +import ora from 'ora'; + +import { promptFuelUpInstall } from '../prompts'; +import { warn } from '../utils/logger'; + +import { checkIfFuelUpInstalled } from './createIfFuelUpInstalled'; +import { installFuelUp } from './installFuelUp'; + +export const tryInstallFuelUp = async (isVerbose: boolean = false) => { + const fuelUpSpinner = ora({ + text: 'Checking if fuelup is installed..', + color: 'green', + }).start(); + + if (checkIfFuelUpInstalled()) { + fuelUpSpinner.succeed('fuelup is already installed.'); + return; + } + + fuelUpSpinner.fail('fuelup not found.'); + + const shouldInstall = await promptFuelUpInstall(); + if (!shouldInstall) { + warn( + 'Warning: You will need to install fuelup manually. See https://docs.fuel.network/guides/installation/#running-fuelup-init' + ); + return; + } + + installFuelUp(isVerbose); +}; diff --git a/packages/create-fuels/src/prompts/index.ts b/packages/create-fuels/src/prompts/index.ts new file mode 100644 index 00000000000..fa7d1cd8f50 --- /dev/null +++ b/packages/create-fuels/src/prompts/index.ts @@ -0,0 +1,4 @@ +export * from './promptFuelUpInstall'; +export * from './promptForProjectPath'; +export * from './promptForPackageManager'; +export * from './promptForProgramsToInclude'; diff --git a/packages/create-fuels/src/prompts/promptForPackageManager.test.ts b/packages/create-fuels/src/prompts/promptForPackageManager.test.ts new file mode 100644 index 00000000000..3cbf9827f9a --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForPackageManager.test.ts @@ -0,0 +1,81 @@ +import * as promptsMod from 'prompts'; + +import { promptForPackageManager } from './promptForPackageManager'; + +vi.mock('prompts', async () => { + const mod = await vi.importActual('prompts'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockPrompts = (params: { results: unknown[] }) => { + promptsMod.default.inject(params.results); + const prompts = vi.spyOn(promptsMod, 'default'); + const exit = vi.spyOn(process, 'exit').mockReturnValue({} as never); + + return { + prompts, + exit, + }; +}; + +const responses = ['pnpm', 'npm']; + +/** + * @group node + */ +describe('promptForPackageManager', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user to select a package manger', async () => { + // Arrange + const { prompts } = mockPrompts({ results: [0] }); + + // Act + await promptForPackageManager(); + + // Assert + expect(prompts).toBeCalledWith( + expect.objectContaining({ + type: 'select', + message: expect.stringContaining(`Select a package manager`), + choices: [ + { title: 'pnpm', value: 'pnpm' }, + { title: 'npm', value: 'npm' }, + ], + }), + expect.any(Object) + ); + }); + + it.each(responses)('should handle "%s" response', async (response) => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [response] }); + + // Act + const result = await promptForPackageManager(); + + // Assert + expect(result).toBe(response); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should exit the process when user cancels the prompt', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [new Error()] }); + + // Act + const result = await promptForPackageManager(); + + // Assert + const expectedExitCode = 0; + expect(exit).toBeCalledWith(expectedExitCode); + expect(result).toBeUndefined(); + expect(prompts).toBeCalledTimes(1); + }); +}); diff --git a/packages/create-fuels/src/prompts/promptForPackageManager.ts b/packages/create-fuels/src/prompts/promptForPackageManager.ts new file mode 100644 index 00000000000..b39c0b9f2f9 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForPackageManager.ts @@ -0,0 +1,18 @@ +import prompts from 'prompts'; + +export const promptForPackageManager = async () => { + const packageManagerInput = await prompts( + { + type: 'select', + name: 'packageManager', + message: 'Select a package manager', + choices: [ + { title: 'pnpm', value: 'pnpm' }, + { title: 'npm', value: 'npm' }, + ], + initial: 0, + }, + { onCancel: () => process.exit(0) } + ); + return packageManagerInput.packageManager as string; +}; diff --git a/packages/create-fuels/src/prompts/promptForProgramsToInclude.test.ts b/packages/create-fuels/src/prompts/promptForProgramsToInclude.test.ts new file mode 100644 index 00000000000..0b1073fb732 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForProgramsToInclude.test.ts @@ -0,0 +1,109 @@ +import * as promptsMod from 'prompts'; + +import { promptForProgramsToInclude } from './promptForProgramsToInclude'; + +vi.mock('prompts', async () => { + const mod = await vi.importActual('prompts'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockPrompts = (params: { results: unknown[] } = { results: [] }) => { + promptsMod.default.inject(params.results); + const prompts = vi.spyOn(promptsMod, 'default'); + const exit = vi.spyOn(process, 'exit').mockReturnValue({} as never); + + return { + prompts, + exit, + }; +}; + +/** + * @group node + */ +describe('promptForProgramsToInclude', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user to include Sway programs', async () => { + // Arrange + const { prompts } = mockPrompts({ + results: [[]], + }); + + // Act + await promptForProgramsToInclude({ forceDisablePrompts: false }); + + // Assert + expect(prompts).toBeCalledWith( + expect.objectContaining({ + type: 'multiselect', + message: expect.stringContaining(`Which Sway programs do you want?`), + choices: [ + { title: 'Contract', value: 'contract', selected: true }, + { title: 'Predicate', value: 'predicate', selected: true }, + { title: 'Script', value: 'script', selected: true }, + ], + }), + expect.any(Object) + ); + }); + + it('should handle all user responses', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ + results: [['contract', 'predicate', 'script']], + }); + + // Act + const result = await promptForProgramsToInclude({ forceDisablePrompts: false }); + + // Assert + expect(result).toStrictEqual({ + contract: true, + predicate: true, + script: true, + }); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should handle no selected programs', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ + results: [[]], + }); + + // Act + const result = await promptForProgramsToInclude({ forceDisablePrompts: false }); + + // Assert + expect(result).toStrictEqual({ + contract: false, + predicate: false, + script: false, + }); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should return the default programs when prompts are disabled', async () => { + // Arrange + const { prompts } = mockPrompts(); + + // Act + const result = await promptForProgramsToInclude({ forceDisablePrompts: true }); + + // Assert + expect(result).toStrictEqual({ + contract: false, + predicate: false, + script: false, + }); + expect(prompts).not.toBeCalled(); + }); +}); diff --git a/packages/create-fuels/src/prompts/promptForProgramsToInclude.ts b/packages/create-fuels/src/prompts/promptForProgramsToInclude.ts new file mode 100644 index 00000000000..3fa7ba35644 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForProgramsToInclude.ts @@ -0,0 +1,35 @@ +import prompts from 'prompts'; + +export const promptForProgramsToInclude = async ({ + forceDisablePrompts = false, +}: { + forceDisablePrompts?: boolean; +}) => { + if (forceDisablePrompts) { + return { + contract: false, + predicate: false, + script: false, + }; + } + const programsToIncludeInput = await prompts( + { + type: 'multiselect', + name: 'programsToInclude', + message: 'Which Sway programs do you want?', + choices: [ + { title: 'Contract', value: 'contract', selected: true }, + { title: 'Predicate', value: 'predicate', selected: true }, + { title: 'Script', value: 'script', selected: true }, + ], + instructions: false, + }, + { onCancel: () => process.exit(0) } + ); + + return { + contract: programsToIncludeInput.programsToInclude.includes('contract'), + predicate: programsToIncludeInput.programsToInclude.includes('predicate'), + script: programsToIncludeInput.programsToInclude.includes('script'), + }; +}; diff --git a/packages/create-fuels/src/prompts/promptForProjectPath.test.ts b/packages/create-fuels/src/prompts/promptForProjectPath.test.ts new file mode 100644 index 00000000000..9abb1d680d3 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForProjectPath.test.ts @@ -0,0 +1,75 @@ +import * as promptsMod from 'prompts'; + +import { promptForProjectPath } from './promptForProjectPath'; + +vi.mock('prompts', async () => { + const mod = await vi.importActual('prompts'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockPrompts = (params: { results: unknown[] }) => { + promptsMod.default.inject(params.results); + const prompts = vi.spyOn(promptsMod, 'default'); + const exit = vi.spyOn(process, 'exit').mockReturnValue({} as never); + + return { + prompts, + exit, + }; +}; + +/** + * @group node + */ +describe('promptForProjectPath', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user to select a project name', async () => { + // Arrange + const { prompts } = mockPrompts({ results: [true] }); + + // Act + await promptForProjectPath(); + + // Assert + expect(prompts).toBeCalledWith( + expect.objectContaining({ + type: 'text', + message: expect.stringContaining(`What is the name of your project?`), + }), + expect.any(Object) + ); + }); + + it('should return the user response', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: ['user-project-name'] }); + + // Act + const result = await promptForProjectPath(); + + // Assert + expect(result).toBe('user-project-name'); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should exit the process when user cancels the prompt', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [new Error()] }); + + // Act + const result = await promptForProjectPath(); + + // Assert + const expectedExitCode = 0; + expect(exit).toBeCalledWith(expectedExitCode); + expect(result).toBeUndefined(); + expect(prompts).toBeCalledTimes(1); + }); +}); diff --git a/packages/create-fuels/src/prompts/promptForProjectPath.ts b/packages/create-fuels/src/prompts/promptForProjectPath.ts new file mode 100644 index 00000000000..87108b33bc3 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptForProjectPath.ts @@ -0,0 +1,15 @@ +import prompts from 'prompts'; + +export const promptForProjectPath = async () => { + const res = await prompts( + { + type: 'text', + name: 'projectName', + message: 'What is the name of your project?', + initial: 'my-fuel-project', + }, + { onCancel: () => process.exit(0) } + ); + + return res.projectName as string; +}; diff --git a/packages/create-fuels/src/prompts/promptFuelUpInstall.test.ts b/packages/create-fuels/src/prompts/promptFuelUpInstall.test.ts new file mode 100644 index 00000000000..12660189f11 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptFuelUpInstall.test.ts @@ -0,0 +1,88 @@ +import * as promptsMod from 'prompts'; + +import { promptFuelUpInstall } from './promptFuelUpInstall'; + +vi.mock('prompts', async () => { + const mod = await vi.importActual('prompts'); + return { + __esModule: true, + ...mod, + }; +}); + +const mockPrompts = (params: { results: unknown[] }) => { + promptsMod.default.inject(params.results); + const prompts = vi.spyOn(promptsMod, 'default'); + const exit = vi.spyOn(process, 'exit').mockReturnValue({} as never); + + return { + prompts, + exit, + }; +}; + +/** + * @group node + */ +describe('promptFuelUpInstall', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should prompt the user to install fuelup', async () => { + // Arrange + const { prompts } = mockPrompts({ results: [true] }); + + // Act + await promptFuelUpInstall(); + + // Assert + expect(prompts).toBeCalledWith( + expect.objectContaining({ + type: 'confirm', + message: expect.stringContaining(`It seems you don't have 'fuelup' installed`), + }), + expect.any(Object) + ); + }); + + it('should return the user response', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [true] }); + + // Act + const result = await promptFuelUpInstall(); + + // Assert + expect(result).toBe(true); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should return the user response when user chooses not to install fuelup', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [false] }); + + // Act + const result = await promptFuelUpInstall(); + + // Assert + expect(result).toBe(false); + expect(prompts).toBeCalledTimes(1); + expect(exit).not.toBeCalled(); + }); + + it('should exit the process when user cancels the prompt', async () => { + // Arrange + const { prompts, exit } = mockPrompts({ results: [new Error()] }); + + // Act + const result = await promptFuelUpInstall(); + + // Assert + const expectedExitCode = 0; + expect(exit).toBeCalledWith(expectedExitCode); + expect(result).toBeUndefined(); + expect(prompts).toBeCalledTimes(1); + }); +}); diff --git a/packages/create-fuels/src/prompts/promptFuelUpInstall.ts b/packages/create-fuels/src/prompts/promptFuelUpInstall.ts new file mode 100644 index 00000000000..661332fdd95 --- /dev/null +++ b/packages/create-fuels/src/prompts/promptFuelUpInstall.ts @@ -0,0 +1,16 @@ +import prompts from 'prompts'; + +export const promptFuelUpInstall = async () => { + const shouldInstallFuelUp = await prompts( + { + type: 'confirm', + name: 'shouldInstallFuelUp', + message: `It seems you don't have 'fuelup' installed. 'fuelup' is required to manage the Fuel toolchain and is a prerequisite for using this template app. Do you want to install it now?`, + initial: true, + }, + { + onCancel: () => process.exit(0), + } + ); + return shouldInstallFuelUp.shouldInstallFuelUp as boolean; +}; diff --git a/packages/create-fuels/src/utils/logger.test.ts b/packages/create-fuels/src/utils/logger.test.ts new file mode 100644 index 00000000000..b48d5f2d66b --- /dev/null +++ b/packages/create-fuels/src/utils/logger.test.ts @@ -0,0 +1,88 @@ +import * as loggerMod from './logger'; + +/** + * @group node + */ +describe('logger', () => { + const { configureLogging, loggingConfig } = loggerMod; + + const loggingBackup = structuredClone(loggingConfig); + + const reset = () => { + vi.restoreAllMocks(); + configureLogging(loggingBackup); + }; + + beforeEach(reset); + afterAll(reset); + + function mockStdIO() { + const err = vi.spyOn(process.stderr, 'write').mockReturnValue(true); + const out = vi.spyOn(process.stdout, 'write').mockReturnValue(true); + return { err, out }; + } + + test('should configure logging', () => { + configureLogging({ isLoggingEnabled: true, isDebugEnabled: false }); + expect(loggingConfig.isLoggingEnabled).toEqual(true); + expect(loggingConfig.isDebugEnabled).toEqual(false); + + configureLogging({ isLoggingEnabled: false, isDebugEnabled: true }); + expect(loggingConfig.isLoggingEnabled).toEqual(false); + expect(loggingConfig.isDebugEnabled).toEqual(false); + + configureLogging({ isLoggingEnabled: true, isDebugEnabled: true }); + expect(loggingConfig.isLoggingEnabled).toEqual(true); + expect(loggingConfig.isDebugEnabled).toEqual(true); + }); + + test('should log', () => { + const { err, out } = mockStdIO(); + configureLogging({ isLoggingEnabled: true, isDebugEnabled: false }); + loggerMod.log('any message'); + expect(out).toHaveBeenCalledTimes(1); + expect(out.mock.calls[0][0]).toMatch(/any message/); + expect(err).toHaveBeenCalledTimes(0); + }); + + test('should not log', () => { + const { err, out } = mockStdIO(); + configureLogging({ isLoggingEnabled: false, isDebugEnabled: false }); + loggerMod.log('any message'); + expect(out).toHaveBeenCalledTimes(0); + expect(err).toHaveBeenCalledTimes(0); + }); + + test('should debug', () => { + const { err, out } = mockStdIO(); + configureLogging({ isLoggingEnabled: true, isDebugEnabled: true }); + loggerMod.debug('any debug message'); + expect(out).toHaveBeenCalledTimes(1); + expect(out.mock.calls[0][0]).toMatch(/any debug message/); + expect(err).toHaveBeenCalledTimes(0); + }); + + test('should not log', () => { + const { err, out } = mockStdIO(); + configureLogging({ isLoggingEnabled: false, isDebugEnabled: false }); + loggerMod.debug('any debug message'); + expect(out).toHaveBeenCalledTimes(0); + expect(err).toHaveBeenCalledTimes(0); + }); + + test('should warn', () => { + const { err, out } = mockStdIO(); + loggerMod.warn('any warn message'); + expect(out).toHaveBeenCalledTimes(1); + expect(out.mock.calls[0][0]).toMatch(/any warn message/); + expect(err).toHaveBeenCalledTimes(0); + }); + + test('should error', () => { + const { err, out } = mockStdIO(); + loggerMod.error('any error message'); + expect(out).toHaveBeenCalledTimes(0); + expect(err).toHaveBeenCalledTimes(1); + expect(err.mock.calls[0][0]).toMatch(/any error message/); + }); +}); diff --git a/packages/create-fuels/src/utils/logger.ts b/packages/create-fuels/src/utils/logger.ts new file mode 100644 index 00000000000..39b7ee94b61 --- /dev/null +++ b/packages/create-fuels/src/utils/logger.ts @@ -0,0 +1,31 @@ +import chalk from 'chalk'; + +export const loggingConfig = { + isDebugEnabled: false, + isLoggingEnabled: true, +}; + +export function configureLogging(params: { isDebugEnabled: boolean; isLoggingEnabled: boolean }) { + loggingConfig.isLoggingEnabled = params.isLoggingEnabled; + loggingConfig.isDebugEnabled = params.isDebugEnabled && loggingConfig.isLoggingEnabled; +} + +export function log(...data: unknown[]) { + if (loggingConfig.isLoggingEnabled) { + process.stdout.write(`${data.join(' ')}\n`); + } +} + +export function debug(...data: unknown[]) { + if (loggingConfig.isDebugEnabled) { + log(data); + } +} + +export function error(...data: unknown[]) { + process.stderr.write(`${chalk.red(data.join(' '))}\n`); +} + +export function warn(...data: unknown[]) { + log(`${chalk.yellow(data.join(' '))}\n`); +} diff --git a/packages/create-fuels/test/cli.test.ts b/packages/create-fuels/test/cli.test.ts index 8d917670116..efd374c6aa4 100644 --- a/packages/create-fuels/test/cli.test.ts +++ b/packages/create-fuels/test/cli.test.ts @@ -1,12 +1,11 @@ import fs, { cp } from 'fs/promises'; import { glob } from 'glob'; import { join } from 'path'; -import type { MockInstance } from 'vitest'; import type { ProgramsToInclude } from '../src/cli'; import { runScaffoldCli, setupProgram } from '../src/cli'; -let writeSpy: MockInstance; +import { mockLogger } from './utils/mockLogger'; const getAllFiles = async (pathToDir: string) => { const files = await glob(`${pathToDir}/**/*`, { @@ -78,21 +77,51 @@ beforeEach(async () => { await cp(join(__dirname, '../../../templates'), join(__dirname, '../templates'), { recursive: true, }); - writeSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); }); afterEach(async () => { await fs.rm(join(__dirname, '../templates'), { recursive: true }); - writeSpy.mockRestore(); }); /** * @group node */ -test.each(possibleProgramsToInclude)( - 'create-fuels extracts the template to the specified directory', - async (programsToInclude) => { - const args = generateArgs(programsToInclude, 'test-project'); +describe('CLI', () => { + test.each(possibleProgramsToInclude)( + 'create-fuels extracts the template to the specified directory', + async (programsToInclude) => { + const args = generateArgs(programsToInclude, 'test-project'); + const program = setupProgram(); + program.parse(args); + + await runScaffoldCli({ + program, + args, + shouldInstallDeps: false, + }); + + let originalTemplateFiles = await getAllFiles(join(__dirname, '../templates/nextjs')); + originalTemplateFiles = filterOriginalTemplateFiles(originalTemplateFiles, programsToInclude); + + const testProjectFiles = await getAllFiles('test-project'); + + expect(originalTemplateFiles.sort()).toEqual(testProjectFiles.sort()); + + await fs.rm('test-project', { recursive: true }); + } + ); + + test('create-fuels reports an error if the project directory already exists', async () => { + await fs.mkdir('test-project-2'); + const { error } = mockLogger(); + const args = generateArgs( + { + contract: true, + predicate: true, + script: true, + }, + 'test-project-2' + ); const program = setupProgram(); program.parse(args); @@ -100,97 +129,68 @@ test.each(possibleProgramsToInclude)( program, args, shouldInstallDeps: false, + }).catch((e) => { + expect(e).toBeInstanceOf(Error); }); - let originalTemplateFiles = await getAllFiles(join(__dirname, '../templates/nextjs')); - originalTemplateFiles = filterOriginalTemplateFiles(originalTemplateFiles, programsToInclude); - - const testProjectFiles = await getAllFiles('test-project'); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('A folder already exists at test-project-2') + ); - expect(originalTemplateFiles.sort()).toEqual(testProjectFiles.sort()); - - await fs.rm('test-project', { recursive: true }); - } -); - -test('create-fuels reports an error if the project directory already exists', async () => { - await fs.mkdir('test-project-2'); - - const args = generateArgs( - { - contract: true, - predicate: true, - script: true, - }, - 'test-project-2' - ); - const program = setupProgram(); - program.parse(args); - - await runScaffoldCli({ - program, - args, - shouldInstallDeps: false, - }).catch((e) => { - expect(e).toBeInstanceOf(Error); + await fs.rm('test-project-2', { recursive: true }); }); - expect(writeSpy).toHaveBeenCalledWith( - expect.stringContaining('A folder already exists at test-project-2') - ); + test('create-fuels reports an error if no programs are chosen to be included', async () => { + const { error } = mockLogger(); + const args = generateArgs( + { + contract: false, + predicate: false, + script: false, + }, + 'test-project-3' + ); + const program = setupProgram(); + program.parse(args); - await fs.rm('test-project-2', { recursive: true }); -}); + await runScaffoldCli({ + program, + args, + shouldInstallDeps: false, + forceDisablePrompts: true, + }).catch((e) => { + expect(e).toBeInstanceOf(Error); + }); -test('create-fuels reports an error if no programs are chosen to be included', async () => { - const args = generateArgs( - { - contract: false, - predicate: false, - script: false, - }, - 'test-project-3' - ); - const program = setupProgram(); - program.parse(args); - - await runScaffoldCli({ - program, - args, - shouldInstallDeps: false, - forceDisablePrompts: true, - }).catch((e) => { - expect(e).toBeInstanceOf(Error); + expect(error).toHaveBeenCalledWith( + expect.stringContaining('You must include at least one Sway program.') + ); }); - expect(writeSpy).toHaveBeenCalledWith( - expect.stringContaining('You must include at least one Sway program.') - ); -}); - -test('setupProgram takes in args properly', () => { - const program = setupProgram(); - program.parse(['', '', 'test-project-name', '-c', '-p', '-s', '--pnpm', '--npm']); - expect(program.args[0]).toBe('test-project-name'); - expect(program.opts().contract).toBe(true); - expect(program.opts().predicate).toBe(true); - expect(program.opts().script).toBe(true); - expect(program.opts().pnpm).toBe(true); - expect(program.opts().npm).toBe(true); -}); + test('setupProgram takes in args properly', () => { + const program = setupProgram(); + program.parse(['', '', 'test-project-name', '-c', '-p', '-s', '--pnpm', '--npm']); + expect(program.args[0]).toBe('test-project-name'); + expect(program.opts().contract).toBe(true); + expect(program.opts().predicate).toBe(true); + expect(program.opts().script).toBe(true); + expect(program.opts().pnpm).toBe(true); + expect(program.opts().npm).toBe(true); + }); -test('setupProgram takes in combined args properly', () => { - const program = setupProgram(); - program.parse(['', '', '-cps']); - expect(program.opts().contract).toBe(true); - expect(program.opts().predicate).toBe(true); - expect(program.opts().script).toBe(true); -}); + test('setupProgram takes in combined args properly', () => { + const program = setupProgram(); + program.parse(['', '', '-cps']); + expect(program.opts().contract).toBe(true); + expect(program.opts().predicate).toBe(true); + expect(program.opts().script).toBe(true); + }); -test('setupProgram - no args', () => { - const program = setupProgram(); - program.parse([]); - expect(program.opts().contract).toBe(undefined); - expect(program.opts().predicate).toBe(undefined); - expect(program.opts().script).toBe(undefined); + test('setupProgram - no args', () => { + const program = setupProgram(); + program.parse([]); + expect(program.opts().contract).toBe(undefined); + expect(program.opts().predicate).toBe(undefined); + expect(program.opts().script).toBe(undefined); + }); }); diff --git a/packages/create-fuels/test/utils/mockLogger.ts b/packages/create-fuels/test/utils/mockLogger.ts new file mode 100644 index 00000000000..caaba1a3f14 --- /dev/null +++ b/packages/create-fuels/test/utils/mockLogger.ts @@ -0,0 +1,14 @@ +import * as logger from '../../src/utils/logger'; + +export function mockLogger() { + const error = vi.spyOn(logger, 'error').mockReturnValue(); + const warn = vi.spyOn(logger, 'warn').mockReturnValue(); + const log = vi.spyOn(logger, 'log').mockReturnValue(); + const debug = vi.spyOn(logger, 'debug').mockReturnValue(); + return { + error, + warn, + log, + debug, + }; +} diff --git a/packages/fuel-gauge/src/coverage-contract.test.ts b/packages/fuel-gauge/src/coverage-contract.test.ts index 2e7641542f2..a447bb7d4e7 100644 --- a/packages/fuel-gauge/src/coverage-contract.test.ts +++ b/packages/fuel-gauge/src/coverage-contract.test.ts @@ -506,6 +506,23 @@ describe('Coverage Contract', () => { expect(result.status).toEqual('success'); }); + it('supports result type', async () => { + const { + value: { Ok }, + } = await contractInstance.functions.types_result({ Ok: 1 }).call(); + expect(Ok.toNumber()).toBe(20); + + const { + value: { Err: DivisError }, + } = await contractInstance.functions.types_result({ Ok: 0 }).call(); + expect(DivisError).toBe('DivisError'); + + const { + value: { Err: InputError }, + } = await contractInstance.functions.types_result({ Err: 1 }).call(); + expect(InputError).toBe('InputError'); + }); + it('can read from produce_logs_variables', async () => { const { logs } = await contractInstance.functions.produce_logs_variables().call(); diff --git a/packages/fuel-gauge/test/fixtures/forc-projects/coverage-contract/src/main.sw b/packages/fuel-gauge/test/fixtures/forc-projects/coverage-contract/src/main.sw index 6cd82721b33..73d2a0ffaca 100644 --- a/packages/fuel-gauge/test/fixtures/forc-projects/coverage-contract/src/main.sw +++ b/packages/fuel-gauge/test/fixtures/forc-projects/coverage-contract/src/main.sw @@ -49,6 +49,18 @@ pub enum ColorEnum { Blue: (), } +enum MyContractError { + DivisionByZero: (), +} + +fn divide(numerator: u64, denominator: u64) -> Result { + if (denominator == 0) { + return Err(MyContractError::DivisionByZero); + } else { + Ok(numerator / denominator) + } +} + abi CoverageContract { fn produce_logs_variables(); fn get_id() -> b256; @@ -107,6 +119,7 @@ abi CoverageContract { inputC: b256, inputD: b256, ) -> Vec; + fn types_result(x: Result) -> Result; } pub fn vec_from(vals: [u32; 3]) -> Vec { @@ -425,4 +438,16 @@ impl CoverageContract for Contract { ) -> Vec { inputB } + + fn types_result(x: Result) -> Result { + if (x.is_err()) { + return Err(__to_str_array("InputError")); + } + + let result = divide(20, x.unwrap()); + match result { + Ok(value) => Ok(value), + Err(MyContractError::DivisionByZero) => Err(__to_str_array("DivisError")), + } + } } diff --git a/packages/fuels/src/cli/utils/logger.test.ts b/packages/fuels/src/cli/utils/logger.test.ts index e38ae184f92..b48d5f2d66b 100644 --- a/packages/fuels/src/cli/utils/logger.test.ts +++ b/packages/fuels/src/cli/utils/logger.test.ts @@ -1,5 +1,3 @@ -import { resetDiskAndMocks } from '../../../test/utils/resetDiskAndMocks'; - import * as loggerMod from './logger'; /** @@ -11,7 +9,7 @@ describe('logger', () => { const loggingBackup = structuredClone(loggingConfig); const reset = () => { - resetDiskAndMocks(); + vi.restoreAllMocks(); configureLogging(loggingBackup); }; diff --git a/scripts/changeset/get-full-changelog.mts b/scripts/changeset/get-full-changelog.mts index c2894048256..1421bb18345 100644 --- a/scripts/changeset/get-full-changelog.mts +++ b/scripts/changeset/get-full-changelog.mts @@ -20,7 +20,7 @@ async function getChangelogInfo( changeset: NewChangeset, ): Promise { const changesetCommit = execSync( - `git log -n 1 --oneline --pretty=format:%H -- ${join( + `git log -n 1 --diff-filter=A --oneline --pretty=format:%H -- ${join( process.cwd(), ".changeset", `${changeset.id}.md`,