From dd020c988f77f4d0c5e40750013488ce85500dfd Mon Sep 17 00:00:00 2001 From: beeman Date: Fri, 4 Sep 2026 02:10:22 +0200 Subject: [PATCH] refactor: split the create barrel into wiring, scaffolding and catalog constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the series: `create` was the last command whose wiring lived in `src/app.ts`, and `create-feature-index.ts` the last barrel. `create-feature.ts` now exports `createCreateCommand(deps): Command` and owns the commander boundary — the command declaration, the `parseOptions` override and its `templateOptions` closure, `extractTemplateOptions` with its helpers, and `parsePackageManagerOption`. `create-feature-scaffold.ts` (the renamed barrel) keeps `runCreate`, the create-solana-dapp API surface, the prompt helpers and template resolution, and no longer imports commander at all. `CUSTOM_TEMPLATES_URL` and `MINIMAL_TEMPLATE_NAME` move to `data-access/template-catalog.ts`, since they describe the template catalog rather than the CLI. `src/app.ts` is down to 110 lines from 660 at the start of the series. It now holds package metadata, the version-check hook, `--skip-version-check` propagation, the `addCommand` settings copy, eight `addCommand` calls and `runApp` — no command declarations, and one injectable dependency of its own. The 28 create tests and the `validate project name` suite move out of `test/core.test.ts` (965 lines to 388) into a new `test/create.test.ts`, with the two create helpers and the template fixtures. `core.test.ts` is now only CLI-command formatting, package metadata, version checking, and app-level wiring. One test is added: `create` is registered with `addCommand` like the rest, so it also depends on createApp copying `showHelpAfterError`, but it cannot join the app-level guard for the other seven — its `parseOptions` override collects unknown long flags as template options before commander can reject them, so the probe has to be an argument the override itself rejects. Also restores the blank line between consecutive tests in `test/device.test.ts`, `test/emulator.test.ts` and `test/templates.test.ts`. The previous commit in this series moved those tests with a join that dropped it, which biome does not flag; `test/doctor.test.ts` is left alone because its own runs predate this work. Behaviour is unchanged: 487 tests pass with and without a TTY, and `create`'s usage line, argument, option list, help footer, `--minimal`/`--template` rejection and template-option passthrough are all identical. --- src/app.ts | 67 +- ...re-index.ts => create-feature-scaffold.ts} | 92 +-- src/create/create-feature.ts | 146 +++++ src/create/data-access/template-catalog.ts | 4 + test/core.test.ts | 574 ----------------- test/create.test.ts | 600 ++++++++++++++++++ test/device.test.ts | 2 + test/emulator.test.ts | 17 + test/templates.test.ts | 3 + 9 files changed, 778 insertions(+), 727 deletions(-) rename src/create/{create-feature-index.ts => create-feature-scaffold.ts} (74%) create mode 100644 src/create/create-feature.ts create mode 100644 src/create/data-access/template-catalog.ts create mode 100644 test/create.test.ts diff --git a/src/app.ts b/src/app.ts index 7a0b88e..f5c4e6f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -6,13 +6,7 @@ import { type VersionCheckResult, } from './core/data-access/version-check.ts' import { formatUpdateWarning } from './core/ui/core-ui-update-warning.ts' -import { - type CreateCommandOptions, - extractTemplateOptions, - MINIMAL_TEMPLATE_NAME, - parsePackageManagerOption, - runCreate, -} from './create/create-feature-index.ts' +import { type CreateCommandDeps, createCreateCommand } from './create/create-feature.ts' import { createDeviceCommand, type DeviceCommandDeps } from './device/device-feature.ts' import { createDoctorCommand, type DoctorCommandDeps } from './doctor/doctor-feature.ts' import { createEmulatorCommand, type EmulatorCommandDeps } from './emulator/emulator-feature.ts' @@ -21,7 +15,8 @@ import { createPlaygroundCommand, type PlaygroundCommandDeps } from './playgroun import { createTemplatesCommand, type TemplatesCommandDeps } from './templates/templates-feature.ts' import { createWebshellCommand, type WebshellCommandDeps } from './webshell/webshell-feature.ts' -export type AppOptions = DeviceCommandDeps & +export type AppOptions = CreateCommandDeps & + DeviceCommandDeps & DoctorCommandDeps & EmulatorCommandDeps & LocalnetCommandDeps & @@ -29,14 +24,10 @@ export type AppOptions = DeviceCommandDeps & TemplatesCommandDeps & WebshellCommandDeps & { checkForNewerVersion?: (options: VersionCheckOptions) => Promise - runCreate?: (options: CreateCommandOptions) => Promise } export function createApp(appOptions: AppOptions = {}) { - const { - checkForNewerVersion: checkForNewerVersionFn = checkForNewerVersion, - runCreate: runCreateCommand = runCreate, - } = appOptions + const { checkForNewerVersion: checkForNewerVersionFn = checkForNewerVersion } = appOptions const metadata = readPackageMetadata() const app = new Command() @@ -62,58 +53,10 @@ export function createApp(appOptions: AppOptions = {}) { } }) - // Template options (e.g. `--reset-project`) are extracted from the raw arguments before - // commander parses them: commander drops the `--` separator and reroutes operands once it hits - // an unknown option, so the leftovers arrive too mangled to parse reliably. - let createTemplateOptions: string[] = [] - - const createCommand = app - .command('create [projectName]') - .description('Create a new Solana Mobile project') - .option('--pm, --package-manager ', 'Package manager to use', parsePackageManagerOption) - .option('-d, --dry-run', 'Dry run') - .option('-t, --template ', 'Use a template') - .option('--list-template-ids', 'List available template ids as JSON array') - .option('--list-templates', 'List available templates') - .option('--list-versions', 'Verify your versions of Anchor, AVM, Rust, and Solana') - .option('--minimal', 'Use the minimal template') - .option('--skip-git', 'Skip git initialization') - .option('--skip-init', 'Skip running the init script') - .option('--skip-install', 'Skip installing dependencies') - .option('-v, --verbose', 'Verbose output') - .addHelpText( - 'after', - '\nOptions declared by the selected template are passed through as boolean long flags, e.g.:\n $ solana-mobile create my-app --minimal --reset-project', - ) - .action(async (projectName: string | undefined, options: CreateCommandOptions) => { - if (options.minimal && options.template) { - createCommand.error( - `error: The --minimal flag can't be used in combination with --template. Please specify only one.`, - ) - } - - await runCreateCommand({ - ...options, - projectName, - template: options.template ?? (options.minimal ? MINIMAL_TEMPLATE_NAME : undefined), - templateOptions: createTemplateOptions, - }) - }) - - const parseCreateCommandOptions = createCommand.parseOptions.bind(createCommand) - createCommand.parseOptions = (argv: string[]) => { - try { - const extracted = extractTemplateOptions(createCommand, argv) - createTemplateOptions = extracted.templateOptions - return parseCreateCommandOptions(extracted.args) - } catch (error) { - return createCommand.error(`error: ${error instanceof Error ? error.message : String(error)}`) - } - } - // Registered in alphabetical order, which is the order they are listed in help output. Every // feature owns the wiring for its own command and picks the dependencies it needs out of // `appOptions`. + app.addCommand(createCreateCommand(appOptions)) app.addCommand(createDeviceCommand(appOptions)) app.addCommand(createDoctorCommand(appOptions)) app.addCommand(createEmulatorCommand(appOptions)) diff --git a/src/create/create-feature-index.ts b/src/create/create-feature-scaffold.ts similarity index 74% rename from src/create/create-feature-index.ts rename to src/create/create-feature-scaffold.ts index 9e6819f..ea2c6b0 100644 --- a/src/create/create-feature-index.ts +++ b/src/create/create-feature-scaffold.ts @@ -1,7 +1,6 @@ import { existsSync } from 'node:fs' import { isAbsolute, resolve } from 'node:path' import { cancel, intro, isCancel, log, note, outro, select, text } from '@clack/prompts' -import { type Command, InvalidArgumentError, type Option } from 'commander' import { type CreateAppArgs, createApp, @@ -17,13 +16,9 @@ import { type Template, type TemplateJsonTemplate, } from 'create-solana-dapp' +import { CUSTOM_TEMPLATES_URL } from './data-access/template-catalog.ts' import { projectNameSchema, validateProjectName } from './data-access/validate-project-name.ts' -export const CUSTOM_TEMPLATES_URL = 'https://raw.githubusercontent.com/solana-mobile/templates/main/templates.json' - -// Must match a template name in CUSTOM_TEMPLATES_URL, otherwise `--minimal` falls through to `gh:` resolution. -export const MINIMAL_TEMPLATE_NAME = 'expo-kit-minimal' - const SOLANA_MOBILE_MENU_CONFIG: MenuConfig = [ { description: 'Solana Mobile templates', @@ -203,91 +198,6 @@ export async function runCreate( } } -const templateOptionPattern = /^--([a-z][a-z0-9-]*)$/ - -/** - * Extracts template-defined option flags such as `--reset-project` from the create command's raw - * arguments before commander parses them, mirroring the extraction create-solana-dapp performs on - * its own argv. Working on the raw arguments is what keeps `--` semantics intact — commander drops - * the separator (or keeps it, depending on what precedes it) before leftovers are visible — and it - * leaves commander's own unknown-option and excess-argument checks active for everything that - * remains. create-solana-dapp validates the collected names against the options the cloned - * template declares. - */ -export function extractTemplateOptions( - command: Command, - args: string[], -): { args: string[]; templateOptions: string[] } { - const remaining: string[] = [] - const templateOptions = new Set() - let positionalOnly = false - let preserveNextArgument = false - - for (const arg of args) { - if (positionalOnly || preserveNextArgument) { - remaining.push(arg) - preserveNextArgument = false - continue - } - - if (arg === '--') { - positionalOnly = true - remaining.push(arg) - continue - } - - const knownOption = findKnownOption(command, arg) - - if (knownOption) { - remaining.push(arg) - preserveNextArgument = Boolean(knownOption.required) && !hasInlineValue(arg) - continue - } - - // The help option is registered outside `command.options`, so it needs its own pass-through - if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { - remaining.push(arg) - continue - } - - const name = templateOptionPattern.exec(arg)?.[1] - - if (!name) { - throw new InvalidArgumentError( - `Template options must be boolean long flags such as --reset-project; received "${arg}".`, - ) - } - - templateOptions.add(name) - } - - return { args: remaining, templateOptions: [...templateOptions] } -} - -function findKnownOption(command: Command, arg: string): Option | undefined { - // Check both flags: a dual-flag option such as `--pm, --package-manager` stores `--pm` as `short` - const flag = arg.startsWith('--') ? (arg.split('=', 1)[0] ?? arg) : arg.slice(0, 2) - - return command.options.find((option) => option.short === flag || option.long === flag) -} - -// A value attached to the flag itself (`--pm=pnpm`, `-tvalue`) means the next argument is not its value -function hasInlineValue(arg: string): boolean { - return arg.startsWith('--') ? arg.includes('=') : arg.length > 2 -} - -export function parsePackageManagerOption(next: string): PackageManager { - if (!next || !isPackageManager(next)) { - throw new InvalidArgumentError(`Invalid package manager: ${next}`) - } - - return next -} - -function isPackageManager(value: string): value is PackageManager { - return value === 'bun' || value === 'npm' || value === 'pnpm' || value === 'yarn' -} - // Templates from the catalog are named with a plain slug, but external (`org/repo`) templates keep // their raw reference as the name, so take the last path segment. Anything that still isn't a valid // project name is dropped rather than rewritten: an invalid pre-fill is worse than none, because it diff --git a/src/create/create-feature.ts b/src/create/create-feature.ts new file mode 100644 index 0000000..1bbaaec --- /dev/null +++ b/src/create/create-feature.ts @@ -0,0 +1,146 @@ +import { Command, InvalidArgumentError, type Option } from 'commander' +import type { PackageManager } from 'create-solana-dapp' +import { type CreateCommandOptions, runCreate } from './create-feature-scaffold.ts' +import { MINIMAL_TEMPLATE_NAME } from './data-access/template-catalog.ts' + +export type CreateCommandDeps = { + runCreate?: (options: CreateCommandOptions) => Promise +} + +export function createCreateCommand({ runCreate: runCreateCommand = runCreate }: CreateCommandDeps = {}): Command { + // Template options (e.g. `--reset-project`) are extracted from the raw arguments before + // commander parses them: commander drops the `--` separator and reroutes operands once it hits + // an unknown option, so the leftovers arrive too mangled to parse reliably. + let templateOptions: string[] = [] + + const createCommand = new Command('create') + .argument('[projectName]') + .description('Create a new Solana Mobile project') + .option('--pm, --package-manager ', 'Package manager to use', parsePackageManagerOption) + .option('-d, --dry-run', 'Dry run') + .option('-t, --template ', 'Use a template') + .option('--list-template-ids', 'List available template ids as JSON array') + .option('--list-templates', 'List available templates') + .option('--list-versions', 'Verify your versions of Anchor, AVM, Rust, and Solana') + .option('--minimal', 'Use the minimal template') + .option('--skip-git', 'Skip git initialization') + .option('--skip-init', 'Skip running the init script') + .option('--skip-install', 'Skip installing dependencies') + .option('-v, --verbose', 'Verbose output') + .addHelpText( + 'after', + '\nOptions declared by the selected template are passed through as boolean long flags, e.g.:\n $ solana-mobile create my-app --minimal --reset-project', + ) + .action(async (projectName: string | undefined, options: CreateCommandOptions) => { + if (options.minimal && options.template) { + createCommand.error( + `error: The --minimal flag can't be used in combination with --template. Please specify only one.`, + ) + } + + await runCreateCommand({ + ...options, + projectName, + template: options.template ?? (options.minimal ? MINIMAL_TEMPLATE_NAME : undefined), + templateOptions, + }) + }) + + const parseCreateCommandOptions = createCommand.parseOptions.bind(createCommand) + createCommand.parseOptions = (argv: string[]) => { + try { + const extracted = extractTemplateOptions(createCommand, argv) + templateOptions = extracted.templateOptions + return parseCreateCommandOptions(extracted.args) + } catch (error) { + return createCommand.error(`error: ${error instanceof Error ? error.message : String(error)}`) + } + } + + return createCommand +} + +const templateOptionPattern = /^--([a-z][a-z0-9-]*)$/ + +/** + * Extracts template-defined option flags such as `--reset-project` from the create command's raw + * arguments before commander parses them, mirroring the extraction create-solana-dapp performs on + * its own argv. Working on the raw arguments is what keeps `--` semantics intact — commander drops + * the separator (or keeps it, depending on what precedes it) before leftovers are visible — and it + * leaves commander's own unknown-option and excess-argument checks active for everything that + * remains. create-solana-dapp validates the collected names against the options the cloned + * template declares. + */ +export function extractTemplateOptions( + command: Command, + args: string[], +): { args: string[]; templateOptions: string[] } { + const remaining: string[] = [] + const templateOptions = new Set() + let positionalOnly = false + let preserveNextArgument = false + + for (const arg of args) { + if (positionalOnly || preserveNextArgument) { + remaining.push(arg) + preserveNextArgument = false + continue + } + + if (arg === '--') { + positionalOnly = true + remaining.push(arg) + continue + } + + const knownOption = findKnownOption(command, arg) + + if (knownOption) { + remaining.push(arg) + preserveNextArgument = Boolean(knownOption.required) && !hasInlineValue(arg) + continue + } + + // The help option is registered outside `command.options`, so it needs its own pass-through + if (!arg.startsWith('-') || arg === '-h' || arg === '--help') { + remaining.push(arg) + continue + } + + const name = templateOptionPattern.exec(arg)?.[1] + + if (!name) { + throw new InvalidArgumentError( + `Template options must be boolean long flags such as --reset-project; received "${arg}".`, + ) + } + + templateOptions.add(name) + } + + return { args: remaining, templateOptions: [...templateOptions] } +} + +function findKnownOption(command: Command, arg: string): Option | undefined { + // Check both flags: a dual-flag option such as `--pm, --package-manager` stores `--pm` as `short` + const flag = arg.startsWith('--') ? (arg.split('=', 1)[0] ?? arg) : arg.slice(0, 2) + + return command.options.find((option) => option.short === flag || option.long === flag) +} + +// A value attached to the flag itself (`--pm=pnpm`, `-tvalue`) means the next argument is not its value +function hasInlineValue(arg: string): boolean { + return arg.startsWith('--') ? arg.includes('=') : arg.length > 2 +} + +export function parsePackageManagerOption(next: string): PackageManager { + if (!next || !isPackageManager(next)) { + throw new InvalidArgumentError(`Invalid package manager: ${next}`) + } + + return next +} + +function isPackageManager(value: string): value is PackageManager { + return value === 'bun' || value === 'npm' || value === 'pnpm' || value === 'yarn' +} diff --git a/src/create/data-access/template-catalog.ts b/src/create/data-access/template-catalog.ts new file mode 100644 index 0000000..d9b142c --- /dev/null +++ b/src/create/data-access/template-catalog.ts @@ -0,0 +1,4 @@ +export const CUSTOM_TEMPLATES_URL = 'https://raw.githubusercontent.com/solana-mobile/templates/main/templates.json' + +// Must match a template name in CUSTOM_TEMPLATES_URL, otherwise `--minimal` falls through to `gh:` resolution. +export const MINIMAL_TEMPLATE_NAME = 'expo-kit-minimal' diff --git a/test/core.test.ts b/test/core.test.ts index 9843d7c..1042b4a 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -1,36 +1,17 @@ import { describe, expect, test } from 'bun:test' import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import type { CreateAppArgs, Template, TemplateJsonTemplate } from 'create-solana-dapp' import { createApp, runApp } from '../src/app.ts' import { readPackageMetadata } from '../src/core/data-access/package-metadata.ts' import { checkForNewerVersion, isVersionGreater } from '../src/core/data-access/version-check.ts' import { formatUpdateWarning } from '../src/core/ui/core-ui-update-warning.ts' import { formatCliCommand } from '../src/core/util/format-cli-command.ts' import { readPackageString } from '../src/core/util/read-package-string.ts' -import type { CreateCommandOptions, CreateSolanaDappApi } from '../src/create/create-feature-index.ts' -import { getInitialProjectName, MINIMAL_TEMPLATE_NAME, runCreate } from '../src/create/create-feature-index.ts' -import { projectNameSchema, validateProjectName } from '../src/create/data-access/validate-project-name.ts' const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { description: string name: string version: string } -const template: TemplateJsonTemplate = { - description: 'A Solana Mobile template', - id: 'gh:solana-mobile/templates/mobile/expo-kit-wallet', - keywords: [], - name: 'expo-kit-wallet', - path: 'mobile/expo-kit-wallet', -} -const minimalTemplate: TemplateJsonTemplate = { - description: 'A minimal Solana Mobile template', - id: 'gh:solana-mobile/templates/mobile/expo-kit-minimal', - keywords: [], - name: 'expo-kit-minimal', - path: 'mobile/expo-kit-minimal', -} describe('core', () => { test('formats direct CLI commands', () => { @@ -375,559 +356,4 @@ describe('app', () => { expect(errors.join('')).toContain(`Usage: solana-mobile ${name}`) } }) - - test('registers create command options', () => { - const createCommand = createApp().commands.find((command) => command.name() === 'create') - - expect(createCommand?.options.map((option) => option.flags)).toEqual([ - '--pm, --package-manager ', - '-d, --dry-run', - '-t, --template ', - '--list-template-ids', - '--list-templates', - '--list-versions', - '--minimal', - '--skip-git', - '--skip-init', - '--skip-install', - '-v, --verbose', - '--skip-version-check', - ]) - }) - - test('delegates create command options', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync([ - 'node', - 'solana-mobile', - 'create', - 'my-app', - '--template', - 'mobile/expo-kit-wallet', - '--pm', - 'pnpm', - '--skip-git', - '--skip-init', - '--skip-install', - '--verbose', - '--dry-run', - ]) - - expect(createOptions).toEqual([ - { - dryRun: true, - packageManager: 'pnpm', - projectName: 'my-app', - skipGit: true, - skipInit: true, - skipInstall: true, - template: 'mobile/expo-kit-wallet', - templateOptions: [], - verbose: true, - }, - ]) - }) - - test('delegates the minimal template name for --minimal', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--dry-run']) - - expect(createOptions).toEqual([ - { dryRun: true, minimal: true, projectName: 'my-app', template: 'expo-kit-minimal', templateOptions: [] }, - ]) - }) - - test('passes template options through to runCreate', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--reset-project', '--dry-run']) - - expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) - }) - - test('recovers the project name when a template option precedes it', async () => { - // commander reroutes operands that follow an unknown option into the leftover args, so the - // declared [projectName] argument would resolve to '--reset-project' here. - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', 'my-app', '--dry-run']) - - expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) - }) - - test('passes the project name after the -- separator', async () => { - // commander keeps the literal `--` in the leftover args when an unknown option precedes it - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', 'sentinel-app']) - - expect(createOptions[0]).toMatchObject({ projectName: 'sentinel-app', templateOptions: ['reset-project'] }) - }) - - test('treats dash-prefixed args after -- as positionals, not template options', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', '--not-an-option']) - - // The invalid name is rejected later by project name validation, not silently collected as an option - expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: ['reset-project'] }) - }) - - test('rejects combining --minimal with --template', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect( - app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--template', 'expo-kit-wallet']), - ).rejects.toThrow(`The --minimal flag can't be used in combination with --template`) - }) - - test('rejects template options that are not boolean long flags', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--reset-project=yes'])).rejects.toThrow( - 'Template options must be boolean long flags', - ) - }) - - test('rejects extra arguments', async () => { - const app = createAppWithSilencedCreateCommand() - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', 'extra'])).rejects.toThrow( - 'too many arguments', - ) - }) - - test('rejects template options placed after the -- separator as excess arguments', async () => { - // Everything after `--` is positional, even when commander itself would have dropped the - // separator before the extraction could see it - const app = createAppWithSilencedCreateCommand() - - await expect( - app.parseAsync(['node', 'solana-mobile', 'create', '--', 'sentinel-app', '--reset-project']), - ).rejects.toThrow('too many arguments') - }) - - test('treats a lone dash-prefixed arg after -- as the project name', async () => { - const createOptions: CreateCommandOptions[] = [] - const app = createApp({ - runCreate: async (options) => { - createOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'create', '--', '--not-an-option']) - - // Rejected later by project name validation, not silently collected as a template option - expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: [] }) - }) - - test('shows help for create --help instead of collecting it as a template option', async () => { - // The help option is registered outside `command.options`, so the extraction special-cases it - const app = createAppWithSilencedCreateCommand() - let helpText = '' - app.commands - .find((command) => command.name() === 'create') - ?.configureOutput({ - writeErr: () => {}, - writeOut: (text) => { - helpText += text - }, - }) - - await expect(app.parseAsync(['node', 'solana-mobile', 'create', '--help'])).rejects.toMatchObject({ - code: 'commander.helpDisplayed', - }) - expect(helpText).toContain('Usage: solana-mobile create') - }) - - test('resolves the minimal template name from the catalog', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: MINIMAL_TEMPLATE_NAME }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ template: minimalTemplate }]) - }) - - test('creates with selected template using create-solana-dapp API', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([ - { - dryRun: false, - name: 'my-app', - packageManager: 'bun', - // Detected rather than selected, so createApp may switch to a template-required manager - packageManagerExplicit: false, - skipGit: false, - skipInit: false, - skipInstall: true, - template, - verbose: false, - }, - ]) - }) - - test('marks an explicitly selected package manager as explicit', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { packageManager: 'pnpm', projectName: 'my-app', skipInstall: true }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ packageManager: 'pnpm', packageManagerExplicit: true }]) - }) - - test('rejects an invalid positional project name before creating', async () => { - // The positional name flows into the generated package.json and the rename search key, so it - // gets the same validation as the prompt - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - try { - await runCreate( - { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('creates without selecting when template is provided', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - let selectCalled = false - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, - { - createSolanaDapp, - selectTemplate: async () => { - selectCalled = true - return template - }, - }, - ) - - expect(createAppArgs).toMatchObject([{ template }]) - expect(selectCalled).toBe(false) - }) - - test('forwards template options to create-solana-dapp', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { - projectName: 'my-app', - skipInstall: true, - template: 'expo-kit-wallet', - templateOptions: ['reset-project'], - }, - { - createSolanaDapp, - selectTemplate: async () => template, - }, - ) - - expect(createAppArgs).toMatchObject([{ templateOptions: ['reset-project'] }]) - }) - - test('passes the selected template to the project name prompt', async () => { - // The prompt pre-fills the project name from the selected template, so it needs the selection - const createSolanaDapp = createMockCreateSolanaDapp() - let promptedTemplate: Template | undefined - - await runCreate( - { skipInstall: true }, - { - createSolanaDapp, - promptProjectName: async (_createSolanaDapp, template) => { - promptedTemplate = template - return 'my-app' - }, - selectTemplate: async () => template, - }, - ) - - expect(promptedTemplate).toBe(template) - }) - - test('derives the initial project name from the template name', () => { - expect(getInitialProjectName(template)).toBe('expo-kit-wallet') - // External templates keep their raw reference as the name; only the last segment is usable - expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates' })).toBe('templates') - expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates/' })).toBe('templates') - // An invalid candidate is dropped rather than rewritten - expect(getInitialProjectName({ ...template, name: 'My_Template' })).toBeUndefined() - }) - - test('passes an absolute local template path through as a local template', async () => { - // Without the local branch the path is prefixed with `gh:` and cloning fails - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - const localTemplate = resolve(process.cwd(), 'test/fixtures/template-repository/mobile/example') - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: localTemplate }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([ - { template: { description: `${localTemplate} (local)`, id: `local:${localTemplate}`, name: localTemplate } }, - ]) - }) - - test('resolves a relative local template path against the working directory', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - const relativeTemplate = './test/fixtures/template-repository/mobile/example' - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: relativeTemplate }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([ - { template: { id: `local:${resolve(process.cwd(), relativeTemplate)}`, name: relativeTemplate } }, - ]) - }) - - test('rejects a local template path that does not exist', async () => { - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - try { - await runCreate( - { projectName: 'my-app', skipInstall: true, template: '/does-not-exist/solana-mobile-template' }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('keeps treating a bare owner/repo template as an external GitHub reference', async () => { - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'solana-mobile/templates/mobile/expo-kit-anchor' }, - { createSolanaDapp, selectTemplate: async () => template }, - ) - - expect(createAppArgs).toMatchObject([{ template: { id: 'gh:solana-mobile/templates/mobile/expo-kit-anchor' } }]) - }) - - test('exits when createApp fails so the leftover spinner cannot hang the process', async () => { - // create-solana-dapp leaves its spinner running when a task throws, and the spinner keeps the - // event loop alive, so returning normally here would hang until the user interrupts - const previousExitCode = process.exitCode - const exitCodes: number[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppError: new Error('Error cloning the template') }) - - try { - await runCreate( - { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, - { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, - ) - - expect(exitCodes).toEqual([1]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('returns without exiting when the failure happens before createApp', async () => { - // No spinner is running yet, so returning lets stdout flush instead of truncating it - const previousExitCode = process.exitCode - const exitCodes: number[] = [] - const createSolanaDapp = createMockCreateSolanaDapp() - - try { - await runCreate( - { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, - { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, - ) - - expect(exitCodes).toEqual([]) - expect(process.exitCode).toBe(1) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) - - test('stops before prompting a project name when template selection is canceled', async () => { - const previousExitCode = process.exitCode - const createAppArgs: CreateAppArgs[] = [] - const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) - let promptCalled = false - - try { - await runCreate( - {}, - { - createSolanaDapp, - promptProjectName: async () => { - promptCalled = true - return 'my-app' - }, - selectTemplate: async () => undefined, - }, - ) - - expect(createAppArgs).toEqual([]) - expect(process.exitCode).toBe(1) - expect(promptCalled).toBe(false) - } finally { - process.exitCode = previousExitCode ?? 0 - } - }) -}) - -describe('validate project name', () => { - test.each(['a', 'app', 'my-app', 'my-app-2', 'web3'])('accepts %p', (name) => { - expect(validateProjectName(name)).toBeUndefined() - }) - - test.each([ - '-app', - '9lives', - '@scope/app', - 'My-App', - 'app-', - 'my app', - 'my--app', - 'my.app', - 'my_app', - ])('rejects %p', (name) => { - expect(validateProjectName(name)).toBe( - 'Please enter a valid project name (lowercase letters, numbers, and single dashes, starting with a letter)', - ) - }) - - test('rejects an empty name', () => { - expect(validateProjectName('')).toBe('Please enter at least 1 character') - }) - - test('rejects a name longer than 214 characters', () => { - expect(validateProjectName('a'.repeat(215))).toBe('Please enter a name with at most 214 characters') - }) - - test('accepts a name of exactly 214 characters', () => { - expect(validateProjectName('a'.repeat(214))).toBeUndefined() - }) - - test('rejects a valid name when the directory already exists', () => { - // bun test runs from the repo root, where `src` exists - expect(validateProjectName('src')).toBe('Directory already exists') - }) - - test('does not check the filesystem in the schema alone', () => { - expect(projectNameSchema.safeParse('src').success).toBe(true) - }) }) - -function createAppWithSilencedCreateCommand() { - const app = createApp({ runCreate: async () => {} }) - - app.exitOverride() - app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) - app.commands - .find((command) => command.name() === 'create') - ?.exitOverride() - .configureOutput({ writeErr: () => {}, writeOut: () => {} }) - - return app -} - -function createMockCreateSolanaDapp({ - createAppArgs = [], - createAppError, -}: { - createAppArgs?: CreateAppArgs[] - createAppError?: Error -} = {}) { - return { - createApp: async (args) => { - createAppArgs.push(args) - if (createAppError) { - throw createAppError - } - return ['Install dependencies:'] - }, - detectInvokedPackageManager: () => 'bun', - fetchTemplateData: async () => ({ items: [], templates: [minimalTemplate, template] }), - finalNote: () => 'Done', - getAppInfo: () => ({ name: 'create-solana-dapp', version: '4.8.5' }), - listTemplateIds: ({ templates }) => templates.map((template) => template.id), - listTemplates: () => {}, - listVersions: () => {}, - validateProjectName, - } satisfies CreateSolanaDappApi -} diff --git a/test/create.test.ts b/test/create.test.ts new file mode 100644 index 0000000..c392456 --- /dev/null +++ b/test/create.test.ts @@ -0,0 +1,600 @@ +import { describe, expect, test } from 'bun:test' +import { resolve } from 'node:path' +import type { CreateAppArgs, Template, TemplateJsonTemplate } from 'create-solana-dapp' +import { createApp } from '../src/app.ts' +import type { CreateCommandOptions, CreateSolanaDappApi } from '../src/create/create-feature-scaffold.ts' +import { getInitialProjectName, runCreate } from '../src/create/create-feature-scaffold.ts' +import { MINIMAL_TEMPLATE_NAME } from '../src/create/data-access/template-catalog.ts' +import { projectNameSchema, validateProjectName } from '../src/create/data-access/validate-project-name.ts' + +const template: TemplateJsonTemplate = { + description: 'A Solana Mobile template', + id: 'gh:solana-mobile/templates/mobile/expo-kit-wallet', + keywords: [], + name: 'expo-kit-wallet', + path: 'mobile/expo-kit-wallet', +} +const minimalTemplate: TemplateJsonTemplate = { + description: 'A minimal Solana Mobile template', + id: 'gh:solana-mobile/templates/mobile/expo-kit-minimal', + keywords: [], + name: 'expo-kit-minimal', + path: 'mobile/expo-kit-minimal', +} + +describe('create command', () => { + test('registers create command options', () => { + const createCommand = createApp().commands.find((command) => command.name() === 'create') + + expect(createCommand?.options.map((option) => option.flags)).toEqual([ + '--pm, --package-manager ', + '-d, --dry-run', + '-t, --template ', + '--list-template-ids', + '--list-templates', + '--list-versions', + '--minimal', + '--skip-git', + '--skip-init', + '--skip-install', + '-v, --verbose', + '--skip-version-check', + ]) + }) + + test('delegates create command options', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'create', + 'my-app', + '--template', + 'mobile/expo-kit-wallet', + '--pm', + 'pnpm', + '--skip-git', + '--skip-init', + '--skip-install', + '--verbose', + '--dry-run', + ]) + + expect(createOptions).toEqual([ + { + dryRun: true, + packageManager: 'pnpm', + projectName: 'my-app', + skipGit: true, + skipInit: true, + skipInstall: true, + template: 'mobile/expo-kit-wallet', + templateOptions: [], + verbose: true, + }, + ]) + }) + + test('delegates the minimal template name for --minimal', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--dry-run']) + + expect(createOptions).toEqual([ + { dryRun: true, minimal: true, projectName: 'my-app', template: 'expo-kit-minimal', templateOptions: [] }, + ]) + }) + + test('passes template options through to runCreate', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--reset-project', '--dry-run']) + + expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) + }) + + test('recovers the project name when a template option precedes it', async () => { + // commander reroutes operands that follow an unknown option into the leftover args, so the + // declared [projectName] argument would resolve to '--reset-project' here. + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', 'my-app', '--dry-run']) + + expect(createOptions[0]).toMatchObject({ projectName: 'my-app', templateOptions: ['reset-project'] }) + }) + + test('passes the project name after the -- separator', async () => { + // commander keeps the literal `--` in the leftover args when an unknown option precedes it + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', 'sentinel-app']) + + expect(createOptions[0]).toMatchObject({ projectName: 'sentinel-app', templateOptions: ['reset-project'] }) + }) + + test('treats dash-prefixed args after -- as positionals, not template options', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--reset-project', '--', '--not-an-option']) + + // The invalid name is rejected later by project name validation, not silently collected as an option + expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: ['reset-project'] }) + }) + + test('rejects combining --minimal with --template', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect( + app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--minimal', '--template', 'expo-kit-wallet']), + ).rejects.toThrow(`The --minimal flag can't be used in combination with --template`) + }) + + test('prints usage after an error, so the settings copy reaches create too', async () => { + // `create` is registered with `addCommand` like every other feature command, so it depends on + // createApp copying the root's `showHelpAfterError`. It cannot join the app-level guard for the + // other commands: its `parseOptions` override collects unknown long flags as template options + // before commander can reject them, so the error has to come from the override itself. + const errors: string[] = [] + const app = createApp({ runCreate: async () => {} }) + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + app.commands + .find((command) => command.name() === 'create') + ?.exitOverride() + .configureOutput({ writeErr: (text) => errors.push(text), writeOut: () => {} }) + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', '-x'])).rejects.toThrow() + + expect(errors.join('')).toContain('Usage: solana-mobile create [options] [projectName]') + }) + + test('rejects template options that are not boolean long flags', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', '--reset-project=yes'])).rejects.toThrow( + 'Template options must be boolean long flags', + ) + }) + + test('rejects extra arguments', async () => { + const app = createAppWithSilencedCreateCommand() + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', 'my-app', 'extra'])).rejects.toThrow( + 'too many arguments', + ) + }) + + test('rejects template options placed after the -- separator as excess arguments', async () => { + // Everything after `--` is positional, even when commander itself would have dropped the + // separator before the extraction could see it + const app = createAppWithSilencedCreateCommand() + + await expect( + app.parseAsync(['node', 'solana-mobile', 'create', '--', 'sentinel-app', '--reset-project']), + ).rejects.toThrow('too many arguments') + }) + + test('treats a lone dash-prefixed arg after -- as the project name', async () => { + const createOptions: CreateCommandOptions[] = [] + const app = createApp({ + runCreate: async (options) => { + createOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'create', '--', '--not-an-option']) + + // Rejected later by project name validation, not silently collected as a template option + expect(createOptions[0]).toMatchObject({ projectName: '--not-an-option', templateOptions: [] }) + }) + + test('shows help for create --help instead of collecting it as a template option', async () => { + // The help option is registered outside `command.options`, so the extraction special-cases it + const app = createAppWithSilencedCreateCommand() + let helpText = '' + app.commands + .find((command) => command.name() === 'create') + ?.configureOutput({ + writeErr: () => {}, + writeOut: (text) => { + helpText += text + }, + }) + + await expect(app.parseAsync(['node', 'solana-mobile', 'create', '--help'])).rejects.toMatchObject({ + code: 'commander.helpDisplayed', + }) + expect(helpText).toContain('Usage: solana-mobile create') + }) + + test('resolves the minimal template name from the catalog', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: MINIMAL_TEMPLATE_NAME }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ template: minimalTemplate }]) + }) + + test('creates with selected template using create-solana-dapp API', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([ + { + dryRun: false, + name: 'my-app', + packageManager: 'bun', + // Detected rather than selected, so createApp may switch to a template-required manager + packageManagerExplicit: false, + skipGit: false, + skipInit: false, + skipInstall: true, + template, + verbose: false, + }, + ]) + }) + + test('marks an explicitly selected package manager as explicit', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { packageManager: 'pnpm', projectName: 'my-app', skipInstall: true }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ packageManager: 'pnpm', packageManagerExplicit: true }]) + }) + + test('rejects an invalid positional project name before creating', async () => { + // The positional name flows into the generated package.json and the rename search key, so it + // gets the same validation as the prompt + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + try { + await runCreate( + { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('creates without selecting when template is provided', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + let selectCalled = false + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, + { + createSolanaDapp, + selectTemplate: async () => { + selectCalled = true + return template + }, + }, + ) + + expect(createAppArgs).toMatchObject([{ template }]) + expect(selectCalled).toBe(false) + }) + + test('forwards template options to create-solana-dapp', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { + projectName: 'my-app', + skipInstall: true, + template: 'expo-kit-wallet', + templateOptions: ['reset-project'], + }, + { + createSolanaDapp, + selectTemplate: async () => template, + }, + ) + + expect(createAppArgs).toMatchObject([{ templateOptions: ['reset-project'] }]) + }) + + test('passes the selected template to the project name prompt', async () => { + // The prompt pre-fills the project name from the selected template, so it needs the selection + const createSolanaDapp = createMockCreateSolanaDapp() + let promptedTemplate: Template | undefined + + await runCreate( + { skipInstall: true }, + { + createSolanaDapp, + promptProjectName: async (_createSolanaDapp, template) => { + promptedTemplate = template + return 'my-app' + }, + selectTemplate: async () => template, + }, + ) + + expect(promptedTemplate).toBe(template) + }) + + test('derives the initial project name from the template name', () => { + expect(getInitialProjectName(template)).toBe('expo-kit-wallet') + // External templates keep their raw reference as the name; only the last segment is usable + expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates' })).toBe('templates') + expect(getInitialProjectName({ ...template, name: 'solana-mobile/templates/' })).toBe('templates') + // An invalid candidate is dropped rather than rewritten + expect(getInitialProjectName({ ...template, name: 'My_Template' })).toBeUndefined() + }) + + test('passes an absolute local template path through as a local template', async () => { + // Without the local branch the path is prefixed with `gh:` and cloning fails + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + const localTemplate = resolve(process.cwd(), 'test/fixtures/template-repository/mobile/example') + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: localTemplate }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([ + { template: { description: `${localTemplate} (local)`, id: `local:${localTemplate}`, name: localTemplate } }, + ]) + }) + + test('resolves a relative local template path against the working directory', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + const relativeTemplate = './test/fixtures/template-repository/mobile/example' + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: relativeTemplate }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([ + { template: { id: `local:${resolve(process.cwd(), relativeTemplate)}`, name: relativeTemplate } }, + ]) + }) + + test('rejects a local template path that does not exist', async () => { + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + try { + await runCreate( + { projectName: 'my-app', skipInstall: true, template: '/does-not-exist/solana-mobile-template' }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('keeps treating a bare owner/repo template as an external GitHub reference', async () => { + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'solana-mobile/templates/mobile/expo-kit-anchor' }, + { createSolanaDapp, selectTemplate: async () => template }, + ) + + expect(createAppArgs).toMatchObject([{ template: { id: 'gh:solana-mobile/templates/mobile/expo-kit-anchor' } }]) + }) + + test('exits when createApp fails so the leftover spinner cannot hang the process', async () => { + // create-solana-dapp leaves its spinner running when a task throws, and the spinner keeps the + // event loop alive, so returning normally here would hang until the user interrupts + const previousExitCode = process.exitCode + const exitCodes: number[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppError: new Error('Error cloning the template') }) + + try { + await runCreate( + { projectName: 'my-app', skipInstall: true, template: 'expo-kit-wallet' }, + { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, + ) + + expect(exitCodes).toEqual([1]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('returns without exiting when the failure happens before createApp', async () => { + // No spinner is running yet, so returning lets stdout flush instead of truncating it + const previousExitCode = process.exitCode + const exitCodes: number[] = [] + const createSolanaDapp = createMockCreateSolanaDapp() + + try { + await runCreate( + { projectName: 'My_App', skipInstall: true, template: 'expo-kit-wallet' }, + { createSolanaDapp, exit: (code) => exitCodes.push(code), selectTemplate: async () => template }, + ) + + expect(exitCodes).toEqual([]) + expect(process.exitCode).toBe(1) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) + + test('stops before prompting a project name when template selection is canceled', async () => { + const previousExitCode = process.exitCode + const createAppArgs: CreateAppArgs[] = [] + const createSolanaDapp = createMockCreateSolanaDapp({ createAppArgs }) + let promptCalled = false + + try { + await runCreate( + {}, + { + createSolanaDapp, + promptProjectName: async () => { + promptCalled = true + return 'my-app' + }, + selectTemplate: async () => undefined, + }, + ) + + expect(createAppArgs).toEqual([]) + expect(process.exitCode).toBe(1) + expect(promptCalled).toBe(false) + } finally { + process.exitCode = previousExitCode ?? 0 + } + }) +}) + +describe('validate project name', () => { + test.each(['a', 'app', 'my-app', 'my-app-2', 'web3'])('accepts %p', (name) => { + expect(validateProjectName(name)).toBeUndefined() + }) + + test.each([ + '-app', + '9lives', + '@scope/app', + 'My-App', + 'app-', + 'my app', + 'my--app', + 'my.app', + 'my_app', + ])('rejects %p', (name) => { + expect(validateProjectName(name)).toBe( + 'Please enter a valid project name (lowercase letters, numbers, and single dashes, starting with a letter)', + ) + }) + + test('rejects an empty name', () => { + expect(validateProjectName('')).toBe('Please enter at least 1 character') + }) + + test('rejects a name longer than 214 characters', () => { + expect(validateProjectName('a'.repeat(215))).toBe('Please enter a name with at most 214 characters') + }) + + test('accepts a name of exactly 214 characters', () => { + expect(validateProjectName('a'.repeat(214))).toBeUndefined() + }) + + test('rejects a valid name when the directory already exists', () => { + // bun test runs from the repo root, where `src` exists + expect(validateProjectName('src')).toBe('Directory already exists') + }) + + test('does not check the filesystem in the schema alone', () => { + expect(projectNameSchema.safeParse('src').success).toBe(true) + }) +}) + +function createAppWithSilencedCreateCommand() { + const app = createApp({ runCreate: async () => {} }) + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + app.commands + .find((command) => command.name() === 'create') + ?.exitOverride() + .configureOutput({ writeErr: () => {}, writeOut: () => {} }) + + return app +} + +function createMockCreateSolanaDapp({ + createAppArgs = [], + createAppError, +}: { + createAppArgs?: CreateAppArgs[] + createAppError?: Error +} = {}) { + return { + createApp: async (args) => { + createAppArgs.push(args) + if (createAppError) { + throw createAppError + } + return ['Install dependencies:'] + }, + detectInvokedPackageManager: () => 'bun', + fetchTemplateData: async () => ({ items: [], templates: [minimalTemplate, template] }), + finalNote: () => 'Done', + getAppInfo: () => ({ name: 'create-solana-dapp', version: '4.8.5' }), + listTemplateIds: ({ templates }) => templates.map((template) => template.id), + listTemplates: () => {}, + listVersions: () => {}, + validateProjectName, + } satisfies CreateSolanaDappApi +} diff --git a/test/device.test.ts b/test/device.test.ts index 92e7a6c..078037a 100644 --- a/test/device.test.ts +++ b/test/device.test.ts @@ -1043,6 +1043,7 @@ describe('device command', () => { expect(deviceCommand?.commands.map((command) => command.name())).toEqual(['install', 'list', 'open', 'tune']) }) + test('delegates device tune command options', async () => { const deviceTuneOptions: DeviceTuneCommandOptions[] = [] const app = createApp({ @@ -1056,6 +1057,7 @@ describe('device command', () => { expect(deviceTuneOptions).toEqual([{ device: 'SM02E4072816572' }, { all: true, yes: true }]) }) + test('rejects device tune with both --all and --device', async () => { const app = createAppWithSilencedDeviceTuneCommand() diff --git a/test/emulator.test.ts b/test/emulator.test.ts index 9f413c0..d363444 100644 --- a/test/emulator.test.ts +++ b/test/emulator.test.ts @@ -2387,6 +2387,7 @@ describe('emulator command', () => { expect(emulatorTuneOptions).toEqual([{ nameOrSerial: undefined }, { nameOrSerial: 'Alpha', yes: true }]) }) + test('registers emulator alias and subcommands', () => { const emulatorCommand = createApp().commands.find((command) => command.name() === 'emulator') @@ -2402,6 +2403,7 @@ describe('emulator command', () => { 'tune', ]) }) + test('does not delegate emulator command to list', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2425,6 +2427,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([]) }) + test('delegates emulator list command options', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2437,6 +2440,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([{}]) }) + test('delegates emulator alias list command options', async () => { const emulatorListOptions: Array> = [] const app = createApp({ @@ -2449,6 +2453,7 @@ describe('emulator command', () => { expect(emulatorListOptions).toEqual([{}]) }) + test('does not delegate emulator images command to list', async () => { const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] const app = createApp({ @@ -2471,6 +2476,7 @@ describe('emulator command', () => { expect(emulatorImagesCommand?.commands.map((command) => command.name())).toEqual(['delete', 'install', 'list']) expect(emulatorImagesOptions).toEqual([]) }) + test('delegates emulator images delete command options', async () => { const emulatorImagesDeleteOptions: EmulatorImagesDeleteCommandOptions[] = [] const app = createApp({ @@ -2501,6 +2507,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator images install command options', async () => { const emulatorImagesInstallOptions: EmulatorImagesInstallCommandOptions[] = [] const app = createApp({ @@ -2531,6 +2538,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator images list command options', async () => { const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] const app = createApp({ @@ -2543,6 +2551,7 @@ describe('emulator command', () => { expect(emulatorImagesOptions).toEqual([{ sdkRoot: '/sdk' }]) }) + test('delegates emulator create command options', async () => { const emulatorCreateOptions: EmulatorCreateCommandOptions[] = [] const app = createApp({ @@ -2595,6 +2604,7 @@ describe('emulator command', () => { }, ]) }) + test('delegates emulator delete command options', async () => { const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] const app = createApp({ @@ -2607,6 +2617,7 @@ describe('emulator command', () => { expect(emulatorDeleteOptions).toEqual([{ names: ['Alpha', 'Beta'], sdkRoot: '/sdk' }]) }) + test('delegates emulator delete without names', async () => { const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] const app = createApp({ @@ -2619,6 +2630,7 @@ describe('emulator command', () => { expect(emulatorDeleteOptions).toEqual([{ names: [] }]) }) + test('delegates emulator start command options', async () => { const emulatorStartOptions: EmulatorStartCommandOptions[] = [] const app = createApp({ @@ -2631,6 +2643,7 @@ describe('emulator command', () => { expect(emulatorStartOptions).toEqual([{ name: 'Alpha', sdkRoot: '/sdk', tune: true }]) }) + test('delegates emulator start without name', async () => { const emulatorStartOptions: EmulatorStartCommandOptions[] = [] const app = createApp({ @@ -2643,6 +2656,7 @@ describe('emulator command', () => { expect(emulatorStartOptions).toEqual([{ name: undefined }]) }) + test('delegates emulator stop command options', async () => { const emulatorStopOptions: EmulatorStopCommandOptions[] = [] const app = createApp({ @@ -2655,6 +2669,7 @@ describe('emulator command', () => { expect(emulatorStopOptions).toEqual([{ nameOrSerial: 'Alpha' }]) }) + test('delegates emulator status command options', async () => { const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] const app = createApp({ @@ -2667,6 +2682,7 @@ describe('emulator command', () => { expect(emulatorStatusOptions).toEqual([{ nameOrSerial: 'Alpha' }]) }) + test('delegates emulator status without name or serial', async () => { const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] const app = createApp({ @@ -2679,6 +2695,7 @@ describe('emulator command', () => { expect(emulatorStatusOptions).toEqual([{ nameOrSerial: undefined }]) }) + test('delegates emulator stop without name or serial', async () => { const emulatorStopOptions: EmulatorStopCommandOptions[] = [] const app = createApp({ diff --git a/test/templates.test.ts b/test/templates.test.ts index b561f5b..bc6dc0e 100644 --- a/test/templates.test.ts +++ b/test/templates.test.ts @@ -1022,6 +1022,7 @@ describe('templates command', () => { expect(templatesCommand?.commands.map((command) => command.name())).toEqual(['check', 'generate', 'sync']) }) + test('does not delegate templates command to check', async () => { const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] const app = createApp({ @@ -1040,6 +1041,7 @@ describe('templates command', () => { expect(templatesCheckOptions).toEqual([]) }) + test('delegates templates check command options', async () => { const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] const app = createApp({ @@ -1052,6 +1054,7 @@ describe('templates command', () => { expect(templatesCheckOptions).toEqual([{ root: '/repo' }]) }) + test('delegates templates generate command options', async () => { const templatesGenerateOptions: TemplatesGenerateCommandOptions[] = [] const app = createApp({