Skip to content

Commit 60582d3

Browse files
authored
refactor(setup): introduce Result type for clearer error handling (#218)
* feat: create error handling types and utilities - Add Result<T, E> type for improved error handling - Add SetupResult, BuildResult, UpdateResult type aliases - Create comprehensive error utility functions in toolbox/system/errors.ts - Add success/failure creators, async/sync wrappers, and helper functions - Maintain TypeScript compilation compatibility This establishes the foundation for removing process.exit() calls and implementing proper error propagation throughout the CLI. * refactor: replace process.exit() in system utilities - Update execWithSudo, pkexec, and environment functions to return Result types - Replace process.exit() calls with proper error returns in packages.ts - Improve error handling in python.ts with Result wrapper - Add error propagation instead of hard exits Breaking change: Function signatures now return Result<T> instead of void/T. Callers will be updated in subsequent commits. * refactor: replace process.exit() in core setup utilities - Update moddable.ts functions to return Result types - Replace process.exit() calls with proper error returns in ejectfix.ts - Wrap async operations with proper error handling - Convert getModdableVersion, fetchRelease, downloadReleaseTools to Result pattern Breaking change: Function signatures now return Result<T> instead of T. Other files using these functions will be updated in subsequent commits. * refactor: replace process.exit() in esp32 setup functions * refactor: replace process.exit() in esp8266 setup functions - Convert esp8266 setup functions to return Result<void> pattern - Replace all process.exit() calls with proper error returns - Apply same error handling pattern as ESP32 implementation - Update function signatures for mac, linux, and windows variants - Handle Result types from dependency installers properly * refactor: replace process.exit() in wasm, pico, and nrf52 setup functions (partial) - Convert wasm setup to return Result<void> pattern - Replace process.exit() calls with proper error returns - Update pico setup and platform-specific variants (mac, linux) - Update nrf52 setup and windows platform support - Apply consistent error handling pattern across platforms - Handle dependency installer Results properly This is partial progress on remaining platform setup refactoring. * refactor: replace process.exit() in linux setup functions (complete Commit 6) - Convert linux platform setup to return Result<void> pattern - Replace process.exit(0) with proper success return - Handle dependency installer Results properly - Apply consistent error handling pattern Commit 6 now covers: wasm, pico (+ mac/linux), nrf52 (+ windows), linux. Remaining mac.ts and windows.ts host setup files can be handled in future commits. * refactor: update setup command to handle Result types (Commit 7) - Update setup command to handle Result types from setup functions - Replace process.exit() calls with appropriate returns and error handling - Add proper error reporting with print.error() before process.exit(1) - Handle setupEjectfix Result type properly - Maintain existing CLI behavior while using new error handling * refactor: remove process.exit() from remaining commands (Commit 8) - Replace process.exit(0) with return statements in init, include, remove commands - Maintain appropriate error reporting while allowing graceful function returns - Remove hard exits for user-initiated cancellations and error cases - Complete command layer refactoring to eliminate problematic process exits * feat: add basic unit tests for error handling utilities (Commit 9) - Add comprehensive tests for error handling utilities (19 test cases) - Test success, failure, wrapAsync, wrapSync functions - Test type guards, unwrap functions, and result chaining - Verify proper Result type behavior and error propagation - Add unit tests for exec utilities (12 test cases) - Test execWithSudo with various scenarios including askpass fallback - Test pkexec command execution - Test environment sourcing functions with mocked system calls - Verify Result type handling across all exec functions - All tests passing with proper TypeScript typing and mocking - Total: 31 test cases providing solid foundation test coverage * feat: replace outdated integration tests with comprehensive CLI tests (Commit 10) - Replace placeholder integration tests with real CLI functionality tests - Add comprehensive end-to-end testing covering: * Basic CLI functionality (version, help, command help) * Error handling (unknown commands, missing arguments) * Command functionality (init, include, remove, setup scenarios) * Doctor command diagnostic checks * Edge cases and error conditions - Test error scenarios end-to-end to verify new Result-based error handling - All 13 integration tests passing with proper timeout handling - Tests verify CLI exits gracefully with appropriate error codes and messages - Validates that refactored error handling works correctly in real usage Phase 1 refactoring now complete with solid test foundation! * fix(doctor): unwrap Result from python and moddable version queries * fix(setup/update): use correct Result type return platform modules * fix: remove integration test errors * refactor: resolve formatting * refactor: resolve linting errors, ignore test files * chore: cleanup files * refactor: resolve CI linting
1 parent 4c2fd44 commit 60582d3

37 files changed

+1091
-281
lines changed

.gitignore

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,5 +8,13 @@ build/
88
.vscode
99
!src/toolbox/build/
1010
.npmrc
11+
test-project/
1112

1213
.env
14+
15+
# Planning documents
16+
plans/
17+
.claude/
18+
.crush/
19+
CLAUDE.md
20+
CRUSH.md

__tests__/cli-integration.test.ts

Lines changed: 150 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,159 @@
1-
const { system, filesystem } = require('gluegun')
1+
import { exec } from 'node:child_process'
2+
import { promisify } from 'node:util'
3+
import { resolve } from 'node:path'
4+
import { filesystem } from 'gluegun'
25

3-
const src = filesystem.path(__dirname, '..')
6+
const execAsync = promisify(exec)
47

5-
const cli = async (cmd: string) =>
6-
system.run('node ' + filesystem.path(src, 'bin', 'xs-dev') + ` ${cmd}`)
8+
const CLI_PATH = resolve(__dirname, '..', 'build', 'src', 'cli.js')
79

8-
test('outputs version', async () => {
9-
const output = await cli('--version')
10-
expect(output).toContain('0.0.1')
11-
})
10+
/**
11+
* Execute xs-dev CLI command and return { stdout, stderr, code }
12+
*/
13+
const runCLI = async (command: string, options: { cwd?: string } = {}) => {
14+
try {
15+
const { stdout, stderr } = await execAsync(
16+
`node "${CLI_PATH}" ${command}`,
17+
{
18+
cwd: options.cwd || process.cwd(),
19+
timeout: 30000, // 30 second timeout
20+
},
21+
)
22+
return { stdout: stdout.trim(), stderr: stderr.trim(), code: 0 }
23+
} catch (error: any) {
24+
return {
25+
stdout: error.stdout?.trim() || '',
26+
stderr: error.stderr?.trim() || '',
27+
code: error.code || 1,
28+
}
29+
}
30+
}
1231

13-
test('outputs help', async () => {
14-
const output = await cli('--help')
15-
expect(output).toContain('0.0.1')
16-
})
32+
describe('CLI Integration Tests', () => {
33+
describe('Basic CLI functionality', () => {
34+
it('should output version information', async () => {
35+
const result = await runCLI('--version')
36+
37+
expect(result.code).toBe(0)
38+
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/)
39+
}, 10000)
40+
41+
it('should display help information', async () => {
42+
const result = await runCLI('--help')
43+
44+
expect(result.code).toBe(0)
45+
expect(result.stdout).toContain('xs-dev')
46+
expect(result.stdout).toContain('setup')
47+
expect(result.stdout).toContain('build')
48+
expect(result.stdout).toContain('run')
49+
}, 10000)
50+
51+
it('should show command help', async () => {
52+
const result = await runCLI('setup --help')
53+
54+
expect(result.code).toBe(0)
55+
expect(result.stdout).toContain('setup')
56+
expect(result.stdout).toContain('device')
57+
}, 10000)
58+
})
59+
60+
describe('Error handling', () => {
61+
it('should handle unknown commands gracefully', async () => {
62+
const result = await runCLI('nonexistent-command')
63+
64+
expect(result.code).not.toBe(0)
65+
expect(result.stderr || result.stdout).toMatch(
66+
/no command registered|unknown|not found|invalid/i,
67+
)
68+
}, 10000)
69+
70+
it('should validate missing required arguments', async () => {
71+
const result = await runCLI('init') // init requires project name
72+
73+
expect(result.code).not.toBe(0)
74+
expect(result.stderr || result.stdout).toMatch(/required|name/i)
75+
}, 10000)
76+
})
77+
78+
describe('Command functionality', () => {
79+
const tempDir = resolve(__dirname, '..', 'test-project')
80+
81+
beforeEach(() => {
82+
// Clean up any previous test artifacts
83+
filesystem.remove(tempDir)
84+
})
85+
86+
afterEach(() => {
87+
// Clean up test artifacts
88+
filesystem.remove(tempDir)
89+
})
90+
91+
it('should handle init command without MODDABLE env var', async () => {
92+
const result = await runCLI(`init test-project --typescript`)
93+
94+
expect(result.code).toBe(0)
95+
}, 15000)
96+
97+
it('should handle include command outside of project directory', async () => {
98+
const result = await runCLI('include base/timer')
99+
expect(result.code).not.toBe(0)
100+
expect(result.stderr || result.stdout).toMatch(
101+
/manifest\.json.*not found|project directory/i,
102+
)
103+
}, 10000)
104+
105+
it('should handle remove command outside of project directory', async () => {
106+
const result = await runCLI('remove timer')
107+
108+
expect(result.code).not.toBe(0)
109+
expect(result.stderr || result.stdout).toMatch(
110+
/manifest\.json.*not found|project directory/i,
111+
)
112+
}, 10000)
113+
114+
it('should handle setup command gracefully when dependencies missing', async () => {
115+
const result = await runCLI('setup --device wasm')
116+
117+
// Should either succeed or provide helpful error messages
118+
// Since we don't have Moddable SDK installed in test environment,
119+
// it should fail gracefully with a helpful message
120+
expect(result.code).not.toBe(0)
121+
expect(result.stderr || result.stdout).toMatch(
122+
/moddable|required|setup|tooling/i,
123+
)
124+
}, 20000)
125+
126+
it('should show device help information', async () => {
127+
const result = await runCLI('setup --help')
128+
129+
// Should mention device options in help
130+
expect(result.stdout || result.stderr).toMatch(
131+
/device|esp32|esp8266|wasm|pico/i,
132+
)
133+
}, 10000)
134+
})
135+
136+
describe('Doctor command', () => {
137+
it('should run diagnostic checks', async () => {
138+
const result = await runCLI('doctor')
139+
140+
expect(result.code).toBe(0)
141+
expect(result.stdout).toMatch(/node|system|platform/i)
142+
}, 15000)
143+
})
17144

18-
test('generates file', async () => {
19-
const output = await cli('generate foo')
145+
describe('Edge cases and error conditions', () => {
146+
it('should handle invalid flags gracefully', async () => {
147+
const result = await runCLI('setup --invalid-flag')
20148

21-
expect(output).toContain('Generated file at models/foo-model.ts')
22-
const foomodel = filesystem.read('models/foo-model.ts')
149+
expect(result.code).not.toBe(0)
150+
}, 10000)
23151

24-
expect(foomodel).toContain(`module.exports = {`)
25-
expect(foomodel).toContain(`name: 'foo'`)
152+
it('should handle empty arguments appropriately', async () => {
153+
const result = await runCLI('')
26154

27-
// cleanup artifact
28-
filesystem.remove('models')
155+
// Should show help or error message
156+
expect(result.stdout || result.stderr).toMatch(/help|usage|command/i)
157+
}, 10000)
158+
})
29159
})

eslint.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,6 @@ module.exports = [
44
{
55
...loveConfig,
66
files: ['src/**/*.ts'],
7+
ignores: ['**/__tests__/**'],
78
},
89
]

package.json

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,10 @@
9898
},
9999
"jest": {
100100
"preset": "ts-jest",
101-
"testEnvironment": "node"
101+
"testEnvironment": "node",
102+
"testPathIgnorePatterns": [
103+
"<rootDir>/build/"
104+
]
102105
},
103106
"volta": {
104107
"node": "18.14.0"
@@ -113,7 +116,14 @@
113116
"ejs@<3.1.7": ">=3.1.7",
114117
"axios@>=0.8.1 <1.6.0": ">=1.6.0",
115118
"minimatch@<3.0.5": ">=3.0.5"
116-
}
119+
},
120+
"onlyBuiltDependencies": [
121+
"@serialport/bindings-cpp",
122+
"esbuild",
123+
"node-liblzma",
124+
"sharp",
125+
"usb"
126+
]
117127
},
118128
"author": "HipsterBrown <headhipster@hipsterbrown.com>",
119129
"auto": {

src/commands/doctor.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { getModdableVersion, moddableExists } from '../toolbox/setup/moddable'
66
import { sourceEnvironment } from '../toolbox/system/exec'
77
import { detectPython, getPythonVersion } from '../toolbox/system/python'
88
import type { Device } from '../types'
9+
import { unwrapOr } from '../toolbox/system/errors'
910

1011
const command = buildCommand({
1112
async func(this: LocalContext) {
@@ -66,10 +67,10 @@ const command = buildCommand({
6667
supportedDevices.push('nrf52')
6768
}
6869

69-
const pythonVersion = (await getPythonVersion()) ?? 'Unavailable'
70+
const pythonVersion = unwrapOr(await getPythonVersion(), 'Unavailable')
7071
const pythonPath = system.which(detectPython() ?? '') ?? 'n/a'
7172

72-
const moddableVersion = (await getModdableVersion()) ?? 'Not found'
73+
const moddableVersion = unwrapOr(await getModdableVersion(), 'Not found')
7374
const moddablePath = process.env.MODDABLE ?? 'n/a'
7475

7576
print.info('xs-dev environment info:')
@@ -103,9 +104,9 @@ const command = buildCommand({
103104
: [],
104105
supportedDevices.includes('nrf52')
105106
? [
106-
'NRF52 SDK Directory',
107-
String(process.env.NRF_SDK_DIR ?? process.env.NRF52_SDK_PATH),
108-
]
107+
'NRF52 SDK Directory',
108+
String(process.env.NRF_SDK_DIR ?? process.env.NRF52_SDK_PATH),
109+
]
109110
: [],
110111
].filter((tuple) => tuple.length !== 0),
111112
)

src/commands/init.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ const command = buildCommand({
3939
warning(
4040
`Directory called ${projectName} already exists. Please pass the --overwrite flag to replace an existing project.`,
4141
)
42-
process.exit(0)
42+
return
4343
}
4444

4545
await sourceEnvironment()
@@ -81,7 +81,7 @@ const command = buildCommand({
8181
filesystem.copy(selectedExamplePath, projectName, { overwrite })
8282
} else {
8383
warning('Please select an example template to use.')
84-
process.exit(0)
84+
return
8585
}
8686
} else {
8787
info(`Generating Moddable project: ${projectName}`)

src/commands/remove.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ const command = buildCommand({
2626

2727
if (moduleName === undefined) {
2828
print.error('Module name is required')
29-
process.exit(1)
29+
return
3030
}
3131

3232
print.info(`Removing "${String(moduleName)}" from manifest includes`)

src/commands/setup.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { type as platformType } from 'node:os'
22
import { buildCommand } from '@stricli/core'
33
import type { LocalContext } from '../cli'
4-
import type { Device } from '../types'
4+
import type { Device, SetupResult } from '../types'
55
import setupEjectfix from '../toolbox/setup/ejectfix'
66
import { DEVICE_ALIAS } from '../toolbox/prompt/devices'
77
import { MODDABLE_REPO } from '../toolbox/setup/constants'
88
import type { SetupArgs } from '../toolbox/setup/types'
9+
import { isFailure } from '../toolbox/system/errors'
910

1011
interface SetupOptions {
1112
device?: Device
@@ -57,7 +58,7 @@ const command = buildCommand({
5758
target = selectedDevice as Device
5859
} else {
5960
print.warning('Please select a target device to run')
60-
process.exit(0)
61+
return
6162
}
6263
}
6364

@@ -66,7 +67,13 @@ const command = buildCommand({
6667
print.warning(`Unknown tool ${tool}`)
6768
process.exit(1)
6869
}
69-
if (tool === 'ejectfix') await setupEjectfix()
70+
if (tool === 'ejectfix') {
71+
const result = await setupEjectfix()
72+
if (isFailure(result)) {
73+
print.error(result.error)
74+
process.exit(1)
75+
}
76+
}
7077
return
7178
}
7279
const platformDevices: Device[] = [
@@ -81,17 +88,25 @@ const command = buildCommand({
8188
const { default: setup } = await import(`../toolbox/setup/${target}`)
8289

8390
if (platformDevices.includes(target)) {
84-
await setup({
91+
const result = await setup({
8592
branch,
8693
release,
8794
sourceRepo,
8895
interactive:
8996
typeof process.env.CI !== 'undefined'
9097
? process.env.CI === 'false'
9198
: interactive,
92-
})
99+
}) as SetupResult
100+
if (isFailure(result)) {
101+
print.error(result.error)
102+
process.exit(1)
103+
}
93104
} else {
94-
await setup({ branch, release })
105+
const result = await setup({ branch, release }) as SetupResult
106+
if (isFailure(result)) {
107+
print.error(result.error)
108+
process.exit(1)
109+
}
95110
}
96111
},
97112
parameters: {

0 commit comments

Comments
 (0)