@remix-run/cli 0.3.4 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +105 -1
  2. package/dist/index.js +1 -1
  3. package/dist/lib/app-root.d.ts +11 -0
  4. package/dist/lib/app-root.d.ts.map +1 -0
  5. package/dist/lib/app-root.js +38 -0
  6. package/dist/lib/bootstrap-project.js +2 -2
  7. package/dist/lib/cli-context.d.ts +6 -0
  8. package/dist/lib/cli-context.d.ts.map +1 -1
  9. package/dist/lib/cli-context.js +12 -1
  10. package/dist/lib/cli.d.ts +3 -2
  11. package/dist/lib/cli.d.ts.map +1 -1
  12. package/dist/lib/cli.js +42 -14
  13. package/dist/lib/commands/completion.js +4 -4
  14. package/dist/lib/commands/db.d.ts +4 -0
  15. package/dist/lib/commands/db.d.ts.map +1 -0
  16. package/dist/lib/commands/db.js +283 -0
  17. package/dist/lib/commands/doctor.d.ts +5 -0
  18. package/dist/lib/commands/doctor.d.ts.map +1 -1
  19. package/dist/lib/commands/doctor.js +26 -251
  20. package/dist/lib/commands/help.d.ts.map +1 -1
  21. package/dist/lib/commands/help.js +17 -9
  22. package/dist/lib/commands/new.js +6 -6
  23. package/dist/lib/commands/routes.js +6 -6
  24. package/dist/lib/commands/test.d.ts +20 -1
  25. package/dist/lib/commands/test.d.ts.map +1 -1
  26. package/dist/lib/commands/test.js +353 -10
  27. package/dist/lib/commands/version.js +3 -3
  28. package/dist/lib/completion.d.ts +1 -1
  29. package/dist/lib/completion.d.ts.map +1 -1
  30. package/dist/lib/completion.js +229 -6
  31. package/dist/lib/controller-files.d.ts +0 -8
  32. package/dist/lib/controller-files.d.ts.map +1 -1
  33. package/dist/lib/controller-files.js +0 -23
  34. package/dist/lib/controller-ownership.d.ts +4 -12
  35. package/dist/lib/controller-ownership.d.ts.map +1 -1
  36. package/dist/lib/controller-ownership.js +31 -127
  37. package/dist/lib/database-command.d.ts +20 -0
  38. package/dist/lib/database-command.d.ts.map +1 -0
  39. package/dist/lib/database-command.js +7 -0
  40. package/dist/lib/doctor/controller-findings.d.ts +2 -2
  41. package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
  42. package/dist/lib/doctor/controller-findings.js +23 -39
  43. package/dist/lib/doctor/controllers.d.ts +1 -2
  44. package/dist/lib/doctor/controllers.d.ts.map +1 -1
  45. package/dist/lib/doctor/controllers.js +4 -7
  46. package/dist/lib/doctor/environment.js +2 -2
  47. package/dist/lib/doctor/fixes.js +1 -1
  48. package/dist/lib/doctor/project.d.ts.map +1 -1
  49. package/dist/lib/doctor/project.js +3 -212
  50. package/dist/lib/doctor/run.d.ts +9 -0
  51. package/dist/lib/doctor/run.d.ts.map +1 -0
  52. package/dist/lib/doctor/run.js +237 -0
  53. package/dist/lib/doctor/types.d.ts +2 -1
  54. package/dist/lib/doctor/types.d.ts.map +1 -1
  55. package/dist/lib/doctor/types.js +4 -1
  56. package/dist/lib/errors.d.ts +30 -0
  57. package/dist/lib/errors.d.ts.map +1 -1
  58. package/dist/lib/errors.js +55 -0
  59. package/dist/lib/help-text.js +1 -1
  60. package/dist/lib/parse-args.d.ts.map +1 -1
  61. package/dist/lib/parse-args.js +17 -4
  62. package/dist/lib/remix-config.d.ts +75 -0
  63. package/dist/lib/remix-config.d.ts.map +1 -0
  64. package/dist/lib/remix-config.js +411 -0
  65. package/dist/lib/reporter.js +1 -1
  66. package/dist/lib/route-map.js +13 -33
  67. package/package.json +13 -7
  68. package/schema/remix.json +242 -0
  69. package/src/lib/app-root.ts +46 -0
  70. package/src/lib/cli-context.ts +17 -0
  71. package/src/lib/cli.ts +41 -6
  72. package/src/lib/commands/db.ts +377 -0
  73. package/src/lib/commands/doctor.ts +27 -329
  74. package/src/lib/commands/help.ts +10 -1
  75. package/src/lib/commands/test.ts +453 -12
  76. package/src/lib/completion.ts +321 -6
  77. package/src/lib/controller-files.ts +0 -38
  78. package/src/lib/controller-ownership.ts +50 -203
  79. package/src/lib/database-command.ts +31 -0
  80. package/src/lib/doctor/controller-findings.ts +30 -47
  81. package/src/lib/doctor/controllers.ts +2 -6
  82. package/src/lib/doctor/project.ts +0 -236
  83. package/src/lib/doctor/run.ts +311 -0
  84. package/src/lib/doctor/types.ts +6 -5
  85. package/src/lib/errors.ts +65 -0
  86. package/src/lib/parse-args.ts +19 -3
  87. package/src/lib/remix-config.ts +639 -0
  88. package/src/lib/route-map.ts +11 -48
  89. package/template/.agents/skills/remix/SKILL.md +38 -27
  90. package/template/.agents/skills/remix/references/assets-and-browser-modules.md +54 -11
  91. package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
  92. package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
  93. package/template/.agents/skills/remix/references/hydration-frames-navigation.md +63 -9
  94. package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
  95. package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
  96. package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
  97. package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
  98. package/template/AGENTS.md +6 -3
  99. package/template/README.md +6 -3
  100. package/template/app/actions/controller.tsx +1 -1
  101. package/template/app/{ui → actions}/document.tsx +7 -5
  102. package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +1 -2
  103. package/template/app/actions/public/entry.ts +48 -0
  104. package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
  105. package/template/app/assets.ts +15 -2
  106. package/template/app/middleware/render.tsx +8 -2
  107. package/template/hmr.ts +46 -0
  108. package/template/package.json +1 -0
  109. package/template/server.ts +8 -1
  110. package/template/tsconfig.json +1 -1
  111. package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
  112. package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
  113. package/dist/lib/doctor/controller-fix-plans.js +0 -119
  114. package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
  115. package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
  116. package/dist/lib/doctor/controller-placeholders.js +0 -52
  117. package/src/lib/doctor/controller-fix-plans.ts +0 -168
  118. package/src/lib/doctor/controller-placeholders.ts +0 -70
  119. package/template/app/assets/entry.ts +0 -17
