diff --git a/src/app.ts b/src/app.ts index 0b91b6c..7a0b88e 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,11 +1,10 @@ -import { Command, InvalidArgumentError, Option } from 'commander' +import { Command, Option } from 'commander' import { readPackageMetadata } from './core/data-access/package-metadata.ts' import { checkForNewerVersion, type VersionCheckOptions, type VersionCheckResult, } from './core/data-access/version-check.ts' -import { parseIntegerOption } from './core/ui/core-ui-command-options.ts' import { formatUpdateWarning } from './core/ui/core-ui-update-warning.ts' import { type CreateCommandOptions, @@ -14,113 +13,29 @@ import { parsePackageManagerOption, runCreate, } from './create/create-feature-index.ts' -import { - type DeviceInstallCommandOptions, - type DeviceListCommandOptions, - type DeviceOpenCommandOptions, - type DeviceTuneCommandOptions, - runDeviceInstall, - runDeviceList, - runDeviceOpen, - runDeviceTune, -} from './device/device-feature-index.ts' -import type { DoctorCommandOptions } from './doctor/doctor-feature-index.ts' -import { runDoctor } from './doctor/doctor-feature-index.ts' -import { - type EmulatorCreateCommandOptions, - type EmulatorDeleteCommandOptions, - type EmulatorImagesCommandOptions, - type EmulatorImagesDeleteCommandOptions, - type EmulatorImagesInstallCommandOptions, - type EmulatorListCommandOptions, - type EmulatorStartCommandOptions, - type EmulatorStatusCommandOptions, - type EmulatorStopCommandOptions, - type EmulatorTuneCommandOptions, - runEmulatorCreate, - runEmulatorDelete, - runEmulatorImages, - runEmulatorImagesDelete, - runEmulatorImagesInstall, - runEmulatorList, - runEmulatorStart, - runEmulatorStatus, - runEmulatorStop, - runEmulatorTune, -} from './emulator/emulator-feature-index.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' import { createLocalnetCommand, type LocalnetCommandDeps } from './localnet/localnet-feature.ts' -import { - type PlaygroundClusterId, - type PlaygroundCommandOptions, - parsePlaygroundClusterId, - runPlayground, -} from './playground/playground-feature-index.ts' -import { - runTemplatesCheck, - runTemplatesGenerate, - runTemplatesSync, - type TemplatesCheckCommandOptions, - type TemplatesGenerateCommandOptions, - type TemplatesSyncCommandOptions, -} from './templates/templates-feature-index.ts' -import { - runWebshellBuild, - runWebshellInit, - type WebshellBuildCommandOptions, - type WebshellInitCommandOptions, -} from './webshell/webshell-feature-index.ts' - -export type AppOptions = LocalnetCommandDeps & { - checkForNewerVersion?: (options: VersionCheckOptions) => Promise - runDeviceInstall?: (options: DeviceInstallCommandOptions) => Promise - runDeviceList?: (options: DeviceListCommandOptions) => Promise - runDeviceOpen?: (options: DeviceOpenCommandOptions) => Promise - runDeviceTune?: (options: DeviceTuneCommandOptions) => Promise - runEmulatorCreate?: (options: EmulatorCreateCommandOptions) => Promise - runEmulatorDelete?: (options: EmulatorDeleteCommandOptions) => Promise - runEmulatorImages?: (options: EmulatorImagesCommandOptions) => Promise - runEmulatorImagesDelete?: (options: EmulatorImagesDeleteCommandOptions) => Promise - runEmulatorImagesInstall?: (options: EmulatorImagesInstallCommandOptions) => Promise - runEmulatorList?: (options: EmulatorListCommandOptions) => Promise - runEmulatorStart?: (options: EmulatorStartCommandOptions) => Promise - runEmulatorStatus?: (options: EmulatorStatusCommandOptions) => Promise - runEmulatorStop?: (options: EmulatorStopCommandOptions) => Promise - runEmulatorTune?: (options: EmulatorTuneCommandOptions) => Promise - runCreate?: (options: CreateCommandOptions) => Promise - runDoctor?: (options: DoctorCommandOptions) => Promise - runPlayground?: (options: PlaygroundCommandOptions) => Promise - runTemplatesCheck?: (options: TemplatesCheckCommandOptions) => Promise - runTemplatesGenerate?: (options: TemplatesGenerateCommandOptions) => Promise - runTemplatesSync?: (options: TemplatesSyncCommandOptions) => Promise - runWebshellBuild?: (options: WebshellBuildCommandOptions) => Promise - runWebshellInit?: (options: WebshellInitCommandOptions) => Promise -} +import { createPlaygroundCommand, type PlaygroundCommandDeps } from './playground/playground-feature.ts' +import { createTemplatesCommand, type TemplatesCommandDeps } from './templates/templates-feature.ts' +import { createWebshellCommand, type WebshellCommandDeps } from './webshell/webshell-feature.ts' + +export type AppOptions = DeviceCommandDeps & + DoctorCommandDeps & + EmulatorCommandDeps & + LocalnetCommandDeps & + PlaygroundCommandDeps & + TemplatesCommandDeps & + WebshellCommandDeps & { + checkForNewerVersion?: (options: VersionCheckOptions) => Promise + runCreate?: (options: CreateCommandOptions) => Promise + } export function createApp(appOptions: AppOptions = {}) { const { checkForNewerVersion: checkForNewerVersionFn = checkForNewerVersion, - runDeviceInstall: runDeviceInstallCommand = runDeviceInstall, - runDeviceList: runDeviceListCommand = runDeviceList, - runDeviceOpen: runDeviceOpenCommand = runDeviceOpen, - runDeviceTune: runDeviceTuneCommand = runDeviceTune, - runEmulatorCreate: runEmulatorCreateCommand = runEmulatorCreate, - runEmulatorDelete: runEmulatorDeleteCommand = runEmulatorDelete, - runEmulatorImages: runEmulatorImagesCommand = runEmulatorImages, - runEmulatorImagesDelete: runEmulatorImagesDeleteCommand = runEmulatorImagesDelete, - runEmulatorImagesInstall: runEmulatorImagesInstallCommand = runEmulatorImagesInstall, - runEmulatorList: runEmulatorListCommand = runEmulatorList, - runEmulatorStart: runEmulatorStartCommand = runEmulatorStart, - runEmulatorStatus: runEmulatorStatusCommand = runEmulatorStatus, - runEmulatorStop: runEmulatorStopCommand = runEmulatorStop, - runEmulatorTune: runEmulatorTuneCommand = runEmulatorTune, runCreate: runCreateCommand = runCreate, - runDoctor: runDoctorCommand = runDoctor, - runPlayground: runPlaygroundCommand = runPlayground, - runTemplatesCheck: runTemplatesCheckCommand = runTemplatesCheck, - runTemplatesGenerate: runTemplatesGenerateCommand = runTemplatesGenerate, - runTemplatesSync: runTemplatesSyncCommand = runTemplatesSync, - runWebshellBuild: runWebshellBuildCommand = runWebshellBuild, - runWebshellInit: runWebshellInitCommand = runWebshellInit, } = appOptions const metadata = readPackageMetadata() const app = new Command() @@ -196,260 +111,16 @@ export function createApp(appOptions: AppOptions = {}) { } } - const deviceCommand = app.command('device').description('Work with connected devices and emulators') - - deviceCommand.action(() => { - deviceCommand.outputHelp() - }) - - const deviceInstallCommand = deviceCommand - .command('install [apks...]') - .description('Install APKs from files, directories, or the APK catalog') - .option('--all', 'Install on every connected device') - .option('--device ', 'Target a device serial') - .option('--downgrade', 'Allow version downgrades (adb install -d)') - .option('--force', 'Re-download catalog APKs even when cached') - .option('--grant', 'Grant all runtime permissions (adb install -g)') - .option('--list', 'List the APKs available in the catalog') - .option('-v, --verbose', 'Verbose output') - .action(async (apks: string[] | undefined, options: Omit) => { - if (options.all && options.device) { - deviceInstallCommand.error( - `error: The --all flag can't be used in combination with --device. Please specify only one.`, - ) - } - - await runDeviceInstallCommand({ ...options, apks: apks ?? [] }) - }) - - deviceCommand - .command('list') - .description('List connected devices and emulators') - .option('--json', 'Print a stable JSON report') - .action(async (options: DeviceListCommandOptions) => { - await runDeviceListCommand(options) - }) - - deviceCommand - .command('open [url]') - .description('Open a URL, port, or deep link on a connected device') - .option('--device ', 'Target a device serial') - .option('--no-forward', 'Do not create an adb reverse for localhost URLs') - .option('-v, --verbose', 'Explain URL and port forwarding decisions') - .action(async (url: string | undefined, options: Omit) => { - await runDeviceOpenCommand({ ...options, url }) - }) - - const deviceTuneCommand = deviceCommand - .command('tune') - .description('Apply agent-friendly tweaks to a connected device or emulator') - .option('--all', 'Tune every connected device') - .option('--device ', 'Target a device serial') - .option('-y, --yes', 'Apply every tweak without prompting') - .action(async (options: DeviceTuneCommandOptions) => { - if (options.all && options.device) { - deviceTuneCommand.error( - `error: The --all flag can't be used in combination with --device. Please specify only one.`, - ) - } - - await runDeviceTuneCommand(options) - }) - - app - .command('doctor') - .description('Check local development dependencies') - .option('--json', 'Print a stable JSON report') - .option('--verbose', 'Include resolved paths and diagnostic details') - .action(async (options: DoctorCommandOptions) => { - process.exitCode = await runDoctorCommand(options) - }) - - const emulatorCommand = app.command('emulator').alias('emu').description('Manage Android emulators') - - emulatorCommand.action(() => { - emulatorCommand.outputHelp() - }) - - emulatorCommand - .command('create [name]') - .description('Create or update an Android emulator') - .option('--data-size ', 'Data partition size') - .option('--device ', 'Android device profile id') - .option('--profile ', 'Solana Mobile emulator profile') - .option('--ram-mb ', 'RAM size in MB', parseIntegerOption) - .option('--sdcard-size ', 'SD card size') - .option('--sdk-root ', 'Android SDK root') - .option('--start', 'Start the emulator after creating it') - .option('--system-image ', 'Android system image package') - .option('--tune', 'Apply emulator tweaks after starting (requires --start)') - .option('-v, --verbose', 'Verbose output') - .option('--vm-heap-mb ', 'VM heap size in MB', parseIntegerOption) - .action(async (name: string | undefined, options: Omit) => { - await runEmulatorCreateCommand({ ...options, name }) - }) - - emulatorCommand - .command('delete [names...]') - .description('Delete Android emulators') - .option('--sdk-root ', 'Android SDK root') - .action(async (names: string[] | undefined, options: Omit) => { - await runEmulatorDeleteCommand({ ...options, names: names ?? [] }) - }) - - const emulatorImagesCommand = emulatorCommand.command('images').description('Manage Android system images') - - emulatorImagesCommand.action(() => { - emulatorImagesCommand.outputHelp() - }) - - emulatorImagesCommand - .command('delete [systemImages...]') - .description('Delete installed Android system images') - .option('--sdk-root ', 'Android SDK root') - .option('-v, --verbose', 'Verbose output') - .action( - async (systemImages: string[] | undefined, options: Omit) => { - await runEmulatorImagesDeleteCommand({ ...options, systemImages: systemImages ?? [] }) - }, - ) - - emulatorImagesCommand - .command('install [systemImage]') - .description('Install an Android system image') - .option('--all', 'Show all available system images') - .option('--sdk-root ', 'Android SDK root') - .option('-v, --verbose', 'Verbose output') - .action( - async (systemImage: string | undefined, options: Omit) => { - await runEmulatorImagesInstallCommand({ ...options, systemImage }) - }, - ) - - emulatorImagesCommand - .command('list') - .description('List installed Android system images') - .option('--sdk-root ', 'Android SDK root') - .action(async (options: EmulatorImagesCommandOptions) => { - await runEmulatorImagesCommand(options) - }) - - emulatorCommand - .command('list') - .description('List installed Android emulators') - .action(async (options: EmulatorListCommandOptions) => { - await runEmulatorListCommand(options) - }) - - emulatorCommand - .command('start [name]') - .description('Start an Android emulator') - .option('--sdk-root ', 'Android SDK root') - .option('--tune', 'Apply emulator tweaks after starting') - .action(async (name: string | undefined, options: Omit) => { - await runEmulatorStartCommand({ ...options, name }) - }) - - emulatorCommand - .command('status [nameOrSerial]') - .description('Show Android emulator status') - .action(async (nameOrSerial: string | undefined) => { - await runEmulatorStatusCommand({ nameOrSerial }) - }) - - emulatorCommand - .command('stop [nameOrSerial]') - .description('Stop a running Android emulator') - .action(async (nameOrSerial: string | undefined) => { - await runEmulatorStopCommand({ nameOrSerial }) - }) - - emulatorCommand - .command('tune [nameOrSerial]') - .description('Apply agent-friendly tweaks to a running Android emulator') - .option('-y, --yes', 'Apply every tweak without prompting') - .action(async (nameOrSerial: string | undefined, options: Omit) => { - await runEmulatorTuneCommand({ ...options, nameOrSerial }) - }) - + // 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(createDeviceCommand(appOptions)) + app.addCommand(createDoctorCommand(appOptions)) + app.addCommand(createEmulatorCommand(appOptions)) app.addCommand(createLocalnetCommand(appOptions)) - - app - .command('playground') - .description('Serve a wallet testing page and open it on a connected device') - .option('--cluster ', 'Cluster: devnet, localnet, mainnet, or testnet', parseClusterOption) - .option('--device ', 'Target a device serial') - .option('--no-open', 'Do not open the page on the device') - .option('--port ', 'Host port for the playground server', parseIntegerOption) - .option('--url ', 'Custom RPC URL for the selected cluster') - .option('-v, --verbose', 'Verbose output') - .action(async (options: PlaygroundCommandOptions) => { - await runPlaygroundCommand(options) - }) - - const templatesCommand = app.command('templates').description('Manage template repositories') - - templatesCommand.action(() => { - templatesCommand.outputHelp() - }) - - templatesCommand - .command('check') - .description('Check generated template artifacts') - .option('--root ', 'Template repository root') - .action(async (options: TemplatesCheckCommandOptions) => { - await runTemplatesCheckCommand(options) - }) - - templatesCommand - .command('generate') - .description('Generate template artifacts') - .option('--root ', 'Template repository root') - .action(async (options: TemplatesGenerateCommandOptions) => { - await runTemplatesGenerateCommand(options) - }) - - templatesCommand - .command('sync ') - .description('Sync git-tracked templates to another template repository') - .option('--dry-run', 'Show what would change without writing') - .option('--force', 'Sync even if the target has uncommitted changes') - .option('--root ', 'Template repository root') - .action(async (target: string, options: Omit) => { - await runTemplatesSyncCommand({ ...options, target }) - }) - - const webshellCommand = app.command('webshell').description('Wrap a web app in an Android WebView shell') - - webshellCommand.action(() => { - webshellCommand.outputHelp() - }) - - webshellCommand - .command('init [directory]') - .description('Generate an Android WebView project for a web app') - .option('--app-name ', 'Application display name') - .option('--application-id ', 'Android application id (e.g. com.example.app)') - .option('--force', 'Overwrite an existing directory') - .option('--keystore-alias ', 'Signing keystore alias') - .option('--keystore-path ', 'Signing keystore path (created when missing)') - .option('--manifest ', 'Web manifest.json or Bubblewrap twa-manifest.json') - .option('--url ', 'Web app URL to wrap') - .option('--version-code ', 'Android versionCode', parseIntegerOption) - .option('--version-name ', 'Android versionName') - .action(async (directory: string | undefined, options: Omit) => { - await runWebshellInitCommand({ ...options, directory }) - }) - - webshellCommand - .command('build [directory]') - .description('Build a release APK from a webshell project') - .option('--keystore-alias ', 'Signing keystore alias') - .option('--keystore-path ', 'Signing keystore path') - .option('--stacktrace', 'Pass --stacktrace to Gradle') - .action(async (directory: string | undefined, options: Omit) => { - await runWebshellBuildCommand({ ...options, directory }) - }) + app.addCommand(createPlaygroundCommand(appOptions)) + app.addCommand(createTemplatesCommand(appOptions)) + app.addCommand(createWebshellCommand(appOptions)) // Commands added with `addCommand` do not inherit the root's settings the way `command()` copies // them, so a feature-owned command would silently lose `enablePositionalOptions` and @@ -494,11 +165,3 @@ export async function runApp(argv = process.argv, options: AppOptions = {}) { await app.parseAsync(argv) } - -function parseClusterOption(value: string): PlaygroundClusterId { - try { - return parsePlaygroundClusterId(value) - } catch (error) { - throw new InvalidArgumentError(error instanceof Error ? error.message : String(error)) - } -} diff --git a/src/device/device-feature-index.ts b/src/device/device-feature-index.ts deleted file mode 100644 index be20574..0000000 --- a/src/device/device-feature-index.ts +++ /dev/null @@ -1,11 +0,0 @@ -export type { - ConnectedDevice, - DeviceInstallCommandOptions, - DeviceListCommandOptions, - DeviceOpenCommandOptions, - DeviceTuneCommandOptions, -} from './data-access/device-types.ts' -export { runDeviceInstall } from './device-feature-install.ts' -export { runDeviceList } from './device-feature-list.ts' -export { runDeviceOpen } from './device-feature-open.ts' -export { runDeviceTune } from './device-feature-tune.ts' diff --git a/src/device/device-feature.ts b/src/device/device-feature.ts new file mode 100644 index 0000000..00fcee2 --- /dev/null +++ b/src/device/device-feature.ts @@ -0,0 +1,87 @@ +import { Command } from 'commander' +import type { + DeviceInstallCommandOptions, + DeviceListCommandOptions, + DeviceOpenCommandOptions, + DeviceTuneCommandOptions, +} from './data-access/device-types.ts' +import { runDeviceInstall } from './device-feature-install.ts' +import { runDeviceList } from './device-feature-list.ts' +import { runDeviceOpen } from './device-feature-open.ts' +import { runDeviceTune } from './device-feature-tune.ts' + +export type DeviceCommandDeps = { + runDeviceInstall?: (options: DeviceInstallCommandOptions) => Promise + runDeviceList?: (options: DeviceListCommandOptions) => Promise + runDeviceOpen?: (options: DeviceOpenCommandOptions) => Promise + runDeviceTune?: (options: DeviceTuneCommandOptions) => Promise +} + +export function createDeviceCommand({ + runDeviceInstall: runDeviceInstallCommand = runDeviceInstall, + runDeviceList: runDeviceListCommand = runDeviceList, + runDeviceOpen: runDeviceOpenCommand = runDeviceOpen, + runDeviceTune: runDeviceTuneCommand = runDeviceTune, +}: DeviceCommandDeps = {}): Command { + const deviceCommand = new Command('device').description('Work with connected devices and emulators') + + deviceCommand.action(() => { + deviceCommand.outputHelp() + }) + + const deviceInstallCommand = deviceCommand + .command('install [apks...]') + .description('Install APKs from files, directories, or the APK catalog') + .option('--all', 'Install on every connected device') + .option('--device ', 'Target a device serial') + .option('--downgrade', 'Allow version downgrades (adb install -d)') + .option('--force', 'Re-download catalog APKs even when cached') + .option('--grant', 'Grant all runtime permissions (adb install -g)') + .option('--list', 'List the APKs available in the catalog') + .option('-v, --verbose', 'Verbose output') + .action(async (apks: string[] | undefined, options: Omit) => { + if (options.all && options.device) { + deviceInstallCommand.error( + `error: The --all flag can't be used in combination with --device. Please specify only one.`, + ) + } + + await runDeviceInstallCommand({ ...options, apks: apks ?? [] }) + }) + + deviceCommand + .command('list') + .description('List connected devices and emulators') + .option('--json', 'Print a stable JSON report') + .action(async (options: DeviceListCommandOptions) => { + await runDeviceListCommand(options) + }) + + deviceCommand + .command('open [url]') + .description('Open a URL, port, or deep link on a connected device') + .option('--device ', 'Target a device serial') + .option('--no-forward', 'Do not create an adb reverse for localhost URLs') + .option('-v, --verbose', 'Explain URL and port forwarding decisions') + .action(async (url: string | undefined, options: Omit) => { + await runDeviceOpenCommand({ ...options, url }) + }) + + const deviceTuneCommand = deviceCommand + .command('tune') + .description('Apply agent-friendly tweaks to a connected device or emulator') + .option('--all', 'Tune every connected device') + .option('--device ', 'Target a device serial') + .option('-y, --yes', 'Apply every tweak without prompting') + .action(async (options: DeviceTuneCommandOptions) => { + if (options.all && options.device) { + deviceTuneCommand.error( + `error: The --all flag can't be used in combination with --device. Please specify only one.`, + ) + } + + await runDeviceTuneCommand(options) + }) + + return deviceCommand +} diff --git a/src/doctor/data-access/doctor-report.ts b/src/doctor/data-access/doctor-report.ts new file mode 100644 index 0000000..8cc7e4d --- /dev/null +++ b/src/doctor/data-access/doctor-report.ts @@ -0,0 +1,27 @@ +import type { DoctorCapabilities, DoctorCheckResult, DoctorReport } from './doctor-check-result.ts' + +export function buildDoctorReport(checks: DoctorCheckResult[]): DoctorReport { + const capabilities = deriveCapabilities(checks) + return { + capabilities, + checks, + ready: capabilities.projectCreation && capabilities.androidBuild, + recommendations: [...new Set(checks.flatMap(({ recommendation }) => (recommendation ? [recommendation] : [])))], + } +} + +export function deriveCapabilities(checks: DoctorCheckResult[]): DoctorCapabilities { + const passes = (name: string) => checks.some((check) => check.name === name && check.status === 'pass') + const androidBuild = ['Android SDK', 'Android platforms', 'Build Tools', 'Java', 'Java compiler', 'adb'].every(passes) + const projectCreation = ['Node.js', 'Package managers'].every(passes) + return { + androidBuild, + emulator: androidBuild && ['Android emulators', 'Emulator', 'avdmanager'].every(passes), + physicalDevice: androidBuild && passes('Physical devices'), + projectCreation, + } +} + +export function getDoctorExitCode(report: DoctorReport) { + return report.checks.some(({ status }) => status === 'fail') ? 1 : 0 +} diff --git a/src/doctor/doctor-feature-index.ts b/src/doctor/doctor-feature-check.ts similarity index 61% rename from src/doctor/doctor-feature-index.ts rename to src/doctor/doctor-feature-check.ts index 47192bd..f76a52f 100644 --- a/src/doctor/doctor-feature-index.ts +++ b/src/doctor/doctor-feature-check.ts @@ -8,8 +8,9 @@ import { checkOperatingSystem, checkPackageManagers, } from './data-access/check-system-and-javascript.ts' -import type { DoctorCapabilities, DoctorCheckResult, DoctorReport } from './data-access/doctor-check-result.ts' +import type { DoctorReport } from './data-access/doctor-check-result.ts' import { type DoctorEnvironment, defaultDoctorEnvironment } from './data-access/doctor-environment.ts' +import { buildDoctorReport, getDoctorExitCode } from './data-access/doctor-report.ts' import { renderDoctorReport } from './ui/doctor-ui-report.ts' export type DoctorCommandOptions = { json?: boolean; verbose?: boolean } @@ -43,35 +44,9 @@ export async function createDoctorReport( return buildDoctorReport(checks) } -export function buildDoctorReport(checks: DoctorCheckResult[]): DoctorReport { - const capabilities = deriveCapabilities(checks) - return { - capabilities, - checks, - ready: capabilities.projectCreation && capabilities.androidBuild, - recommendations: [...new Set(checks.flatMap(({ recommendation }) => (recommendation ? [recommendation] : [])))], - } -} - -export function deriveCapabilities(checks: DoctorCheckResult[]): DoctorCapabilities { - const passes = (name: string) => checks.some((check) => check.name === name && check.status === 'pass') - const androidBuild = ['Android SDK', 'Android platforms', 'Build Tools', 'Java', 'Java compiler', 'adb'].every(passes) - const projectCreation = ['Node.js', 'Package managers'].every(passes) - return { - androidBuild, - emulator: androidBuild && ['Android emulators', 'Emulator', 'avdmanager'].every(passes), - physicalDevice: androidBuild && passes('Physical devices'), - projectCreation, - } -} - export async function runDoctor(options: DoctorCommandOptions = {}) { const report = await createDoctorReport() if (options.json) process.stdout.write(`${JSON.stringify(report, null, 2)}\n`) else renderDoctorReport(report, options.verbose) return getDoctorExitCode(report) } - -export function getDoctorExitCode(report: DoctorReport) { - return report.checks.some(({ status }) => status === 'fail') ? 1 : 0 -} diff --git a/src/doctor/doctor-feature.ts b/src/doctor/doctor-feature.ts new file mode 100644 index 0000000..dffbdf6 --- /dev/null +++ b/src/doctor/doctor-feature.ts @@ -0,0 +1,16 @@ +import { Command } from 'commander' +import { type DoctorCommandOptions, runDoctor } from './doctor-feature-check.ts' + +export type DoctorCommandDeps = { + runDoctor?: (options: DoctorCommandOptions) => Promise +} + +export function createDoctorCommand({ runDoctor: runDoctorCommand = runDoctor }: DoctorCommandDeps = {}): Command { + return new Command('doctor') + .description('Check local development dependencies') + .option('--json', 'Print a stable JSON report') + .option('--verbose', 'Include resolved paths and diagnostic details') + .action(async (options: DoctorCommandOptions) => { + process.exitCode = await runDoctorCommand(options) + }) +} diff --git a/src/emulator/emulator-feature-index.ts b/src/emulator/emulator-feature-index.ts deleted file mode 100644 index 4851058..0000000 --- a/src/emulator/emulator-feature-index.ts +++ /dev/null @@ -1,24 +0,0 @@ -export type { - EmulatorCreateCommandOptions, - EmulatorDeleteCommandOptions, - EmulatorImagesCommandOptions, - EmulatorImagesDeleteCommandOptions, - EmulatorImagesInstallCommandOptions, - EmulatorListCommandOptions, - EmulatorStartCommandOptions, - EmulatorStatusCommandOptions, - EmulatorStopCommandOptions, - EmulatorTuneCommandOptions, -} from './data-access/emulator-types.ts' -export { runEmulatorCreate } from './emulator-feature-create.ts' -export { runEmulatorDelete } from './emulator-feature-delete.ts' -export { - runEmulatorImages, - runEmulatorImagesDelete, - runEmulatorImagesInstall, -} from './emulator-feature-images.ts' -export { runEmulatorList } from './emulator-feature-list.ts' -export { runEmulatorStart } from './emulator-feature-start.ts' -export { runEmulatorStatus } from './emulator-feature-status.ts' -export { runEmulatorStop } from './emulator-feature-stop.ts' -export { runEmulatorTune, waitAndTuneEmulator } from './emulator-feature-tune.ts' diff --git a/src/emulator/emulator-feature.ts b/src/emulator/emulator-feature.ts new file mode 100644 index 0000000..29272a5 --- /dev/null +++ b/src/emulator/emulator-feature.ts @@ -0,0 +1,157 @@ +import { Command } from 'commander' +import { parseIntegerOption } from '../core/ui/core-ui-command-options.ts' +import type { + EmulatorCreateCommandOptions, + EmulatorDeleteCommandOptions, + EmulatorImagesCommandOptions, + EmulatorImagesDeleteCommandOptions, + EmulatorImagesInstallCommandOptions, + EmulatorListCommandOptions, + EmulatorStartCommandOptions, + EmulatorStatusCommandOptions, + EmulatorStopCommandOptions, + EmulatorTuneCommandOptions, +} from './data-access/emulator-types.ts' +import { runEmulatorCreate } from './emulator-feature-create.ts' +import { runEmulatorDelete } from './emulator-feature-delete.ts' +import { runEmulatorImages, runEmulatorImagesDelete, runEmulatorImagesInstall } from './emulator-feature-images.ts' +import { runEmulatorList } from './emulator-feature-list.ts' +import { runEmulatorStart } from './emulator-feature-start.ts' +import { runEmulatorStatus } from './emulator-feature-status.ts' +import { runEmulatorStop } from './emulator-feature-stop.ts' +import { runEmulatorTune } from './emulator-feature-tune.ts' + +export type EmulatorCommandDeps = { + runEmulatorCreate?: (options: EmulatorCreateCommandOptions) => Promise + runEmulatorDelete?: (options: EmulatorDeleteCommandOptions) => Promise + runEmulatorImages?: (options: EmulatorImagesCommandOptions) => Promise + runEmulatorImagesDelete?: (options: EmulatorImagesDeleteCommandOptions) => Promise + runEmulatorImagesInstall?: (options: EmulatorImagesInstallCommandOptions) => Promise + runEmulatorList?: (options: EmulatorListCommandOptions) => Promise + runEmulatorStart?: (options: EmulatorStartCommandOptions) => Promise + runEmulatorStatus?: (options: EmulatorStatusCommandOptions) => Promise + runEmulatorStop?: (options: EmulatorStopCommandOptions) => Promise + runEmulatorTune?: (options: EmulatorTuneCommandOptions) => Promise +} + +export function createEmulatorCommand({ + runEmulatorCreate: runEmulatorCreateCommand = runEmulatorCreate, + runEmulatorDelete: runEmulatorDeleteCommand = runEmulatorDelete, + runEmulatorImages: runEmulatorImagesCommand = runEmulatorImages, + runEmulatorImagesDelete: runEmulatorImagesDeleteCommand = runEmulatorImagesDelete, + runEmulatorImagesInstall: runEmulatorImagesInstallCommand = runEmulatorImagesInstall, + runEmulatorList: runEmulatorListCommand = runEmulatorList, + runEmulatorStart: runEmulatorStartCommand = runEmulatorStart, + runEmulatorStatus: runEmulatorStatusCommand = runEmulatorStatus, + runEmulatorStop: runEmulatorStopCommand = runEmulatorStop, + runEmulatorTune: runEmulatorTuneCommand = runEmulatorTune, +}: EmulatorCommandDeps = {}): Command { + const emulatorCommand = new Command('emulator').alias('emu').description('Manage Android emulators') + + emulatorCommand.action(() => { + emulatorCommand.outputHelp() + }) + + emulatorCommand + .command('create [name]') + .description('Create or update an Android emulator') + .option('--data-size ', 'Data partition size') + .option('--device ', 'Android device profile id') + .option('--profile ', 'Solana Mobile emulator profile') + .option('--ram-mb ', 'RAM size in MB', parseIntegerOption) + .option('--sdcard-size ', 'SD card size') + .option('--sdk-root ', 'Android SDK root') + .option('--start', 'Start the emulator after creating it') + .option('--system-image ', 'Android system image package') + .option('--tune', 'Apply emulator tweaks after starting (requires --start)') + .option('-v, --verbose', 'Verbose output') + .option('--vm-heap-mb ', 'VM heap size in MB', parseIntegerOption) + .action(async (name: string | undefined, options: Omit) => { + await runEmulatorCreateCommand({ ...options, name }) + }) + + emulatorCommand + .command('delete [names...]') + .description('Delete Android emulators') + .option('--sdk-root ', 'Android SDK root') + .action(async (names: string[] | undefined, options: Omit) => { + await runEmulatorDeleteCommand({ ...options, names: names ?? [] }) + }) + + const emulatorImagesCommand = emulatorCommand.command('images').description('Manage Android system images') + + emulatorImagesCommand.action(() => { + emulatorImagesCommand.outputHelp() + }) + + emulatorImagesCommand + .command('delete [systemImages...]') + .description('Delete installed Android system images') + .option('--sdk-root ', 'Android SDK root') + .option('-v, --verbose', 'Verbose output') + .action( + async (systemImages: string[] | undefined, options: Omit) => { + await runEmulatorImagesDeleteCommand({ ...options, systemImages: systemImages ?? [] }) + }, + ) + + emulatorImagesCommand + .command('install [systemImage]') + .description('Install an Android system image') + .option('--all', 'Show all available system images') + .option('--sdk-root ', 'Android SDK root') + .option('-v, --verbose', 'Verbose output') + .action( + async (systemImage: string | undefined, options: Omit) => { + await runEmulatorImagesInstallCommand({ ...options, systemImage }) + }, + ) + + emulatorImagesCommand + .command('list') + .description('List installed Android system images') + .option('--sdk-root ', 'Android SDK root') + .action(async (options: EmulatorImagesCommandOptions) => { + await runEmulatorImagesCommand(options) + }) + + emulatorCommand + .command('list') + .description('List installed Android emulators') + .action(async (options: EmulatorListCommandOptions) => { + await runEmulatorListCommand(options) + }) + + emulatorCommand + .command('start [name]') + .description('Start an Android emulator') + .option('--sdk-root ', 'Android SDK root') + .option('--tune', 'Apply emulator tweaks after starting') + .action(async (name: string | undefined, options: Omit) => { + await runEmulatorStartCommand({ ...options, name }) + }) + + emulatorCommand + .command('status [nameOrSerial]') + .description('Show Android emulator status') + .action(async (nameOrSerial: string | undefined) => { + await runEmulatorStatusCommand({ nameOrSerial }) + }) + + emulatorCommand + .command('stop [nameOrSerial]') + .description('Stop a running Android emulator') + .action(async (nameOrSerial: string | undefined) => { + await runEmulatorStopCommand({ nameOrSerial }) + }) + + emulatorCommand + .command('tune [nameOrSerial]') + .description('Apply agent-friendly tweaks to a running Android emulator') + .option('-y, --yes', 'Apply every tweak without prompting') + .action(async (nameOrSerial: string | undefined, options: Omit) => { + await runEmulatorTuneCommand({ ...options, nameOrSerial }) + }) + + return emulatorCommand +} diff --git a/src/playground/playground-feature-index.ts b/src/playground/playground-feature-serve.ts similarity index 97% rename from src/playground/playground-feature-index.ts rename to src/playground/playground-feature-serve.ts index 68d0986..5ecefe4 100644 --- a/src/playground/playground-feature-index.ts +++ b/src/playground/playground-feature-serve.ts @@ -21,9 +21,6 @@ import { startPlaygroundServer } from './data-access/playground-server.ts' import type { PlaygroundCommandOptions } from './data-access/playground-types.ts' import { renderPlaygroundEvent } from './ui/playground-ui-messages.ts' -export { parsePlaygroundClusterId } from './data-access/playground-clusters.ts' -export type { PlaygroundClusterId, PlaygroundCommandOptions } from './data-access/playground-types.ts' - interface RunPlaygroundDependencies extends AdbDependencies, PromptDependencies { cancel?: (message: string) => void formatCommand?: typeof formatCliCommand diff --git a/src/playground/playground-feature.ts b/src/playground/playground-feature.ts new file mode 100644 index 0000000..be5bf54 --- /dev/null +++ b/src/playground/playground-feature.ts @@ -0,0 +1,38 @@ +import { Command, InvalidArgumentError } from 'commander' +import { parseIntegerOption } from '../core/ui/core-ui-command-options.ts' +import { parsePlaygroundClusterId } from './data-access/playground-clusters.ts' +import type { PlaygroundClusterId, PlaygroundCommandOptions } from './data-access/playground-types.ts' +import { runPlayground } from './playground-feature-serve.ts' + +export type PlaygroundCommandDeps = { + runPlayground?: (options: PlaygroundCommandOptions) => Promise +} + +export function createPlaygroundCommand({ + runPlayground: runPlaygroundCommand = runPlayground, +}: PlaygroundCommandDeps = {}): Command { + return new Command('playground') + .description('Serve a wallet testing page and open it on a connected device') + .option('--cluster ', 'Cluster: devnet, localnet, mainnet, or testnet', parseClusterOption) + .option('--device ', 'Target a device serial') + .option('--no-open', 'Do not open the page on the device') + .option('--port ', 'Host port for the playground server', parseIntegerOption) + .option('--url ', 'Custom RPC URL for the selected cluster') + .option('-v, --verbose', 'Verbose output') + .action(async (options: PlaygroundCommandOptions) => { + await runPlaygroundCommand(options) + }) +} + +/** + * Commander only renders a concise usage error for `InvalidArgumentError`; anything else escapes parsing + * and the built CLI prints a stack trace. The cluster parser itself stays free of Commander so it can be + * used outside the CLI boundary. + */ +function parseClusterOption(value: string): PlaygroundClusterId { + try { + return parsePlaygroundClusterId(value) + } catch (error) { + throw new InvalidArgumentError(error instanceof Error ? error.message : String(error)) + } +} diff --git a/src/templates.ts b/src/templates.ts index c7244ce..c5f94a7 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -1,33 +1,38 @@ export type { - TemplateGroupConfig, - TemplateMetadata, - TemplatePackageJson, - TemplateRepository, - TemplateRepositoryArtifact, - TemplateRepositoryCheckResult, - TemplateRepositoryGroup, - TemplateRepositoryPackageJson, - TemplateRepositoryWriteResult, TemplateSyncAction, TemplateSyncActionKind, TemplateSyncDependencies, TemplateSyncPlan, - TemplatesCheckCommandOptions, - TemplatesGenerateCommandOptions, - TemplatesSyncCommandOptions, TrackedFile, -} from './templates/templates-feature-index.ts' +} from './templates/data-access/sync-template-repository.ts' export { applyTemplateSync, - checkTemplateRepository, listWorkTreeChanges, planTemplateSync, +} from './templates/data-access/sync-template-repository.ts' +export type { + TemplateMetadata, + TemplateRepository, + TemplateRepositoryArtifact, + TemplateRepositoryCheckResult, + TemplateRepositoryGroup, + TemplateRepositoryWriteResult, +} from './templates/data-access/template-repository.ts' +export { + checkTemplateRepository, renderTemplateRepository, - runTemplatesCheck, - runTemplatesGenerate, - runTemplatesSync, + writeTemplateRepository, +} from './templates/data-access/template-repository.ts' +export type { + TemplateGroupConfig, + TemplatePackageJson, + TemplateRepositoryPackageJson, +} from './templates/data-access/template-repository-schema.ts' +export { TemplateGroupConfigSchema, TemplatePackageJsonSchema, TemplateRepositoryPackageJsonSchema, - writeTemplateRepository, -} from './templates/templates-feature-index.ts' +} from './templates/data-access/template-repository-schema.ts' +export { runTemplatesCheck, type TemplatesCheckCommandOptions } from './templates/templates-feature-check.ts' +export { runTemplatesGenerate, type TemplatesGenerateCommandOptions } from './templates/templates-feature-generate.ts' +export { runTemplatesSync, type TemplatesSyncCommandOptions } from './templates/templates-feature-sync.ts' diff --git a/src/templates/templates-feature-index.ts b/src/templates/templates-feature-index.ts deleted file mode 100644 index 5a631db..0000000 --- a/src/templates/templates-feature-index.ts +++ /dev/null @@ -1,34 +0,0 @@ -export type { - TemplateSyncAction, - TemplateSyncActionKind, - TemplateSyncDependencies, - TemplateSyncPlan, - TrackedFile, -} from './data-access/sync-template-repository.ts' -export { applyTemplateSync, listWorkTreeChanges, planTemplateSync } from './data-access/sync-template-repository.ts' -export type { - TemplateMetadata, - TemplateRepository, - TemplateRepositoryArtifact, - TemplateRepositoryCheckResult, - TemplateRepositoryGroup, - TemplateRepositoryWriteResult, -} from './data-access/template-repository.ts' -export { - checkTemplateRepository, - renderTemplateRepository, - writeTemplateRepository, -} from './data-access/template-repository.ts' -export type { - TemplateGroupConfig, - TemplatePackageJson, - TemplateRepositoryPackageJson, -} from './data-access/template-repository-schema.ts' -export { - TemplateGroupConfigSchema, - TemplatePackageJsonSchema, - TemplateRepositoryPackageJsonSchema, -} from './data-access/template-repository-schema.ts' -export { runTemplatesCheck, type TemplatesCheckCommandOptions } from './templates-feature-check.ts' -export { runTemplatesGenerate, type TemplatesGenerateCommandOptions } from './templates-feature-generate.ts' -export { runTemplatesSync, type TemplatesSyncCommandOptions } from './templates-feature-sync.ts' diff --git a/src/templates/templates-feature.ts b/src/templates/templates-feature.ts new file mode 100644 index 0000000..8d4ad05 --- /dev/null +++ b/src/templates/templates-feature.ts @@ -0,0 +1,50 @@ +import { Command } from 'commander' +import { runTemplatesCheck, type TemplatesCheckCommandOptions } from './templates-feature-check.ts' +import { runTemplatesGenerate, type TemplatesGenerateCommandOptions } from './templates-feature-generate.ts' +import { runTemplatesSync, type TemplatesSyncCommandOptions } from './templates-feature-sync.ts' + +export type TemplatesCommandDeps = { + runTemplatesCheck?: (options: TemplatesCheckCommandOptions) => Promise + runTemplatesGenerate?: (options: TemplatesGenerateCommandOptions) => Promise + runTemplatesSync?: (options: TemplatesSyncCommandOptions) => Promise +} + +export function createTemplatesCommand({ + runTemplatesCheck: runTemplatesCheckCommand = runTemplatesCheck, + runTemplatesGenerate: runTemplatesGenerateCommand = runTemplatesGenerate, + runTemplatesSync: runTemplatesSyncCommand = runTemplatesSync, +}: TemplatesCommandDeps = {}): Command { + const templatesCommand = new Command('templates').description('Manage template repositories') + + templatesCommand.action(() => { + templatesCommand.outputHelp() + }) + + templatesCommand + .command('check') + .description('Check generated template artifacts') + .option('--root ', 'Template repository root') + .action(async (options: TemplatesCheckCommandOptions) => { + await runTemplatesCheckCommand(options) + }) + + templatesCommand + .command('generate') + .description('Generate template artifacts') + .option('--root ', 'Template repository root') + .action(async (options: TemplatesGenerateCommandOptions) => { + await runTemplatesGenerateCommand(options) + }) + + templatesCommand + .command('sync ') + .description('Sync git-tracked templates to another template repository') + .option('--dry-run', 'Show what would change without writing') + .option('--force', 'Sync even if the target has uncommitted changes') + .option('--root ', 'Template repository root') + .action(async (target: string, options: Omit) => { + await runTemplatesSyncCommand({ ...options, target }) + }) + + return templatesCommand +} diff --git a/src/webshell/webshell-feature-index.ts b/src/webshell/webshell-feature-index.ts deleted file mode 100644 index 413403c..0000000 --- a/src/webshell/webshell-feature-index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { WebshellBuildCommandOptions, WebshellInitCommandOptions } from './data-access/webshell-types.ts' -export { runWebshellBuild } from './webshell-feature-build.ts' -export { runWebshellInit } from './webshell-feature-init.ts' diff --git a/src/webshell/webshell-feature.ts b/src/webshell/webshell-feature.ts new file mode 100644 index 0000000..f4700aa --- /dev/null +++ b/src/webshell/webshell-feature.ts @@ -0,0 +1,49 @@ +import { Command } from 'commander' +import { parseIntegerOption } from '../core/ui/core-ui-command-options.ts' +import type { WebshellBuildCommandOptions, WebshellInitCommandOptions } from './data-access/webshell-types.ts' +import { runWebshellBuild } from './webshell-feature-build.ts' +import { runWebshellInit } from './webshell-feature-init.ts' + +export type WebshellCommandDeps = { + runWebshellBuild?: (options: WebshellBuildCommandOptions) => Promise + runWebshellInit?: (options: WebshellInitCommandOptions) => Promise +} + +export function createWebshellCommand({ + runWebshellBuild: runWebshellBuildCommand = runWebshellBuild, + runWebshellInit: runWebshellInitCommand = runWebshellInit, +}: WebshellCommandDeps = {}): Command { + const webshellCommand = new Command('webshell').description('Wrap a web app in an Android WebView shell') + + webshellCommand.action(() => { + webshellCommand.outputHelp() + }) + + webshellCommand + .command('init [directory]') + .description('Generate an Android WebView project for a web app') + .option('--app-name ', 'Application display name') + .option('--application-id ', 'Android application id (e.g. com.example.app)') + .option('--force', 'Overwrite an existing directory') + .option('--keystore-alias ', 'Signing keystore alias') + .option('--keystore-path ', 'Signing keystore path (created when missing)') + .option('--manifest ', 'Web manifest.json or Bubblewrap twa-manifest.json') + .option('--url ', 'Web app URL to wrap') + .option('--version-code ', 'Android versionCode', parseIntegerOption) + .option('--version-name ', 'Android versionName') + .action(async (directory: string | undefined, options: Omit) => { + await runWebshellInitCommand({ ...options, directory }) + }) + + webshellCommand + .command('build [directory]') + .description('Build a release APK from a webshell project') + .option('--keystore-alias ', 'Signing keystore alias') + .option('--keystore-path ', 'Signing keystore path') + .option('--stacktrace', 'Pass --stacktrace to Gradle') + .action(async (directory: string | undefined, options: Omit) => { + await runWebshellBuildCommand({ ...options, directory }) + }) + + return webshellCommand +} diff --git a/test/core.test.ts b/test/core.test.ts index 099fc58..9843d7c 100644 --- a/test/core.test.ts +++ b/test/core.test.ts @@ -11,22 +11,6 @@ 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' -import type { DeviceTuneCommandOptions } from '../src/device/device-feature-index.ts' -import type { - EmulatorCreateCommandOptions, - EmulatorDeleteCommandOptions, - EmulatorImagesCommandOptions, - EmulatorImagesDeleteCommandOptions, - EmulatorImagesInstallCommandOptions, - EmulatorStartCommandOptions, - EmulatorStatusCommandOptions, - EmulatorStopCommandOptions, - EmulatorTuneCommandOptions, -} from '../src/emulator/emulator-feature-index.ts' -import type { - TemplatesCheckCommandOptions, - TemplatesGenerateCommandOptions, -} from '../src/templates/templates-feature-index.ts' const packageJson = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { description: string @@ -370,418 +354,26 @@ describe('app', () => { expect(checkCalled).toBe(false) }) - test('registers device subcommands', () => { - const deviceCommand = createApp().commands.find((command) => command.name() === 'device') + test('copies the root settings into every feature-owned command', async () => { + // Commander copies the root's settings into commands made with `command()` but not into ones + // passed to `addCommand`, which is how every feature-owned command is registered. Without + // createApp's copy pass they each lose `showHelpAfterError` (and `enablePositionalOptions`), so + // this asserts the behaviour on all of them at once rather than one feature at a time. + for (const name of ['device', 'doctor', 'emulator', 'localnet', 'playground', 'templates', 'webshell']) { + const errors: string[] = [] + const app = createApp() + const command = app.commands.find((child) => child.name() === name) - 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({ - runDeviceTune: async (options) => { - deviceTuneOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--device', 'SM02E4072816572']) - await app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--all', '-y']) - - expect(deviceTuneOptions).toEqual([{ device: 'SM02E4072816572' }, { all: true, yes: true }]) - }) - - test('rejects device tune with both --all and --device', async () => { - const app = createAppWithSilencedDeviceTuneCommand() + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + command?.exitOverride().configureOutput({ writeErr: (text) => errors.push(text), writeOut: () => {} }) - await expect( - app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--all', '--device', 'SM02E4072816572']), - ).rejects.toThrow(`The --all flag can't be used in combination with --device`) - }) - - test('delegates emulator tune command options', async () => { - const emulatorTuneOptions: EmulatorTuneCommandOptions[] = [] - const app = createApp({ - runEmulatorTune: async (options) => { - emulatorTuneOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'tune']) - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'tune', 'Alpha', '--yes']) - - expect(emulatorTuneOptions).toEqual([{ nameOrSerial: undefined }, { nameOrSerial: 'Alpha', yes: true }]) - }) - - test('registers emulator alias and subcommands', () => { - const emulatorCommand = createApp().commands.find((command) => command.name() === 'emulator') - - expect(emulatorCommand?.aliases()).toEqual(['emu']) - expect(emulatorCommand?.commands.map((command) => command.name())).toEqual([ - 'create', - 'delete', - 'images', - 'list', - 'start', - 'status', - 'stop', - 'tune', - ]) - }) - - test('does not delegate emulator command to list', async () => { - const emulatorListOptions: Array> = [] - const app = createApp({ - runEmulatorList: async (options) => { - emulatorListOptions.push(options) - }, - }) - - app.configureOutput({ - writeErr: () => {}, - writeOut: () => {}, - }) - app.commands - .find((command) => command.name() === 'emulator') - ?.configureOutput({ - writeErr: () => {}, - writeOut: () => {}, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator']) - - expect(emulatorListOptions).toEqual([]) - }) - - test('delegates emulator list command options', async () => { - const emulatorListOptions: Array> = [] - const app = createApp({ - runEmulatorList: async (options) => { - emulatorListOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'list']) - - expect(emulatorListOptions).toEqual([{}]) - }) - - test('delegates emulator alias list command options', async () => { - const emulatorListOptions: Array> = [] - const app = createApp({ - runEmulatorList: async (options) => { - emulatorListOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emu', 'list']) - - expect(emulatorListOptions).toEqual([{}]) - }) - - test('registers templates subcommands', () => { - const templatesCommand = createApp().commands.find((command) => command.name() === 'templates') - - 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({ - runTemplatesCheck: async (options) => { - templatesCheckOptions.push(options) - }, - }) - const templatesCommand = app.commands.find((command) => command.name() === 'templates') - - templatesCommand?.configureOutput({ - writeErr: () => {}, - writeOut: () => {}, - }) - - await app.parseAsync(['node', 'solana-mobile', 'templates']) - - expect(templatesCheckOptions).toEqual([]) - }) - - test('delegates templates check command options', async () => { - const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] - const app = createApp({ - runTemplatesCheck: async (options) => { - templatesCheckOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'templates', 'check', '--root', '/repo']) - - expect(templatesCheckOptions).toEqual([{ root: '/repo' }]) - }) - - test('delegates templates generate command options', async () => { - const templatesGenerateOptions: TemplatesGenerateCommandOptions[] = [] - const app = createApp({ - runTemplatesGenerate: async (options) => { - templatesGenerateOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'templates', 'generate', '--root', '/repo']) - - expect(templatesGenerateOptions).toEqual([{ root: '/repo' }]) - }) - - test('does not delegate emulator images command to list', async () => { - const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] - const app = createApp({ - runEmulatorImages: async (options) => { - emulatorImagesOptions.push(options) - }, - }) - - const emulatorImagesCommand = app.commands - .find((command) => command.name() === 'emulator') - ?.commands.find((command) => command.name() === 'images') - - emulatorImagesCommand?.configureOutput({ - writeErr: () => {}, - writeOut: () => {}, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'images']) - - 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({ - runEmulatorImagesDelete: async (options) => { - emulatorImagesDeleteOptions.push(options) - }, - }) - - await app.parseAsync([ - 'node', - 'solana-mobile', - 'emulator', - 'images', - 'delete', - 'system-images/android-35/google_apis_playstore/arm64-v8a', - 'system-images/android-36/google_apis_playstore/arm64-v8a', - '--sdk-root', - '/sdk', - ]) - - expect(emulatorImagesDeleteOptions).toEqual([ - { - sdkRoot: '/sdk', - systemImages: [ - 'system-images/android-35/google_apis_playstore/arm64-v8a', - 'system-images/android-36/google_apis_playstore/arm64-v8a', - ], - }, - ]) - }) - - test('delegates emulator images install command options', async () => { - const emulatorImagesInstallOptions: EmulatorImagesInstallCommandOptions[] = [] - const app = createApp({ - runEmulatorImagesInstall: async (options) => { - emulatorImagesInstallOptions.push(options) - }, - }) - - await app.parseAsync([ - 'node', - 'solana-mobile', - 'emulator', - 'images', - 'install', - 'system-images/android-36.1/google_apis_playstore/arm64-v8a', - '--all', - '--sdk-root', - '/sdk', - '--verbose', - ]) - - expect(emulatorImagesInstallOptions).toEqual([ - { - all: true, - sdkRoot: '/sdk', - systemImage: 'system-images/android-36.1/google_apis_playstore/arm64-v8a', - verbose: true, - }, - ]) - }) - - test('delegates emulator images list command options', async () => { - const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] - const app = createApp({ - runEmulatorImages: async (options) => { - emulatorImagesOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'images', 'list', '--sdk-root', '/sdk']) - - expect(emulatorImagesOptions).toEqual([{ sdkRoot: '/sdk' }]) - }) - - test('delegates emulator create command options', async () => { - const emulatorCreateOptions: EmulatorCreateCommandOptions[] = [] - const app = createApp({ - runEmulatorCreate: async (options) => { - emulatorCreateOptions.push(options) - }, - }) - - await app.parseAsync([ - 'node', - 'solana-mobile', - 'emulator', - 'create', - 'test_phone', - '--data-size', - '16G', - '--device', - 'pixel_9', - '--profile', - 'solana-mobile', - '--ram-mb', - '4096', - '--sdcard-size', - '256M', - '--sdk-root', - '/sdk', - '--start', - '--system-image', - 'system-images;android-36;google_apis_playstore;arm64-v8a', - '--tune', - '--verbose', - '--vm-heap-mb', - '384', - ]) - - expect(emulatorCreateOptions).toEqual([ - { - dataSize: '16G', - device: 'pixel_9', - name: 'test_phone', - profile: 'solana-mobile', - ramMb: 4096, - sdcardSize: '256M', - sdkRoot: '/sdk', - start: true, - systemImage: 'system-images;android-36;google_apis_playstore;arm64-v8a', - tune: true, - verbose: true, - vmHeapMb: 384, - }, - ]) - }) - - test('delegates emulator delete command options', async () => { - const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] - const app = createApp({ - runEmulatorDelete: async (options) => { - emulatorDeleteOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'delete', 'Alpha', 'Beta', '--sdk-root', '/sdk']) - - expect(emulatorDeleteOptions).toEqual([{ names: ['Alpha', 'Beta'], sdkRoot: '/sdk' }]) - }) - - test('delegates emulator delete without names', async () => { - const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] - const app = createApp({ - runEmulatorDelete: async (options) => { - emulatorDeleteOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'delete']) - - expect(emulatorDeleteOptions).toEqual([{ names: [] }]) - }) - - test('delegates emulator start command options', async () => { - const emulatorStartOptions: EmulatorStartCommandOptions[] = [] - const app = createApp({ - runEmulatorStart: async (options) => { - emulatorStartOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'start', 'Alpha', '--sdk-root', '/sdk', '--tune']) - - expect(emulatorStartOptions).toEqual([{ name: 'Alpha', sdkRoot: '/sdk', tune: true }]) - }) - - test('delegates emulator start without name', async () => { - const emulatorStartOptions: EmulatorStartCommandOptions[] = [] - const app = createApp({ - runEmulatorStart: async (options) => { - emulatorStartOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'start']) - - expect(emulatorStartOptions).toEqual([{ name: undefined }]) - }) - - test('delegates emulator stop command options', async () => { - const emulatorStopOptions: EmulatorStopCommandOptions[] = [] - const app = createApp({ - runEmulatorStop: async (options) => { - emulatorStopOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'stop', 'Alpha']) - - expect(emulatorStopOptions).toEqual([{ nameOrSerial: 'Alpha' }]) - }) - - test('delegates emulator status command options', async () => { - const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] - const app = createApp({ - runEmulatorStatus: async (options) => { - emulatorStatusOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'status', 'Alpha']) - - expect(emulatorStatusOptions).toEqual([{ nameOrSerial: 'Alpha' }]) - }) - - test('delegates emulator status without name or serial', async () => { - const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] - const app = createApp({ - runEmulatorStatus: async (options) => { - emulatorStatusOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'status']) - - expect(emulatorStatusOptions).toEqual([{ nameOrSerial: undefined }]) - }) - - test('delegates emulator stop without name or serial', async () => { - const emulatorStopOptions: EmulatorStopCommandOptions[] = [] - const app = createApp({ - runEmulatorStop: async (options) => { - emulatorStopOptions.push(options) - }, - }) - - await app.parseAsync(['node', 'solana-mobile', 'emulator', 'stop']) + await expect(app.parseAsync(['node', 'solana-mobile', name, '--bogus'])).rejects.toThrow( + "unknown option '--bogus'", + ) - expect(emulatorStopOptions).toEqual([{ nameOrSerial: undefined }]) + expect(errors.join('')).toContain(`Usage: solana-mobile ${name}`) + } }) test('registers create command options', () => { @@ -1301,21 +893,6 @@ describe('validate project name', () => { }) }) -function createAppWithSilencedDeviceTuneCommand() { - const app = createApp({ runDeviceTune: async () => {} }) - const deviceCommand = app.commands.find((command) => command.name() === 'device') - - app.exitOverride() - app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) - deviceCommand?.exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} }) - deviceCommand?.commands - .find((command) => command.name() === 'tune') - ?.exitOverride() - .configureOutput({ writeErr: () => {}, writeOut: () => {} }) - - return app -} - function createAppWithSilencedCreateCommand() { const app = createApp({ runCreate: async () => {} }) diff --git a/test/device.test.ts b/test/device.test.ts index a679fb4..92e7a6c 100644 --- a/test/device.test.ts +++ b/test/device.test.ts @@ -3,8 +3,10 @@ import { createHash } from 'node:crypto' import { mkdtemp, readFile, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { createApp } from '../src/app.ts' import type { CommandRunner } from '../src/core/data-access/command-types.ts' import { findApkCatalogEntry, githubReleaseDownloadUrl } from '../src/device/data-access/apk-catalog.ts' +import type { DeviceTuneCommandOptions } from '../src/device/data-access/device-types.ts' import { defaultDownloadFile, ensureApkDownloaded } from '../src/device/data-access/download-apk.ts' import { buildAdbInstallCommand, extractAdbInstallFailure, installApk } from '../src/device/data-access/install-apk.ts' import { listConnectedDevices } from '../src/device/data-access/list-connected-devices.ts' @@ -1019,3 +1021,46 @@ describe('runDeviceTune', () => { } }) }) + +function createAppWithSilencedDeviceTuneCommand() { + const app = createApp({ runDeviceTune: async () => {} }) + const deviceCommand = app.commands.find((command) => command.name() === 'device') + + app.exitOverride() + app.configureOutput({ writeErr: () => {}, writeOut: () => {} }) + deviceCommand?.exitOverride().configureOutput({ writeErr: () => {}, writeOut: () => {} }) + deviceCommand?.commands + .find((command) => command.name() === 'tune') + ?.exitOverride() + .configureOutput({ writeErr: () => {}, writeOut: () => {} }) + + return app +} + +describe('device command', () => { + test('registers device subcommands', () => { + const deviceCommand = createApp().commands.find((command) => command.name() === 'device') + + 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({ + runDeviceTune: async (options) => { + deviceTuneOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--device', 'SM02E4072816572']) + await app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--all', '-y']) + + expect(deviceTuneOptions).toEqual([{ device: 'SM02E4072816572' }, { all: true, yes: true }]) + }) + test('rejects device tune with both --all and --device', async () => { + const app = createAppWithSilencedDeviceTuneCommand() + + await expect( + app.parseAsync(['node', 'solana-mobile', 'device', 'tune', '--all', '--device', 'SM02E4072816572']), + ).rejects.toThrow(`The --all flag can't be used in combination with --device`) + }) +}) diff --git a/test/doctor.test.ts b/test/doctor.test.ts index c4fb4d5..a7d2b65 100644 --- a/test/doctor.test.ts +++ b/test/doctor.test.ts @@ -12,7 +12,7 @@ import { checkNodeVersion, normalizeNodeVersion } from '../src/doctor/data-acces import { checkOperatingSystem, checkPackageManagers } from '../src/doctor/data-access/check-system-and-javascript.ts' import type { DoctorCheckResult } from '../src/doctor/data-access/doctor-check-result.ts' import { type DoctorEnvironment, expandHome } from '../src/doctor/data-access/doctor-environment.ts' -import { buildDoctorReport, deriveCapabilities, getDoctorExitCode } from '../src/doctor/doctor-feature-index.ts' +import { buildDoctorReport, deriveCapabilities, getDoctorExitCode } from '../src/doctor/data-access/doctor-report.ts' import { formatDoctorReport } from '../src/doctor/ui/doctor-ui-report.ts' function environment(overrides: Partial = {}): DoctorEnvironment { diff --git a/test/emulator.test.ts b/test/emulator.test.ts index 9bb3b70..9f413c0 100644 --- a/test/emulator.test.ts +++ b/test/emulator.test.ts @@ -3,10 +3,22 @@ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { tasks } from '@clack/prompts' +import { createApp } from '../src/app.ts' import { formatCliCommand } from '../src/core/util/format-cli-command.ts' import { createAvdConfigValues, parseAvdConfig } from '../src/emulator/data-access/avd-config.ts' import { createAvd } from '../src/emulator/data-access/create-avd.ts' import { deleteInstalledAvds } from '../src/emulator/data-access/delete-installed-avds.ts' +import type { + EmulatorCreateCommandOptions, + EmulatorDeleteCommandOptions, + EmulatorImagesCommandOptions, + EmulatorImagesDeleteCommandOptions, + EmulatorImagesInstallCommandOptions, + EmulatorStartCommandOptions, + EmulatorStatusCommandOptions, + EmulatorStopCommandOptions, + EmulatorTuneCommandOptions, +} from '../src/emulator/data-access/emulator-types.ts' import { listEmulatorStatuses } from '../src/emulator/data-access/list-emulator-statuses.ts' import { listInstalledAvds } from '../src/emulator/data-access/list-installed-avds.ts' import { @@ -2360,3 +2372,323 @@ Available packages: expect(commands).toEqual([['adb', 'devices']]) }) }) + +describe('emulator command', () => { + test('delegates emulator tune command options', async () => { + const emulatorTuneOptions: EmulatorTuneCommandOptions[] = [] + const app = createApp({ + runEmulatorTune: async (options) => { + emulatorTuneOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'tune']) + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'tune', 'Alpha', '--yes']) + + expect(emulatorTuneOptions).toEqual([{ nameOrSerial: undefined }, { nameOrSerial: 'Alpha', yes: true }]) + }) + test('registers emulator alias and subcommands', () => { + const emulatorCommand = createApp().commands.find((command) => command.name() === 'emulator') + + expect(emulatorCommand?.aliases()).toEqual(['emu']) + expect(emulatorCommand?.commands.map((command) => command.name())).toEqual([ + 'create', + 'delete', + 'images', + 'list', + 'start', + 'status', + 'stop', + 'tune', + ]) + }) + test('does not delegate emulator command to list', async () => { + const emulatorListOptions: Array> = [] + const app = createApp({ + runEmulatorList: async (options) => { + emulatorListOptions.push(options) + }, + }) + + app.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }) + app.commands + .find((command) => command.name() === 'emulator') + ?.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator']) + + expect(emulatorListOptions).toEqual([]) + }) + test('delegates emulator list command options', async () => { + const emulatorListOptions: Array> = [] + const app = createApp({ + runEmulatorList: async (options) => { + emulatorListOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'list']) + + expect(emulatorListOptions).toEqual([{}]) + }) + test('delegates emulator alias list command options', async () => { + const emulatorListOptions: Array> = [] + const app = createApp({ + runEmulatorList: async (options) => { + emulatorListOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emu', 'list']) + + expect(emulatorListOptions).toEqual([{}]) + }) + test('does not delegate emulator images command to list', async () => { + const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] + const app = createApp({ + runEmulatorImages: async (options) => { + emulatorImagesOptions.push(options) + }, + }) + + const emulatorImagesCommand = app.commands + .find((command) => command.name() === 'emulator') + ?.commands.find((command) => command.name() === 'images') + + emulatorImagesCommand?.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'images']) + + 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({ + runEmulatorImagesDelete: async (options) => { + emulatorImagesDeleteOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'emulator', + 'images', + 'delete', + 'system-images/android-35/google_apis_playstore/arm64-v8a', + 'system-images/android-36/google_apis_playstore/arm64-v8a', + '--sdk-root', + '/sdk', + ]) + + expect(emulatorImagesDeleteOptions).toEqual([ + { + sdkRoot: '/sdk', + systemImages: [ + 'system-images/android-35/google_apis_playstore/arm64-v8a', + 'system-images/android-36/google_apis_playstore/arm64-v8a', + ], + }, + ]) + }) + test('delegates emulator images install command options', async () => { + const emulatorImagesInstallOptions: EmulatorImagesInstallCommandOptions[] = [] + const app = createApp({ + runEmulatorImagesInstall: async (options) => { + emulatorImagesInstallOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'emulator', + 'images', + 'install', + 'system-images/android-36.1/google_apis_playstore/arm64-v8a', + '--all', + '--sdk-root', + '/sdk', + '--verbose', + ]) + + expect(emulatorImagesInstallOptions).toEqual([ + { + all: true, + sdkRoot: '/sdk', + systemImage: 'system-images/android-36.1/google_apis_playstore/arm64-v8a', + verbose: true, + }, + ]) + }) + test('delegates emulator images list command options', async () => { + const emulatorImagesOptions: EmulatorImagesCommandOptions[] = [] + const app = createApp({ + runEmulatorImages: async (options) => { + emulatorImagesOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'images', 'list', '--sdk-root', '/sdk']) + + expect(emulatorImagesOptions).toEqual([{ sdkRoot: '/sdk' }]) + }) + test('delegates emulator create command options', async () => { + const emulatorCreateOptions: EmulatorCreateCommandOptions[] = [] + const app = createApp({ + runEmulatorCreate: async (options) => { + emulatorCreateOptions.push(options) + }, + }) + + await app.parseAsync([ + 'node', + 'solana-mobile', + 'emulator', + 'create', + 'test_phone', + '--data-size', + '16G', + '--device', + 'pixel_9', + '--profile', + 'solana-mobile', + '--ram-mb', + '4096', + '--sdcard-size', + '256M', + '--sdk-root', + '/sdk', + '--start', + '--system-image', + 'system-images;android-36;google_apis_playstore;arm64-v8a', + '--tune', + '--verbose', + '--vm-heap-mb', + '384', + ]) + + expect(emulatorCreateOptions).toEqual([ + { + dataSize: '16G', + device: 'pixel_9', + name: 'test_phone', + profile: 'solana-mobile', + ramMb: 4096, + sdcardSize: '256M', + sdkRoot: '/sdk', + start: true, + systemImage: 'system-images;android-36;google_apis_playstore;arm64-v8a', + tune: true, + verbose: true, + vmHeapMb: 384, + }, + ]) + }) + test('delegates emulator delete command options', async () => { + const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] + const app = createApp({ + runEmulatorDelete: async (options) => { + emulatorDeleteOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'delete', 'Alpha', 'Beta', '--sdk-root', '/sdk']) + + expect(emulatorDeleteOptions).toEqual([{ names: ['Alpha', 'Beta'], sdkRoot: '/sdk' }]) + }) + test('delegates emulator delete without names', async () => { + const emulatorDeleteOptions: EmulatorDeleteCommandOptions[] = [] + const app = createApp({ + runEmulatorDelete: async (options) => { + emulatorDeleteOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'delete']) + + expect(emulatorDeleteOptions).toEqual([{ names: [] }]) + }) + test('delegates emulator start command options', async () => { + const emulatorStartOptions: EmulatorStartCommandOptions[] = [] + const app = createApp({ + runEmulatorStart: async (options) => { + emulatorStartOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'start', 'Alpha', '--sdk-root', '/sdk', '--tune']) + + expect(emulatorStartOptions).toEqual([{ name: 'Alpha', sdkRoot: '/sdk', tune: true }]) + }) + test('delegates emulator start without name', async () => { + const emulatorStartOptions: EmulatorStartCommandOptions[] = [] + const app = createApp({ + runEmulatorStart: async (options) => { + emulatorStartOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'start']) + + expect(emulatorStartOptions).toEqual([{ name: undefined }]) + }) + test('delegates emulator stop command options', async () => { + const emulatorStopOptions: EmulatorStopCommandOptions[] = [] + const app = createApp({ + runEmulatorStop: async (options) => { + emulatorStopOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'stop', 'Alpha']) + + expect(emulatorStopOptions).toEqual([{ nameOrSerial: 'Alpha' }]) + }) + test('delegates emulator status command options', async () => { + const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] + const app = createApp({ + runEmulatorStatus: async (options) => { + emulatorStatusOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'status', 'Alpha']) + + expect(emulatorStatusOptions).toEqual([{ nameOrSerial: 'Alpha' }]) + }) + test('delegates emulator status without name or serial', async () => { + const emulatorStatusOptions: EmulatorStatusCommandOptions[] = [] + const app = createApp({ + runEmulatorStatus: async (options) => { + emulatorStatusOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'status']) + + expect(emulatorStatusOptions).toEqual([{ nameOrSerial: undefined }]) + }) + test('delegates emulator stop without name or serial', async () => { + const emulatorStopOptions: EmulatorStopCommandOptions[] = [] + const app = createApp({ + runEmulatorStop: async (options) => { + emulatorStopOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'emulator', 'stop']) + + expect(emulatorStopOptions).toEqual([{ nameOrSerial: undefined }]) + }) +}) diff --git a/test/localnet.test.ts b/test/localnet.test.ts index 2a70384..0edd293 100644 --- a/test/localnet.test.ts +++ b/test/localnet.test.ts @@ -1314,28 +1314,6 @@ describe('localnet command', () => { expect(forwardOptions[0]?.watch).toBe(true) }) - test('inherits the root command settings through addCommand', async () => { - // Commander copies the root's settings into commands made with `command()` but not into ones passed - // to `addCommand`, so a feature-owned command silently drops `showHelpAfterError` (and - // `enablePositionalOptions`) unless `createApp` copies them back. Without that copy this prints the - // bare error line and nothing else. - const errors: string[] = [] - const app = createApp({ runLocalnetStart: async () => {} }) - - app.exitOverride() - app.configureOutput({ writeErr: (text) => errors.push(text), writeOut: () => {} }) - app.commands - .find((command) => command.name() === 'localnet') - ?.exitOverride() - .configureOutput({ writeErr: (text) => errors.push(text), writeOut: () => {} }) - - await expect(app.parseAsync(['node', 'solana-mobile', 'localnet', '--bogus'])).rejects.toThrow( - "unknown option '--bogus'", - ) - - expect(errors.join('')).toContain('Usage: solana-mobile localnet') - }) - test('rejects an unknown localnet engine', async () => { const app = createApp({ runLocalnetStart: async () => {} }) diff --git a/test/playground.test.ts b/test/playground.test.ts index 6b04315..f9596a4 100644 --- a/test/playground.test.ts +++ b/test/playground.test.ts @@ -10,7 +10,7 @@ import { } from '../src/playground/data-access/playground-clusters.ts' import { type PlaygroundServer, startPlaygroundServer } from '../src/playground/data-access/playground-server.ts' import type { PlaygroundConfig, PlaygroundEvent } from '../src/playground/data-access/playground-types.ts' -import { runPlayground } from '../src/playground/playground-feature-index.ts' +import { runPlayground } from '../src/playground/playground-feature-serve.ts' import { renderPlaygroundEvent } from '../src/playground/ui/playground-ui-messages.ts' const TEST_CONFIG: PlaygroundConfig = { diff --git a/test/templates.test.ts b/test/templates.test.ts index f0ee255..b561f5b 100644 --- a/test/templates.test.ts +++ b/test/templates.test.ts @@ -16,6 +16,9 @@ import { } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, join } from 'node:path' +import { createApp } from '../src/app.ts' +import type { TemplatesCheckCommandOptions } from '../src/templates/templates-feature-check.ts' +import type { TemplatesGenerateCommandOptions } from '../src/templates/templates-feature-generate.ts' import { applyTemplateSync, checkTemplateRepository, @@ -1012,3 +1015,53 @@ function writeManifest(root: string, update: (manifest: Record) update(manifest) writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) } + +describe('templates command', () => { + test('registers templates subcommands', () => { + const templatesCommand = createApp().commands.find((command) => command.name() === 'templates') + + 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({ + runTemplatesCheck: async (options) => { + templatesCheckOptions.push(options) + }, + }) + const templatesCommand = app.commands.find((command) => command.name() === 'templates') + + templatesCommand?.configureOutput({ + writeErr: () => {}, + writeOut: () => {}, + }) + + await app.parseAsync(['node', 'solana-mobile', 'templates']) + + expect(templatesCheckOptions).toEqual([]) + }) + test('delegates templates check command options', async () => { + const templatesCheckOptions: TemplatesCheckCommandOptions[] = [] + const app = createApp({ + runTemplatesCheck: async (options) => { + templatesCheckOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'templates', 'check', '--root', '/repo']) + + expect(templatesCheckOptions).toEqual([{ root: '/repo' }]) + }) + test('delegates templates generate command options', async () => { + const templatesGenerateOptions: TemplatesGenerateCommandOptions[] = [] + const app = createApp({ + runTemplatesGenerate: async (options) => { + templatesGenerateOptions.push(options) + }, + }) + + await app.parseAsync(['node', 'solana-mobile', 'templates', 'generate', '--root', '/repo']) + + expect(templatesGenerateOptions).toEqual([{ root: '/repo' }]) + }) +})