@remix-run/cli 0.3.4 → 0.4.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.
- package/README.md +105 -1
- package/dist/index.js +1 -1
- package/dist/lib/app-root.d.ts +11 -0
- package/dist/lib/app-root.d.ts.map +1 -0
- package/dist/lib/app-root.js +38 -0
- package/dist/lib/bootstrap-project.js +2 -2
- package/dist/lib/cli-context.d.ts +6 -0
- package/dist/lib/cli-context.d.ts.map +1 -1
- package/dist/lib/cli-context.js +12 -1
- package/dist/lib/cli.d.ts +3 -2
- package/dist/lib/cli.d.ts.map +1 -1
- package/dist/lib/cli.js +42 -14
- package/dist/lib/commands/completion.js +4 -4
- package/dist/lib/commands/db.d.ts +4 -0
- package/dist/lib/commands/db.d.ts.map +1 -0
- package/dist/lib/commands/db.js +283 -0
- package/dist/lib/commands/doctor.d.ts +5 -0
- package/dist/lib/commands/doctor.d.ts.map +1 -1
- package/dist/lib/commands/doctor.js +26 -251
- package/dist/lib/commands/help.d.ts.map +1 -1
- package/dist/lib/commands/help.js +17 -9
- package/dist/lib/commands/new.js +6 -6
- package/dist/lib/commands/routes.js +6 -6
- package/dist/lib/commands/test.d.ts +20 -1
- package/dist/lib/commands/test.d.ts.map +1 -1
- package/dist/lib/commands/test.js +353 -10
- package/dist/lib/commands/version.js +3 -3
- package/dist/lib/completion.d.ts +1 -1
- package/dist/lib/completion.d.ts.map +1 -1
- package/dist/lib/completion.js +229 -6
- package/dist/lib/controller-files.d.ts +0 -8
- package/dist/lib/controller-files.d.ts.map +1 -1
- package/dist/lib/controller-files.js +0 -23
- package/dist/lib/controller-ownership.d.ts +4 -12
- package/dist/lib/controller-ownership.d.ts.map +1 -1
- package/dist/lib/controller-ownership.js +31 -127
- package/dist/lib/database-command.d.ts +20 -0
- package/dist/lib/database-command.d.ts.map +1 -0
- package/dist/lib/database-command.js +7 -0
- package/dist/lib/doctor/controller-findings.d.ts +2 -2
- package/dist/lib/doctor/controller-findings.d.ts.map +1 -1
- package/dist/lib/doctor/controller-findings.js +23 -39
- package/dist/lib/doctor/controllers.d.ts +1 -2
- package/dist/lib/doctor/controllers.d.ts.map +1 -1
- package/dist/lib/doctor/controllers.js +4 -7
- package/dist/lib/doctor/environment.js +2 -2
- package/dist/lib/doctor/fixes.js +1 -1
- package/dist/lib/doctor/project.d.ts.map +1 -1
- package/dist/lib/doctor/project.js +3 -212
- package/dist/lib/doctor/run.d.ts +9 -0
- package/dist/lib/doctor/run.d.ts.map +1 -0
- package/dist/lib/doctor/run.js +237 -0
- package/dist/lib/doctor/types.d.ts +2 -1
- package/dist/lib/doctor/types.d.ts.map +1 -1
- package/dist/lib/doctor/types.js +4 -1
- package/dist/lib/errors.d.ts +30 -0
- package/dist/lib/errors.d.ts.map +1 -1
- package/dist/lib/errors.js +55 -0
- package/dist/lib/help-text.js +1 -1
- package/dist/lib/parse-args.d.ts.map +1 -1
- package/dist/lib/parse-args.js +17 -4
- package/dist/lib/remix-config.d.ts +75 -0
- package/dist/lib/remix-config.d.ts.map +1 -0
- package/dist/lib/remix-config.js +411 -0
- package/dist/lib/reporter.js +1 -1
- package/dist/lib/route-map.js +13 -33
- package/package.json +13 -7
- package/schema/remix.json +242 -0
- package/src/lib/app-root.ts +46 -0
- package/src/lib/cli-context.ts +17 -0
- package/src/lib/cli.ts +41 -6
- package/src/lib/commands/db.ts +377 -0
- package/src/lib/commands/doctor.ts +27 -329
- package/src/lib/commands/help.ts +10 -1
- package/src/lib/commands/test.ts +453 -12
- package/src/lib/completion.ts +321 -6
- package/src/lib/controller-files.ts +0 -38
- package/src/lib/controller-ownership.ts +50 -203
- package/src/lib/database-command.ts +31 -0
- package/src/lib/doctor/controller-findings.ts +30 -47
- package/src/lib/doctor/controllers.ts +2 -6
- package/src/lib/doctor/project.ts +0 -236
- package/src/lib/doctor/run.ts +311 -0
- package/src/lib/doctor/types.ts +6 -5
- package/src/lib/errors.ts +65 -0
- package/src/lib/parse-args.ts +19 -3
- package/src/lib/remix-config.ts +639 -0
- package/src/lib/route-map.ts +11 -48
- package/template/.agents/skills/remix/SKILL.md +38 -27
- package/template/.agents/skills/remix/references/assets-and-browser-modules.md +53 -10
- package/template/.agents/skills/remix/references/auth-and-sessions.md +5 -5
- package/template/.agents/skills/remix/references/data-and-validation.md +15 -15
- package/template/.agents/skills/remix/references/hydration-frames-navigation.md +54 -8
- package/template/.agents/skills/remix/references/middleware-and-server.md +68 -6
- package/template/.agents/skills/remix/references/mixins-styling-events.md +2 -0
- package/template/.agents/skills/remix/references/routing-and-controllers.md +8 -8
- package/template/.agents/skills/remix/references/testing-patterns.md +19 -18
- package/template/AGENTS.md +6 -3
- package/template/README.md +6 -3
- package/template/app/actions/controller.tsx +1 -1
- package/template/app/{ui → actions}/document.tsx +7 -5
- package/template/app/{ui/scaffold-home-page.tsx → actions/home-page.tsx} +1 -2
- package/template/app/actions/public/entry.ts +48 -0
- package/template/app/{assets → actions/public}/prompt-button.tsx +29 -25
- package/template/app/assets.ts +15 -2
- package/template/hmr.ts +46 -0
- package/template/package.json +1 -0
- package/template/server.ts +8 -1
- package/template/tsconfig.json +1 -1
- package/dist/lib/doctor/controller-fix-plans.d.ts +0 -4
- package/dist/lib/doctor/controller-fix-plans.d.ts.map +0 -1
- package/dist/lib/doctor/controller-fix-plans.js +0 -119
- package/dist/lib/doctor/controller-placeholders.d.ts +0 -3
- package/dist/lib/doctor/controller-placeholders.d.ts.map +0 -1
- package/dist/lib/doctor/controller-placeholders.js +0 -52
- package/src/lib/doctor/controller-fix-plans.ts +0 -168
- package/src/lib/doctor/controller-placeholders.ts +0 -70
- package/template/app/assets/entry.ts +0 -17
|
@@ -0,0 +1,639 @@
|
|
|
1
|
+
import * as fs from 'node:fs/promises'
|
|
2
|
+
import * as path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
findNodeAtLocation,
|
|
5
|
+
getNodeValue,
|
|
6
|
+
parseTree,
|
|
7
|
+
printParseErrorCode,
|
|
8
|
+
type Node as JsonNode,
|
|
9
|
+
type ParseError,
|
|
10
|
+
} from 'jsonc-parser'
|
|
11
|
+
import type { RemixTestPool } from '@remix-run/test/cli'
|
|
12
|
+
|
|
13
|
+
import { invalidRemixConfig, remixConfigNotFound } from './errors.ts'
|
|
14
|
+
|
|
15
|
+
const reporters = ['spec', 'files', 'tap', 'dot'] as const
|
|
16
|
+
const testPools: readonly RemixTestPool[] = ['forks', 'threads']
|
|
17
|
+
const testTypes = ['server', 'browser', 'e2e'] as const
|
|
18
|
+
|
|
19
|
+
type Reporter = (typeof reporters)[number]
|
|
20
|
+
type TestPool = RemixTestPool
|
|
21
|
+
type TestType = (typeof testTypes)[number]
|
|
22
|
+
type JsonPath = Array<number | string>
|
|
23
|
+
|
|
24
|
+
export interface RemixConfig {
|
|
25
|
+
db?: RemixDbCommandConfig
|
|
26
|
+
doctor?: RemixDoctorCommandConfig
|
|
27
|
+
test?: RemixTestCommandConfig
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type RemixDbString = string | { env: string; default?: string }
|
|
31
|
+
|
|
32
|
+
export type RemixDbAdapterConfig =
|
|
33
|
+
| {
|
|
34
|
+
type: 'sqlite'
|
|
35
|
+
filename: RemixDbString
|
|
36
|
+
foreignKeys?: boolean
|
|
37
|
+
busyTimeout?: number
|
|
38
|
+
}
|
|
39
|
+
| {
|
|
40
|
+
type: 'postgres'
|
|
41
|
+
connectionString: RemixDbString
|
|
42
|
+
maintenanceDatabase?: string
|
|
43
|
+
template?: string
|
|
44
|
+
}
|
|
45
|
+
| {
|
|
46
|
+
type: 'mysql'
|
|
47
|
+
uri: RemixDbString
|
|
48
|
+
characterSet?: string
|
|
49
|
+
collation?: string
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface RemixDbCommandConfig {
|
|
53
|
+
adapter: RemixDbAdapterConfig
|
|
54
|
+
migrations?: {
|
|
55
|
+
directory: string
|
|
56
|
+
journalTable?: string
|
|
57
|
+
}
|
|
58
|
+
seed?: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface RemixDoctorCommandConfig {
|
|
62
|
+
strict?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface RemixTestCommandConfig {
|
|
66
|
+
browserFiles?: string[]
|
|
67
|
+
concurrency?: number
|
|
68
|
+
coverage?: {
|
|
69
|
+
branches?: number
|
|
70
|
+
dir?: string
|
|
71
|
+
enabled?: boolean
|
|
72
|
+
exclude?: string[]
|
|
73
|
+
functions?: number
|
|
74
|
+
include?: string[]
|
|
75
|
+
lines?: number
|
|
76
|
+
statements?: number
|
|
77
|
+
}
|
|
78
|
+
e2eFiles?: string[]
|
|
79
|
+
exclude?: string[]
|
|
80
|
+
files?: string[]
|
|
81
|
+
only?: string[]
|
|
82
|
+
playwright?: {
|
|
83
|
+
configFile?: string
|
|
84
|
+
echo?: boolean
|
|
85
|
+
open?: boolean
|
|
86
|
+
projects?: string[]
|
|
87
|
+
}
|
|
88
|
+
pool?: TestPool
|
|
89
|
+
quiet?: boolean
|
|
90
|
+
reporter?: Reporter
|
|
91
|
+
setup?: string
|
|
92
|
+
type?: TestType[]
|
|
93
|
+
watch?: boolean
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
interface ConfigSource {
|
|
97
|
+
filePath: string
|
|
98
|
+
root: JsonNode | undefined
|
|
99
|
+
text: string
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export async function loadRemixConfig(
|
|
103
|
+
cwd: string,
|
|
104
|
+
configPath: string | undefined,
|
|
105
|
+
): Promise<RemixConfig> {
|
|
106
|
+
let filePath = path.resolve(cwd, configPath ?? 'remix.json')
|
|
107
|
+
let text: string
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
text = await fs.readFile(filePath, 'utf8')
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (isNodeError(error) && error.code === 'ENOENT') {
|
|
113
|
+
if (configPath === undefined) return {}
|
|
114
|
+
throw remixConfigNotFound(filePath)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
throw error
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Editors saving "UTF-8 with BOM" prefix the file with U+FEFF, which
|
|
121
|
+
// jsonc-parser reports as an InvalidSymbol error.
|
|
122
|
+
if (text.charCodeAt(0) === 0xfeff) text = text.slice(1)
|
|
123
|
+
|
|
124
|
+
let parseErrors: ParseError[] = []
|
|
125
|
+
let root = parseTree(text, parseErrors, {
|
|
126
|
+
allowTrailingComma: true,
|
|
127
|
+
disallowComments: false,
|
|
128
|
+
})
|
|
129
|
+
let value: unknown = root === undefined ? undefined : getNodeValue(root)
|
|
130
|
+
let source = { filePath, root, text }
|
|
131
|
+
|
|
132
|
+
// An empty or comments-only file has no JSON value at all; treat it like a
|
|
133
|
+
// missing file rather than a malformed one.
|
|
134
|
+
if (
|
|
135
|
+
root === undefined &&
|
|
136
|
+
parseErrors.every((error) => printParseErrorCode(error.error) === 'ValueExpected')
|
|
137
|
+
) {
|
|
138
|
+
return {}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (parseErrors.length > 0) {
|
|
142
|
+
let error = parseErrors[0]!
|
|
143
|
+
throwConfigError(source, [], printParseErrorCode(error.error), error.offset)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return parseConfig(value, source, path.dirname(filePath), cwd)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function parseConfig(
|
|
150
|
+
value: unknown,
|
|
151
|
+
source: ConfigSource,
|
|
152
|
+
configDir: string,
|
|
153
|
+
cwd: string,
|
|
154
|
+
): RemixConfig {
|
|
155
|
+
let object = requireObject(value, source, [])
|
|
156
|
+
requireKnownProperties(object, ['$schema', 'db', 'doctor', 'test'], source, [])
|
|
157
|
+
|
|
158
|
+
if (object.$schema !== undefined) {
|
|
159
|
+
requireString(object.$schema, source, ['$schema'])
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
let config: RemixConfig = {}
|
|
163
|
+
|
|
164
|
+
if (object.db !== undefined) {
|
|
165
|
+
config.db = parseDbConfig(object.db, source, configDir)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (object.doctor !== undefined) {
|
|
169
|
+
config.doctor = parseDoctorConfig(object.doctor, source)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
if (object.test !== undefined) {
|
|
173
|
+
config.test = parseTestConfig(object.test, source, configDir, cwd)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return config
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function parseDbConfig(
|
|
180
|
+
value: unknown,
|
|
181
|
+
source: ConfigSource,
|
|
182
|
+
configDir: string,
|
|
183
|
+
): RemixDbCommandConfig {
|
|
184
|
+
let objectPath = ['db']
|
|
185
|
+
let object = requireObject(value, source, objectPath)
|
|
186
|
+
requireKnownProperties(object, ['adapter', 'migrations', 'seed'], source, objectPath)
|
|
187
|
+
|
|
188
|
+
if (object.adapter === undefined) {
|
|
189
|
+
throwConfigError(source, [...objectPath, 'adapter'], 'Expected an adapter configuration')
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let config: RemixDbCommandConfig = {
|
|
193
|
+
adapter: parseDbAdapterConfig(object.adapter, source),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (object.migrations !== undefined) {
|
|
197
|
+
let migrationsPath = [...objectPath, 'migrations']
|
|
198
|
+
let migrations = requireObject(object.migrations, source, migrationsPath)
|
|
199
|
+
requireKnownProperties(migrations, ['directory', 'journalTable'], source, migrationsPath)
|
|
200
|
+
let directory = requireString(migrations.directory, source, [...migrationsPath, 'directory'])
|
|
201
|
+
let journalTable = optionalString(migrations.journalTable, source, [
|
|
202
|
+
...migrationsPath,
|
|
203
|
+
'journalTable',
|
|
204
|
+
])
|
|
205
|
+
config.migrations = {
|
|
206
|
+
directory: path.resolve(configDir, directory),
|
|
207
|
+
journalTable,
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (object.seed !== undefined) {
|
|
212
|
+
let seed = requireString(object.seed, source, [...objectPath, 'seed'])
|
|
213
|
+
config.seed = path.resolve(configDir, seed)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return config
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function parseDbAdapterConfig(value: unknown, source: ConfigSource): RemixDbAdapterConfig {
|
|
220
|
+
let objectPath = ['db', 'adapter']
|
|
221
|
+
let object = requireObject(value, source, objectPath)
|
|
222
|
+
let type = requireEnum(object.type, ['sqlite', 'postgres', 'mysql'], source, [
|
|
223
|
+
...objectPath,
|
|
224
|
+
'type',
|
|
225
|
+
])
|
|
226
|
+
|
|
227
|
+
if (type === 'sqlite') {
|
|
228
|
+
requireKnownProperties(
|
|
229
|
+
object,
|
|
230
|
+
['busyTimeout', 'filename', 'foreignKeys', 'type'],
|
|
231
|
+
source,
|
|
232
|
+
objectPath,
|
|
233
|
+
)
|
|
234
|
+
let busyTimeout = optionalNumber(object.busyTimeout, source, [...objectPath, 'busyTimeout'])
|
|
235
|
+
if (busyTimeout !== undefined && busyTimeout < 0) {
|
|
236
|
+
throwConfigError(source, [...objectPath, 'busyTimeout'], 'Expected a non-negative number')
|
|
237
|
+
}
|
|
238
|
+
return {
|
|
239
|
+
type,
|
|
240
|
+
filename: parseDbString(object.filename, source, [...objectPath, 'filename']),
|
|
241
|
+
foreignKeys: optionalBoolean(object.foreignKeys, source, [...objectPath, 'foreignKeys']),
|
|
242
|
+
busyTimeout,
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (type === 'postgres') {
|
|
247
|
+
requireKnownProperties(
|
|
248
|
+
object,
|
|
249
|
+
['connectionString', 'maintenanceDatabase', 'template', 'type'],
|
|
250
|
+
source,
|
|
251
|
+
objectPath,
|
|
252
|
+
)
|
|
253
|
+
return {
|
|
254
|
+
type,
|
|
255
|
+
connectionString: parseDbString(object.connectionString, source, [
|
|
256
|
+
...objectPath,
|
|
257
|
+
'connectionString',
|
|
258
|
+
]),
|
|
259
|
+
maintenanceDatabase: optionalString(object.maintenanceDatabase, source, [
|
|
260
|
+
...objectPath,
|
|
261
|
+
'maintenanceDatabase',
|
|
262
|
+
]),
|
|
263
|
+
template: optionalString(object.template, source, [...objectPath, 'template']),
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
requireKnownProperties(object, ['characterSet', 'collation', 'type', 'uri'], source, objectPath)
|
|
268
|
+
return {
|
|
269
|
+
type,
|
|
270
|
+
uri: parseDbString(object.uri, source, [...objectPath, 'uri']),
|
|
271
|
+
characterSet: optionalString(object.characterSet, source, [...objectPath, 'characterSet']),
|
|
272
|
+
collation: optionalString(object.collation, source, [...objectPath, 'collation']),
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function parseDbString(
|
|
277
|
+
value: unknown,
|
|
278
|
+
source: ConfigSource,
|
|
279
|
+
propertyPath: JsonPath,
|
|
280
|
+
): RemixDbString {
|
|
281
|
+
if (typeof value === 'string') return value
|
|
282
|
+
|
|
283
|
+
let object = requireObject(value, source, propertyPath)
|
|
284
|
+
requireKnownProperties(object, ['default', 'env'], source, propertyPath)
|
|
285
|
+
let env = requireString(object.env, source, [...propertyPath, 'env'])
|
|
286
|
+
let defaultValue = optionalString(object.default, source, [...propertyPath, 'default'])
|
|
287
|
+
return defaultValue === undefined ? { env } : { env, default: defaultValue }
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function parseDoctorConfig(value: unknown, source: ConfigSource): RemixDoctorCommandConfig {
|
|
291
|
+
let objectPath = ['doctor']
|
|
292
|
+
let object = requireObject(value, source, objectPath)
|
|
293
|
+
requireKnownProperties(object, ['strict'], source, objectPath)
|
|
294
|
+
|
|
295
|
+
let config: RemixDoctorCommandConfig = {}
|
|
296
|
+
let strict = optionalBoolean(object.strict, source, [...objectPath, 'strict'])
|
|
297
|
+
if (strict !== undefined) config.strict = strict
|
|
298
|
+
return config
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function parseTestConfig(
|
|
302
|
+
value: unknown,
|
|
303
|
+
source: ConfigSource,
|
|
304
|
+
configDir: string,
|
|
305
|
+
cwd: string,
|
|
306
|
+
): RemixTestCommandConfig {
|
|
307
|
+
let objectPath = ['test']
|
|
308
|
+
let object = requireObject(value, source, objectPath)
|
|
309
|
+
requireKnownProperties(
|
|
310
|
+
object,
|
|
311
|
+
[
|
|
312
|
+
'browserFiles',
|
|
313
|
+
'concurrency',
|
|
314
|
+
'coverage',
|
|
315
|
+
'e2eFiles',
|
|
316
|
+
'exclude',
|
|
317
|
+
'files',
|
|
318
|
+
'only',
|
|
319
|
+
'playwright',
|
|
320
|
+
'pool',
|
|
321
|
+
'quiet',
|
|
322
|
+
'reporter',
|
|
323
|
+
'setup',
|
|
324
|
+
'type',
|
|
325
|
+
'watch',
|
|
326
|
+
],
|
|
327
|
+
source,
|
|
328
|
+
objectPath,
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
let config: RemixTestCommandConfig = {}
|
|
332
|
+
let browserFiles = optionalStringArray(object.browserFiles, source, [
|
|
333
|
+
...objectPath,
|
|
334
|
+
'browserFiles',
|
|
335
|
+
])
|
|
336
|
+
let e2eFiles = optionalStringArray(object.e2eFiles, source, [...objectPath, 'e2eFiles'])
|
|
337
|
+
let exclude = optionalStringArray(object.exclude, source, [...objectPath, 'exclude'])
|
|
338
|
+
let files = optionalStringArray(object.files, source, [...objectPath, 'files'])
|
|
339
|
+
let only = optionalStringArray(object.only, source, [...objectPath, 'only'])
|
|
340
|
+
let concurrency = optionalNumber(object.concurrency, source, [...objectPath, 'concurrency'])
|
|
341
|
+
let setup = optionalString(object.setup, source, [...objectPath, 'setup'])
|
|
342
|
+
|
|
343
|
+
if (browserFiles !== undefined) config.browserFiles = resolveGlobs(browserFiles, configDir, cwd)
|
|
344
|
+
if (e2eFiles !== undefined) config.e2eFiles = resolveGlobs(e2eFiles, configDir, cwd)
|
|
345
|
+
if (exclude !== undefined) config.exclude = resolveGlobs(exclude, configDir, cwd)
|
|
346
|
+
if (files !== undefined) config.files = resolveGlobs(files, configDir, cwd)
|
|
347
|
+
if (only !== undefined) {
|
|
348
|
+
for (let [index, pattern] of only.entries()) {
|
|
349
|
+
validateOnlyPattern(pattern, source, [...objectPath, 'only', index])
|
|
350
|
+
}
|
|
351
|
+
config.only = only
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (concurrency !== undefined) {
|
|
355
|
+
if (!Number.isInteger(concurrency) || concurrency < 1) {
|
|
356
|
+
throwConfigError(source, [...objectPath, 'concurrency'], 'Expected a positive integer')
|
|
357
|
+
}
|
|
358
|
+
config.concurrency = concurrency
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
let pool = optionalEnum(object.pool, testPools, source, [...objectPath, 'pool'])
|
|
362
|
+
if (pool !== undefined) config.pool = pool
|
|
363
|
+
|
|
364
|
+
let quiet = optionalBoolean(object.quiet, source, [...objectPath, 'quiet'])
|
|
365
|
+
if (quiet !== undefined) config.quiet = quiet
|
|
366
|
+
|
|
367
|
+
let reporter = optionalEnum(object.reporter, reporters, source, [...objectPath, 'reporter'])
|
|
368
|
+
if (reporter !== undefined) config.reporter = reporter
|
|
369
|
+
|
|
370
|
+
if (setup !== undefined) config.setup = path.resolve(configDir, setup)
|
|
371
|
+
|
|
372
|
+
let type = optionalEnumArray(object.type, testTypes, source, [...objectPath, 'type'])
|
|
373
|
+
if (type !== undefined) config.type = type
|
|
374
|
+
|
|
375
|
+
let watch = optionalBoolean(object.watch, source, [...objectPath, 'watch'])
|
|
376
|
+
if (watch !== undefined) config.watch = watch
|
|
377
|
+
|
|
378
|
+
if (object.coverage !== undefined) {
|
|
379
|
+
config.coverage = parseCoverageConfig(object.coverage, source, configDir, cwd)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (object.playwright !== undefined) {
|
|
383
|
+
config.playwright = parsePlaywrightConfig(object.playwright, source, configDir)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
return config
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function parseCoverageConfig(
|
|
390
|
+
value: unknown,
|
|
391
|
+
source: ConfigSource,
|
|
392
|
+
configDir: string,
|
|
393
|
+
cwd: string,
|
|
394
|
+
): NonNullable<RemixTestCommandConfig['coverage']> {
|
|
395
|
+
let objectPath = ['test', 'coverage']
|
|
396
|
+
let object = requireObject(value, source, objectPath)
|
|
397
|
+
requireKnownProperties(
|
|
398
|
+
object,
|
|
399
|
+
['branches', 'dir', 'enabled', 'exclude', 'functions', 'include', 'lines', 'statements'],
|
|
400
|
+
source,
|
|
401
|
+
objectPath,
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
let config: NonNullable<RemixTestCommandConfig['coverage']> = {}
|
|
405
|
+
let branches = optionalNumber(object.branches, source, [...objectPath, 'branches'])
|
|
406
|
+
let dir = optionalString(object.dir, source, [...objectPath, 'dir'])
|
|
407
|
+
let enabled = optionalBoolean(object.enabled, source, [...objectPath, 'enabled'])
|
|
408
|
+
let exclude = optionalStringArray(object.exclude, source, [...objectPath, 'exclude'])
|
|
409
|
+
let functions = optionalNumber(object.functions, source, [...objectPath, 'functions'])
|
|
410
|
+
let include = optionalStringArray(object.include, source, [...objectPath, 'include'])
|
|
411
|
+
let lines = optionalNumber(object.lines, source, [...objectPath, 'lines'])
|
|
412
|
+
let statements = optionalNumber(object.statements, source, [...objectPath, 'statements'])
|
|
413
|
+
|
|
414
|
+
if (branches !== undefined) config.branches = branches
|
|
415
|
+
if (dir !== undefined) config.dir = path.resolve(configDir, dir)
|
|
416
|
+
if (enabled !== undefined) config.enabled = enabled
|
|
417
|
+
if (exclude !== undefined) config.exclude = resolveGlobs(exclude, configDir, cwd)
|
|
418
|
+
if (functions !== undefined) config.functions = functions
|
|
419
|
+
if (include !== undefined) config.include = resolveGlobs(include, configDir, cwd)
|
|
420
|
+
if (lines !== undefined) config.lines = lines
|
|
421
|
+
if (statements !== undefined) config.statements = statements
|
|
422
|
+
|
|
423
|
+
return config
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function parsePlaywrightConfig(
|
|
427
|
+
value: unknown,
|
|
428
|
+
source: ConfigSource,
|
|
429
|
+
configDir: string,
|
|
430
|
+
): NonNullable<RemixTestCommandConfig['playwright']> {
|
|
431
|
+
let objectPath = ['test', 'playwright']
|
|
432
|
+
let object = requireObject(value, source, objectPath)
|
|
433
|
+
requireKnownProperties(object, ['configFile', 'echo', 'open', 'projects'], source, objectPath)
|
|
434
|
+
|
|
435
|
+
let config: NonNullable<RemixTestCommandConfig['playwright']> = {}
|
|
436
|
+
let configFile = optionalString(object.configFile, source, [...objectPath, 'configFile'])
|
|
437
|
+
let echo = optionalBoolean(object.echo, source, [...objectPath, 'echo'])
|
|
438
|
+
let open = optionalBoolean(object.open, source, [...objectPath, 'open'])
|
|
439
|
+
let projects = optionalStringArray(object.projects, source, [...objectPath, 'projects'])
|
|
440
|
+
|
|
441
|
+
if (configFile !== undefined) config.configFile = path.resolve(configDir, configFile)
|
|
442
|
+
if (echo !== undefined) config.echo = echo
|
|
443
|
+
if (open !== undefined) config.open = open
|
|
444
|
+
if (projects !== undefined) config.projects = projects
|
|
445
|
+
|
|
446
|
+
return config
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function requireObject(
|
|
450
|
+
value: unknown,
|
|
451
|
+
source: ConfigSource,
|
|
452
|
+
propertyPath: JsonPath,
|
|
453
|
+
): Record<string, unknown> {
|
|
454
|
+
if (!isRecord(value)) {
|
|
455
|
+
throwConfigError(source, propertyPath, 'Expected an object')
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
return value
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
function requireKnownProperties(
|
|
462
|
+
object: Record<string, unknown>,
|
|
463
|
+
knownProperties: readonly string[],
|
|
464
|
+
source: ConfigSource,
|
|
465
|
+
propertyPath: JsonPath,
|
|
466
|
+
): void {
|
|
467
|
+
for (let property of Object.keys(object)) {
|
|
468
|
+
if (!knownProperties.includes(property)) {
|
|
469
|
+
throwConfigError(source, [...propertyPath, property], `Unknown property "${property}"`)
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function optionalBoolean(
|
|
475
|
+
value: unknown,
|
|
476
|
+
source: ConfigSource,
|
|
477
|
+
propertyPath: JsonPath,
|
|
478
|
+
): boolean | undefined {
|
|
479
|
+
if (value === undefined) return undefined
|
|
480
|
+
if (typeof value !== 'boolean') throwConfigError(source, propertyPath, 'Expected a boolean')
|
|
481
|
+
return value
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function optionalNumber(
|
|
485
|
+
value: unknown,
|
|
486
|
+
source: ConfigSource,
|
|
487
|
+
propertyPath: JsonPath,
|
|
488
|
+
): number | undefined {
|
|
489
|
+
if (value === undefined) return undefined
|
|
490
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
491
|
+
throwConfigError(source, propertyPath, 'Expected a number')
|
|
492
|
+
}
|
|
493
|
+
return value
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function optionalString(
|
|
497
|
+
value: unknown,
|
|
498
|
+
source: ConfigSource,
|
|
499
|
+
propertyPath: JsonPath,
|
|
500
|
+
): string | undefined {
|
|
501
|
+
if (value === undefined) return undefined
|
|
502
|
+
return requireString(value, source, propertyPath)
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function requireString(value: unknown, source: ConfigSource, propertyPath: JsonPath): string {
|
|
506
|
+
if (typeof value !== 'string') throwConfigError(source, propertyPath, 'Expected a string')
|
|
507
|
+
return value
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
function optionalStringArray(
|
|
511
|
+
value: unknown,
|
|
512
|
+
source: ConfigSource,
|
|
513
|
+
propertyPath: JsonPath,
|
|
514
|
+
): string[] | undefined {
|
|
515
|
+
if (value === undefined) return undefined
|
|
516
|
+
if (!Array.isArray(value)) throwConfigError(source, propertyPath, 'Expected an array of strings')
|
|
517
|
+
|
|
518
|
+
return value.map((item, index) => requireString(item, source, [...propertyPath, index]))
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function optionalEnum<const value extends string>(
|
|
522
|
+
input: unknown,
|
|
523
|
+
values: readonly value[],
|
|
524
|
+
source: ConfigSource,
|
|
525
|
+
propertyPath: JsonPath,
|
|
526
|
+
): value | undefined {
|
|
527
|
+
if (input === undefined) return undefined
|
|
528
|
+
return requireEnum(input, values, source, propertyPath)
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function optionalEnumArray<const value extends string>(
|
|
532
|
+
input: unknown,
|
|
533
|
+
values: readonly value[],
|
|
534
|
+
source: ConfigSource,
|
|
535
|
+
propertyPath: JsonPath,
|
|
536
|
+
): value[] | undefined {
|
|
537
|
+
if (input === undefined) return undefined
|
|
538
|
+
if (!Array.isArray(input)) throwConfigError(source, propertyPath, 'Expected an array')
|
|
539
|
+
|
|
540
|
+
return input.map((item, index) => requireEnum(item, values, source, [...propertyPath, index]))
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function requireEnum<const value extends string>(
|
|
544
|
+
input: unknown,
|
|
545
|
+
values: readonly value[],
|
|
546
|
+
source: ConfigSource,
|
|
547
|
+
propertyPath: JsonPath,
|
|
548
|
+
): value {
|
|
549
|
+
let string = requireString(input, source, propertyPath)
|
|
550
|
+
let matched = values.find((value) => value === string)
|
|
551
|
+
if (matched === undefined) {
|
|
552
|
+
throwConfigError(source, propertyPath, `Expected one of: ${values.join(', ')}`)
|
|
553
|
+
}
|
|
554
|
+
return matched
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function resolveGlobs(values: string[], configDir: string, cwd: string): string[] {
|
|
558
|
+
return values.map((value) => toPosix(path.relative(cwd, path.resolve(configDir, value))))
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function toPosix(value: string): string {
|
|
562
|
+
return path.sep === '/' ? value : value.replaceAll(path.sep, '/')
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function validateOnlyPattern(pattern: string, source: ConfigSource, propertyPath: JsonPath): void {
|
|
566
|
+
let regex = parseRegexLiteral(pattern) ?? { flags: 'i', source: pattern }
|
|
567
|
+
|
|
568
|
+
try {
|
|
569
|
+
new RegExp(regex.source, regex.flags)
|
|
570
|
+
} catch (error) {
|
|
571
|
+
let reason = error instanceof Error ? error.message : String(error)
|
|
572
|
+
throwConfigError(
|
|
573
|
+
source,
|
|
574
|
+
propertyPath,
|
|
575
|
+
`Expected a valid JavaScript regular expression: ${reason}`,
|
|
576
|
+
)
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
function parseRegexLiteral(pattern: string): { flags: string; source: string } | undefined {
|
|
581
|
+
if (!pattern.startsWith('/') || pattern.length < 2) return undefined
|
|
582
|
+
|
|
583
|
+
// The closing delimiter is the last unescaped slash; escape state must be
|
|
584
|
+
// tracked left-to-right since it depends on the preceding backslashes.
|
|
585
|
+
let closingIndex = -1
|
|
586
|
+
let escaped = false
|
|
587
|
+
for (let index = 1; index < pattern.length; index++) {
|
|
588
|
+
if (escaped) {
|
|
589
|
+
escaped = false
|
|
590
|
+
} else if (pattern[index] === '\\') {
|
|
591
|
+
escaped = true
|
|
592
|
+
} else if (pattern[index] === '/') {
|
|
593
|
+
closingIndex = index
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (closingIndex === -1) return undefined
|
|
598
|
+
|
|
599
|
+
return {
|
|
600
|
+
flags: pattern.slice(closingIndex + 1),
|
|
601
|
+
source: pattern.slice(1, closingIndex),
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function throwConfigError(
|
|
606
|
+
source: ConfigSource,
|
|
607
|
+
propertyPath: JsonPath,
|
|
608
|
+
details: string,
|
|
609
|
+
offset?: number,
|
|
610
|
+
): never {
|
|
611
|
+
let node = source.root == null ? undefined : findNodeAtLocation(source.root, propertyPath)
|
|
612
|
+
let location = getLineAndColumn(source.text, offset ?? node?.offset ?? 0)
|
|
613
|
+
let label = propertyPath.length === 0 ? '' : ` at ${propertyPath.join('.')}`
|
|
614
|
+
throw invalidRemixConfig(source.filePath, `${details}${label}`, location.line, location.column)
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function getLineAndColumn(text: string, offset: number): { column: number; line: number } {
|
|
618
|
+
let line = 1
|
|
619
|
+
let column = 1
|
|
620
|
+
|
|
621
|
+
for (let index = 0; index < offset; index++) {
|
|
622
|
+
if (text[index] === '\n') {
|
|
623
|
+
line++
|
|
624
|
+
column = 1
|
|
625
|
+
} else {
|
|
626
|
+
column++
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
return { column, line }
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function isNodeError(error: unknown): error is NodeJS.ErrnoException {
|
|
634
|
+
return error instanceof Error && 'code' in error
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
638
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
639
|
+
}
|