@@ -1,31 +1,472 @@
1
1
  import * as process from 'node:process'
2
+ import { parseArgs } from 'node:util'
3
+ import type { RemixTestConfig, RemixTestPool } from '@remix-run/test/cli'
2
4
 
3
5
  import type { CliContext } from '../cli-context.ts'
4
- import { renderCliError, toCliError } from '../errors.ts'
6
+ import {
7
+ invalidOptionValue,
8
+ missingOptionValue,
9
+ renderCliError,
10
+ toCliError,
11
+ unknownArgument,
12
+ } from '../errors.ts'
13
+ import { formatHelpText } from '../help-text.ts'
14
+ import type { RemixTestCommandConfig } from '../remix-config.ts'
15
+
16
+ const testCommandOptions = {
17
+ 'browser.echo': { type: 'boolean' },
18
+ 'no-browser.echo': { type: 'boolean' },
19
+ 'browser.open': { type: 'boolean' },
20
+ 'no-browser.open': { type: 'boolean' },
21
+ 'glob.browser': { type: 'string', multiple: true },
22
+ 'glob.e2e': { type: 'string', multiple: true },
23
+ 'glob.exclude': { type: 'string', multiple: true },
24
+ 'glob.test': { type: 'string', multiple: true },
25
+ concurrency: { type: 'string', short: 'c' },
26
+ coverage: { type: 'boolean' },
27
+ 'no-coverage': { type: 'boolean' },
28
+ 'coverage.dir': { type: 'string' },
29
+ 'coverage.include': { type: 'string', multiple: true },
30
+ 'coverage.exclude': { type: 'string', multiple: true },
31
+ 'coverage.branches': { type: 'string' },
32
+ 'coverage.functions': { type: 'string' },
33
+ 'coverage.lines': { type: 'string' },
34
+ 'coverage.statements': { type: 'string' },
35
+ setup: { type: 'string', short: 's' },
36
+ playwrightConfig: { type: 'string' },
37
+ project: { type: 'string', short: 'p', multiple: true },
38
+ pool: { type: 'string' },
39
+ quiet: { type: 'boolean', short: 'q' },
40
+ 'no-quiet': { type: 'boolean' },
41
+ only: { type: 'string', multiple: true },
42
+ reporter: { type: 'string', short: 'r' },
43
+ type: { type: 'string', short: 't', multiple: true },
44
+ watch: { type: 'boolean', short: 'w' },
45
+ 'no-watch': { type: 'boolean' },
46
+ } as const
47
+
48
+ interface TestFlagMeta {
49
+ /** Base help description; "(repeatable)" and "(default: ...)" are appended automatically. */
50
+ description: string
51
+ /** Placeholder for the flag's value in help text, e.g. `glob` renders as `<glob>`. */
52
+ valueHint?: string
53
+ /** Complete this flag's value as a file path in shell completion. */
54
+ files?: boolean
55
+ /** Default value shown in help text. */
56
+ default?: string
57
+ }
58
+
59
+ // The annotation keeps this table in lockstep with `testCommandOptions`: adding
60
+ // or removing a flag without updating its metadata is a compile error.
61
+ const testCommandFlagMeta: Record<keyof typeof testCommandOptions, TestFlagMeta> = {
62
+ 'browser.echo': { description: 'Echo browser console output' },
63
+ 'no-browser.echo': { description: 'Do not echo browser console output' },
64
+ 'browser.open': { description: 'Open the browser after tests finish' },
65
+ 'no-browser.open': { description: 'Do not open the browser after tests finish' },
66
+ 'glob.browser': {
67
+ description: 'Glob pattern for browser test files',
68
+ valueHint: 'glob',
69
+ files: true,
70
+ },
71
+ 'glob.e2e': { description: 'Glob pattern for E2E test files', valueHint: 'glob', files: true },
72
+ 'glob.exclude': {
73
+ description: 'Glob pattern to exclude from discovery',
74
+ valueHint: 'glob',
75
+ files: true,
76
+ },
77
+ 'glob.test': {
78
+ description: 'Glob pattern for all test files',
79
+ valueHint: 'glob',
80
+ files: true,
81
+ default: '**/*.test{,.e2e,.browser}.{ts,tsx}',
82
+ },
83
+ concurrency: {
84
+ description: 'Maximum concurrent test workers',
85
+ valueHint: 'count',
86
+ default: 'os.availableParallelism()',
87
+ },
88
+ coverage: { description: 'Collect test coverage', default: 'false' },
89
+ 'no-coverage': { description: 'Do not collect test coverage' },
90
+ 'coverage.dir': {
91
+ description: 'Coverage report output directory',
92
+ valueHint: 'path',
93
+ default: '.coverage',
94
+ },
95
+ 'coverage.include': { description: 'Coverage inclusion glob', valueHint: 'glob' },
96
+ 'coverage.exclude': { description: 'Coverage exclusion glob', valueHint: 'glob' },
97
+ 'coverage.branches': { description: 'Minimum branch coverage percentage', valueHint: 'percent' },
98
+ 'coverage.functions': {
99
+ description: 'Minimum function coverage percentage',
100
+ valueHint: 'percent',
101
+ },
102
+ 'coverage.lines': { description: 'Minimum line coverage percentage', valueHint: 'percent' },
103
+ 'coverage.statements': {
104
+ description: 'Minimum statement coverage percentage',
105
+ valueHint: 'percent',
106
+ },
107
+ setup: { description: 'Path to a setup module', valueHint: 'path', files: true },
108
+ playwrightConfig: {
109
+ description: 'Path to a Playwright config file',
110
+ valueHint: 'path',
111
+ files: true,
112
+ },
113
+ project: { description: 'Playwright project name', valueHint: 'name' },
114
+ pool: { description: 'Worker pool: forks or threads', valueHint: 'pool', default: 'forks' },
115
+ quiet: { description: 'Do not print skipped tests' },
116
+ 'no-quiet': { description: 'Print skipped tests' },
117
+ only: { description: 'Test name pattern', valueHint: 'pattern' },
118
+ reporter: {
119
+ description: 'Reporter: spec, files, tap, or dot',
120
+ valueHint: 'name',
121
+ default: 'spec',
122
+ },
123
+ type: {
124
+ description: 'Test type: server, browser, or e2e',
125
+ valueHint: 'type',
126
+ default: 'server, browser, e2e',
127
+ },
128
+ watch: { description: 'Re-run tests when files change' },
129
+ 'no-watch': { description: 'Do not re-run tests when files change' },
130
+ }
131
+
132
+ /**
133
+ * Flag descriptors for `remix test`, derived from the parse table so that
134
+ * argument parsing, help text, and shell completion share one source of truth.
135
+ */
136
+ export interface TestCommandFlag {
137
+ /** Long flag, e.g. `--glob.test`. */
138
+ name: string
139
+ /** Short alias, e.g. `-c`. */
140
+ alias?: string
141
+ type: 'boolean' | 'string'
142
+ /** Whether the flag may be repeated. */
143
+ multiple: boolean
144
+ /** Whether the flag's value completes as a file path. */
145
+ files: boolean
146
+ }
147
+
148
+ export const testCommandFlags: TestCommandFlag[] = Object.entries(
149
+ testCommandOptions as Record<
150
+ string,
151
+ { type: 'boolean' | 'string'; multiple?: boolean; short?: string }
152
+ >,
153
+ ).map(([name, option]) => ({
154
+ name: `--${name}`,
155
+ alias: option.short === undefined ? undefined : `-${option.short}`,
156
+ type: option.type,
157
+ multiple: option.multiple === true,
158
+ files: testCommandFlagMeta[name as keyof typeof testCommandOptions].files === true,
159
+ }))
5
160
 
6
161
  export async function runTestCommand(argv: string[], context: CliContext): Promise<number> {
7
- if (argv.includes('-h') || argv.includes('--help')) {
8
- process.stdout.write(`${await getTestCommandHelpText()}\n`)
162
+ if (wantsHelp(argv)) {
163
+ process.stdout.write(getTestCommandHelpText())
9
164
  return 0
10
165
  }
11
166
 
12
167
  try {
13
- let { runRemixTest } = await import('@remix-run/test/cli')
14
- return await runRemixTest({ argv, cwd: context.cwd })
168
+ let { remixTestPools, runRemixTest } = await import('@remix-run/test/cli')
169
+ let config = await context.loadConfig()
170
+ let options = resolveTestCommandOptions(argv, config.test, remixTestPools)
171
+ return await runRemixTest({ ...options, cwd: context.cwd })
15
172
  } catch (error) {
16
- let helpText = await getTestCommandHelpText(process.stderr)
17
173
  process.stderr.write(
18
174
  renderCliError(toCliError(error), {
19
- helpText,
175
+ helpText: getTestCommandHelpText(process.stderr),
20
176
  }),
21
177
  )
22
178
  return 1
23
179
  }
24
180
  }
25
181
 
26
- export async function getTestCommandHelpText(
27
- target: NodeJS.WriteStream = process.stdout,
28
- ): Promise<string> {
29
- let { getRemixTestHelpText } = await import('@remix-run/test/cli')
30
- return getRemixTestHelpText(target)
182
+ export function resolveTestCommandOptions(
183
+ argv: string[],
184
+ config: RemixTestCommandConfig | undefined,
185
+ pools: readonly RemixTestPool[],
186
+ ): RemixTestConfig {
187
+ let cliOptions = parseTestCommandArgs(argv, pools)
188
+ let configOptions = createTestOptionsFromConfig(config)
189
+ return mergeTestOptions(configOptions, cliOptions)
190
+ }
191
+
192
+ export function getTestCommandHelpText(target: NodeJS.WriteStream = process.stdout): string {
193
+ return formatHelpText(
194
+ {
195
+ description: 'Run tests for the current project.',
196
+ examples: [
197
+ 'remix test',
198
+ 'remix test app/**/*.test.ts',
199
+ 'remix test --type server --concurrency 1',
200
+ 'remix test --coverage',
201
+ 'remix test --watch',
202
+ ],
203
+ options: [
204
+ ...Object.entries(testCommandOptions).map(([name, option]) => {
205
+ let meta = testCommandFlagMeta[name as keyof typeof testCommandOptions]
206
+ let short = 'short' in option ? `-${option.short}, ` : ''
207
+ let value = option.type === 'string' ? ` <${meta.valueHint}>` : ''
208
+ let repeatable = 'multiple' in option && option.multiple ? ' (repeatable)' : ''
209
+ let defaultValue = meta.default === undefined ? '' : ` (default: ${meta.default})`
210
+
211
+ return {
212
+ description: `${meta.description}${repeatable}${defaultValue}`,
213
+ label: `${short}--${name}${value}`,
214
+ }
215
+ }),
216
+ { description: 'Use a custom Remix config file', label: '--config <path>' },
217
+ { description: 'Show help', label: '-h, --help' },
218
+ { description: 'Disable ANSI color output', label: '--no-color' },
219
+ ],
220
+ usage: ['remix test [glob...] [options]'],
221
+ },
222
+ target,
223
+ )
224
+ }
225
+
226
+ function wantsHelp(argv: string[]): boolean {
227
+ // Everything after a bare `--` is a positional test file glob, not a flag
228
+ let separatorIndex = argv.indexOf('--')
229
+ let flags = separatorIndex === -1 ? argv : argv.slice(0, separatorIndex)
230
+ return flags.includes('-h') || flags.includes('--help')
231
+ }
232
+
233
+ function parseTestCommandArgs(argv: string[], pools: readonly RemixTestPool[]) {
234
+ let parsed: ReturnType<typeof parseTestCommandArgsRaw>
235
+
236
+ try {
237
+ parsed = parseTestCommandArgsRaw(argv)
238
+ } catch (error) {
239
+ throw toTestCommandUsageError(error)
240
+ }
241
+
242
+ let { positionals, tokens, values } = parsed
243
+ let coverage = createCoverageOptions(values, tokens)
244
+ let glob = compactObject({
245
+ browser: values['glob.browser'],
246
+ e2e: values['glob.e2e'],
247
+ exclude: values['glob.exclude'],
248
+ test: positionals.length > 0 ? positionals : values['glob.test'],
249
+ })
250
+
251
+ return compactObject({
252
+ browser: compactObject({
253
+ echo: resolveBooleanOption(tokens, 'browser.echo', 'no-browser.echo'),
254
+ open: resolveBooleanOption(tokens, 'browser.open', 'no-browser.open'),
255
+ }),
256
+ concurrency: optionalPositiveInteger(values.concurrency, '--concurrency'),
257
+ coverage,
258
+ glob,
259
+ only: values.only,
260
+ playwrightConfig: values.playwrightConfig,
261
+ pool: parsePool(values.pool, pools),
262
+ project: values.project,
263
+ quiet: resolveBooleanOption(tokens, 'quiet', 'no-quiet'),
264
+ reporter: values.reporter,
265
+ setup: values.setup,
266
+ type: values.type,
267
+ watch: resolveBooleanOption(tokens, 'watch', 'no-watch'),
268
+ })
269
+ }
270
+
271
+ function createCoverageOptions(
272
+ values: ReturnType<typeof parseTestCommandArgsRaw>['values'],
273
+ tokens: ReturnType<typeof parseTestCommandArgsRaw>['tokens'],
274
+ ) {
275
+ let options = compactObject({
276
+ branches: optionalNumber(values['coverage.branches'], '--coverage.branches'),
277
+ dir: values['coverage.dir'],
278
+ exclude: values['coverage.exclude'],
279
+ functions: optionalNumber(values['coverage.functions'], '--coverage.functions'),
280
+ include: values['coverage.include'],
281
+ lines: optionalNumber(values['coverage.lines'], '--coverage.lines'),
282
+ statements: optionalNumber(values['coverage.statements'], '--coverage.statements'),
283
+ })
284
+
285
+ let enabled = resolveBooleanOption(tokens, 'coverage', 'no-coverage')
286
+ if (enabled === true) {
287
+ return options ?? true
288
+ }
289
+
290
+ if (enabled === false) {
291
+ return false
292
+ }
293
+
294
+ // Coverage settings without --coverage refine the config file's coverage
295
+ // options while leaving enablement up to the config file.
296
+ return options == null ? undefined : { ...options, enabled: 'inherit' as const }
297
+ }
298
+
299
+ function parseTestCommandArgsRaw(argv: string[]) {
300
+ return parseArgs({
301
+ allowPositionals: true,
302
+ args: argv,
303
+ options: testCommandOptions,
304
+ tokens: true,
305
+ })
306
+ }
307
+
308
+ function resolveBooleanOption(
309
+ tokens: ReturnType<typeof parseTestCommandArgsRaw>['tokens'],
310
+ enabledName: string,
311
+ disabledName: string,
312
+ ): boolean | undefined {
313
+ let value: boolean | undefined
314
+
315
+ for (let token of tokens) {
316
+ if (token.kind !== 'option') continue
317
+ if (token.name === enabledName) value = true
318
+ if (token.name === disabledName) value = false
319
+ }
320
+
321
+ return value
322
+ }
323
+
324
+ function createTestOptionsFromConfig(config: RemixTestCommandConfig | undefined): RemixTestConfig {
325
+ if (config === undefined) return {}
326
+
327
+ return {
328
+ browser:
329
+ config.playwright === undefined
330
+ ? undefined
331
+ : {
332
+ echo: config.playwright.echo,
333
+ open: config.playwright.open,
334
+ },
335
+ concurrency: config.concurrency,
336
+ coverage: config.coverage,
337
+ glob: {
338
+ browser: config.browserFiles,
339
+ e2e: config.e2eFiles,
340
+ exclude: config.exclude,
341
+ test: config.files,
342
+ },
343
+ only: config.only,
344
+ playwrightConfig: config.playwright?.configFile,
345
+ pool: config.pool,
346
+ project: config.playwright?.projects,
347
+ quiet: config.quiet,
348
+ reporter: config.reporter,
349
+ setup: config.setup,
350
+ type: config.type,
351
+ watch: config.watch,
352
+ }
353
+ }
354
+
355
+ function mergeTestOptions(
356
+ config: RemixTestConfig,
357
+ cli: RemixTestConfig | undefined,
358
+ ): RemixTestConfig {
359
+ if (cli === undefined) return config
360
+
361
+ // CLI options are compacted (no undefined values), so spreading them over
362
+ // the config gives CLI input precedence; nested objects merge by field.
363
+ return {
364
+ ...config,
365
+ ...cli,
366
+ browser: mergeObject(config.browser, cli.browser),
367
+ coverage: mergeCoverage(config.coverage, cli.coverage),
368
+ glob: mergeObject(config.glob, cli.glob),
369
+ }
370
+ }
371
+
372
+ function mergeObject<value extends object>(
373
+ config: value | undefined,
374
+ cli: value | undefined,
375
+ ): value | undefined {
376
+ if (config === undefined) return cli
377
+ if (cli === undefined) return config
378
+ return { ...config, ...cli }
379
+ }
380
+
381
+ function mergeCoverage(
382
+ config: RemixTestConfig['coverage'],
383
+ cli: RemixTestConfig['coverage'],
384
+ ): RemixTestConfig['coverage'] {
385
+ if (cli === undefined) return config
386
+ if (cli === false) return false
387
+
388
+ let configOptions = typeof config === 'object' && config !== null ? config : {}
389
+ if (cli === true) return { ...configOptions, enabled: true }
390
+
391
+ let enabled =
392
+ cli.enabled === 'inherit'
393
+ ? typeof config === 'boolean'
394
+ ? config
395
+ : config === undefined
396
+ ? false
397
+ : (config.enabled ?? true)
398
+ : cli.enabled
399
+
400
+ return { ...configOptions, ...cli, enabled }
401
+ }
402
+
403
+ function optionalNumber(value: string | undefined, flag: string): number | undefined {
404
+ if (value === undefined) {
405
+ return undefined
406
+ }
407
+
408
+ let parsed = Number(value)
409
+ if (value.trim() === '' || Number.isNaN(parsed)) {
410
+ throw invalidOptionValue(`Invalid ${flag} value "${value}". Expected a number`)
411
+ }
412
+
413
+ return parsed
414
+ }
415
+
416
+ function optionalPositiveInteger(value: string | undefined, flag: string): number | undefined {
417
+ let parsed = optionalNumber(value, flag)
418
+ if (parsed !== undefined && (!Number.isInteger(parsed) || parsed < 1)) {
419
+ throw invalidOptionValue(`Invalid ${flag} value "${value}". Expected a positive integer`)
420
+ }
421
+
422
+ return parsed
423
+ }
424
+
425
+ function parsePool(
426
+ value: string | undefined,
427
+ pools: readonly RemixTestPool[],
428
+ ): RemixTestPool | undefined {
429
+ if (value === undefined || (pools as readonly string[]).includes(value)) {
430
+ return value as RemixTestPool | undefined
431
+ }
432
+
433
+ throw invalidOptionValue(
434
+ `Unsupported test pool "${value}". Supported pools are: ${pools.join(', ')}`,
435
+ )
436
+ }
437
+
438
+ function compactObject<shape extends Record<string, unknown>>(value: shape): shape | undefined {
439
+ for (let key in value) {
440
+ if (value[key] === undefined) delete value[key]
441
+ }
442
+
443
+ return Object.keys(value).length === 0 ? undefined : value
444
+ }
445
+
446
+ function toTestCommandUsageError(error: unknown): unknown {
447
+ if (!isErrorWithCode(error)) {
448
+ return error
449
+ }
450
+
451
+ if (error.code === 'ERR_PARSE_ARGS_UNKNOWN_OPTION') {
452
+ let option = error.message.match(/'([^']+)'/)?.[1]
453
+ return unknownArgument(option ?? error.message)
454
+ }
455
+
456
+ if (error.code === 'ERR_PARSE_ARGS_INVALID_OPTION_VALUE') {
457
+ // parseArgs reports a flag with no value as an invalid-value error, e.g.
458
+ // "Option '--config <value>' argument missing"; surface it as missing.
459
+ let missing = error.message.match(/^Option '(.+?) <value>' argument missing/)
460
+ if (missing !== null) {
461
+ return missingOptionValue(missing[1])
462
+ }
463
+
464
+ return invalidOptionValue(error.message)
465
+ }
466
+
467
+ return error
468
+ }
469
+
470
+ function isErrorWithCode(error: unknown): error is Error & { code: string } {
471
+ return error instanceof Error && 'code' in error && typeof error.code === 'string'
31
472
  }