@untestutils/cli 0.5.4 → 0.6.1
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/package.json +4 -4
- package/src/commands/doctor.ts +202 -68
- package/src/commands/init.ts +29 -6
- package/src/utils/templates.ts +12 -3
- package/templates/nuxt/fixtures/nuxt/app.vue +5 -0
- package/templates/nuxt/fixtures/nuxt/nuxt.config.ts +4 -0
- package/templates/nuxt/fixtures/nuxt/package.json +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@untestutils/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.1",
|
|
4
4
|
"description": "CLI for untestutils: init, convert, ai, doctor, and monorepo commands",
|
|
5
5
|
"homepage": "https://s00d.github.io/untestutils/",
|
|
6
6
|
"license": "MIT",
|
|
@@ -40,9 +40,9 @@
|
|
|
40
40
|
"pathe": "^2.0.3",
|
|
41
41
|
"tinyglobby": "^0.2.17",
|
|
42
42
|
"tsx": "^4.23.13",
|
|
43
|
-
"@untestutils/
|
|
44
|
-
"@untestutils/
|
|
45
|
-
"@untestutils/core": "0.
|
|
43
|
+
"@untestutils/perf": "0.6.1",
|
|
44
|
+
"@untestutils/ai": "0.6.1",
|
|
45
|
+
"@untestutils/core": "0.6.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
48
|
"@types/node": "^26.6.1",
|
package/src/commands/doctor.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { defineCommand, type CommandDef } from 'citty';
|
|
2
2
|
import { consola } from 'consola';
|
|
3
|
-
import { existsSync } from 'node:fs';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
4
|
import { resolve, join } from 'pathe';
|
|
5
5
|
import { createRequire } from 'node:module';
|
|
6
|
+
import { pathToFileURL } from 'node:url';
|
|
6
7
|
import { findMonorepoRoot } from '../utils/workspace';
|
|
7
8
|
|
|
8
9
|
type Check = { ok: boolean; label: string; hint?: string; optional?: boolean };
|
|
@@ -17,89 +18,222 @@ function canResolve(id: string, cwd: string): boolean {
|
|
|
17
18
|
}
|
|
18
19
|
}
|
|
19
20
|
|
|
21
|
+
function readPkgVersion(id: string, cwd: string): string | undefined {
|
|
22
|
+
try {
|
|
23
|
+
const req = createRequire(join(cwd, 'package.json'));
|
|
24
|
+
const pkgJson = req.resolve(`${id}/package.json`);
|
|
25
|
+
const ver = (JSON.parse(readFileSync(pkgJson, 'utf8')) as { version?: string }).version;
|
|
26
|
+
return ver;
|
|
27
|
+
} catch {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function findRecipesFile(cwd: string): string | undefined {
|
|
33
|
+
for (const rel of ['recipes.ts', 'tests/recipes.ts', 'e2e/recipes.ts', 'recipes.mts']) {
|
|
34
|
+
const p = join(cwd, rel);
|
|
35
|
+
if (existsSync(p)) return p;
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function hasPlaywrightConfig(cwd: string): boolean {
|
|
41
|
+
return ['playwright.config.ts', 'playwright.config.mts', 'playwright.config.js'].some((f) =>
|
|
42
|
+
existsSync(join(cwd, f)),
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function fileMentions(cwd: string, needle: string): boolean {
|
|
47
|
+
const candidates = [
|
|
48
|
+
'recipes.ts',
|
|
49
|
+
'vitest.config.ts',
|
|
50
|
+
'vitest.config.mts',
|
|
51
|
+
'playwright.config.ts',
|
|
52
|
+
'package.json',
|
|
53
|
+
];
|
|
54
|
+
for (const rel of candidates) {
|
|
55
|
+
const p = join(cwd, rel);
|
|
56
|
+
if (!existsSync(p)) continue;
|
|
57
|
+
try {
|
|
58
|
+
if (readFileSync(p, 'utf8').includes(needle)) return true;
|
|
59
|
+
} catch {
|
|
60
|
+
/* skip */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function runDoctorChecks(cwd: string): Promise<number> {
|
|
67
|
+
const checks: Check[] = [];
|
|
68
|
+
const mono = findMonorepoRoot(cwd);
|
|
69
|
+
const inMono = Boolean(mono && (cwd === mono || cwd.startsWith(`${mono}/`)));
|
|
70
|
+
|
|
71
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
72
|
+
checks.push({
|
|
73
|
+
ok: major >= 20,
|
|
74
|
+
label: `Node.js ${process.version}`,
|
|
75
|
+
hint: 'Requires Node >= 20',
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
for (const id of ['untestutils', 'vitest'] as const) {
|
|
79
|
+
checks.push({
|
|
80
|
+
ok: canResolve(id, cwd) || inMono,
|
|
81
|
+
label: `package ${id}`,
|
|
82
|
+
hint: `pnpm add -D ${id}`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const vitestVer = readPkgVersion('vitest', cwd);
|
|
87
|
+
if (vitestVer) {
|
|
88
|
+
const vitestMajor = Number(vitestVer.split('.')[0]);
|
|
89
|
+
checks.push({
|
|
90
|
+
ok: vitestMajor === 4 || vitestMajor === 5,
|
|
91
|
+
label: `vitest major ${vitestMajor} (${vitestVer})`,
|
|
92
|
+
hint: 'Supported majors: 4 or 5 (peer ^4 || ^5)',
|
|
93
|
+
});
|
|
94
|
+
} else if (!inMono) {
|
|
95
|
+
checks.push({
|
|
96
|
+
ok: false,
|
|
97
|
+
label: 'vitest version',
|
|
98
|
+
hint: 'pnpm add -D vitest@^4 || vitest@^5',
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const needsPw = hasPlaywrightConfig(cwd);
|
|
103
|
+
const hasPw = canResolve('@playwright/test', cwd) || inMono;
|
|
104
|
+
checks.push({
|
|
105
|
+
ok: !needsPw || hasPw,
|
|
106
|
+
optional: !needsPw,
|
|
107
|
+
label: needsPw ? '@playwright/test (playwright.config present)' : 'optional: @playwright/test',
|
|
108
|
+
hint: 'pnpm add -D @playwright/test playwright-core',
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
for (const id of ['playwright-core', 'nuxt', '@untestutils/nuxt'] as const) {
|
|
112
|
+
const ok = canResolve(id, cwd) || (inMono && id !== 'nuxt' && id !== '@untestutils/nuxt');
|
|
113
|
+
checks.push({
|
|
114
|
+
ok,
|
|
115
|
+
optional: true,
|
|
116
|
+
label: `optional: ${id}`,
|
|
117
|
+
hint: ok ? undefined : `needed for the matching preset (pnpm add -D ${id})`,
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (fileMentions(cwd, 'untestutils/perf') || fileMentions(cwd, '@untestutils/perf')) {
|
|
122
|
+
const ok = canResolve('@untestutils/perf', cwd) || inMono;
|
|
123
|
+
checks.push({
|
|
124
|
+
ok,
|
|
125
|
+
optional: true,
|
|
126
|
+
label: 'optional: @untestutils/perf (imported)',
|
|
127
|
+
hint: ok ? undefined : 'pnpm add -D @untestutils/perf',
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (fileMentions(cwd, 'untestutils/ai') || fileMentions(cwd, '@untestutils/ai')) {
|
|
132
|
+
const ok = canResolve('@untestutils/ai', cwd) || inMono;
|
|
133
|
+
checks.push({
|
|
134
|
+
ok,
|
|
135
|
+
optional: true,
|
|
136
|
+
label: 'optional: @untestutils/ai (imported)',
|
|
137
|
+
hint: ok ? undefined : 'pnpm add -D @untestutils/ai',
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const configCandidates = [
|
|
142
|
+
'vitest.config.ts',
|
|
143
|
+
'vitest.config.mts',
|
|
144
|
+
'vitest.config.js',
|
|
145
|
+
'playwright.config.ts',
|
|
146
|
+
'playwright.config.mts',
|
|
147
|
+
'vitest.unit.config.ts',
|
|
148
|
+
];
|
|
149
|
+
const hasConfig = inMono || configCandidates.some((f) => existsSync(join(cwd, f)));
|
|
150
|
+
checks.push({
|
|
151
|
+
ok: hasConfig,
|
|
152
|
+
label: 'vitest/playwright config',
|
|
153
|
+
hint: 'untestutils init --preset vitest',
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
const recipesFile = findRecipesFile(cwd);
|
|
157
|
+
checks.push({
|
|
158
|
+
ok: inMono || Boolean(recipesFile),
|
|
159
|
+
label: 'recipes.ts',
|
|
160
|
+
hint: 'create defineRecipes(...) or run init',
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
let failed = 0;
|
|
164
|
+
for (const c of checks) {
|
|
165
|
+
if (c.ok) {
|
|
166
|
+
consola.success(c.label);
|
|
167
|
+
} else if (c.optional) {
|
|
168
|
+
consola.info(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
169
|
+
} else {
|
|
170
|
+
failed++;
|
|
171
|
+
consola.error(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
return failed;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function listRecipes(cwd: string): Promise<void> {
|
|
178
|
+
const recipesFile = findRecipesFile(cwd);
|
|
179
|
+
if (!recipesFile) {
|
|
180
|
+
consola.error('No recipes.ts found (looked for recipes.ts, tests/recipes.ts, e2e/recipes.ts)');
|
|
181
|
+
process.exitCode = 1;
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
consola.start(`Loading ${recipesFile}`);
|
|
185
|
+
try {
|
|
186
|
+
const mod = (await import(pathToFileURL(recipesFile).href)) as {
|
|
187
|
+
recipes?: Record<string, { id?: string }>;
|
|
188
|
+
};
|
|
189
|
+
const map = mod.recipes;
|
|
190
|
+
if (!map || typeof map !== 'object') {
|
|
191
|
+
consola.error('recipes module must export `recipes` from defineRecipes(...)');
|
|
192
|
+
process.exitCode = 1;
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
const ids = Object.entries(map).map(([key, recipe]) => recipe?.id ?? key);
|
|
196
|
+
if (ids.length === 0) {
|
|
197
|
+
consola.warn('No recipes registered');
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
consola.success(`Recipes (${ids.length}):`);
|
|
201
|
+
for (const id of ids.sort()) {
|
|
202
|
+
consola.log(` - ${id}`);
|
|
203
|
+
}
|
|
204
|
+
} catch (e) {
|
|
205
|
+
consola.error(`Failed to load recipes: ${e}`);
|
|
206
|
+
consola.info('Ensure dependencies are installed and the recipes module can be imported.');
|
|
207
|
+
process.exitCode = 1;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
20
211
|
const doctorArgs = {
|
|
21
212
|
cwd: {
|
|
22
213
|
type: 'string',
|
|
23
214
|
description: 'Project directory',
|
|
24
215
|
default: '.',
|
|
25
216
|
},
|
|
217
|
+
recipes: {
|
|
218
|
+
type: 'boolean',
|
|
219
|
+
description: 'List recipe ids from defineRecipes export',
|
|
220
|
+
default: false,
|
|
221
|
+
},
|
|
26
222
|
} as const;
|
|
27
223
|
|
|
28
224
|
export const doctorCommand: CommandDef<typeof doctorArgs> = defineCommand({
|
|
29
225
|
meta: {
|
|
30
226
|
name: 'doctor',
|
|
31
|
-
description: 'Check untestutils environment and configs',
|
|
227
|
+
description: 'Check untestutils environment and configs (use --recipes to list ids)',
|
|
32
228
|
},
|
|
33
229
|
args: doctorArgs,
|
|
34
230
|
async run({ args }) {
|
|
35
231
|
const cwd = resolve(String(args.cwd));
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
const major = Number(process.versions.node.split('.')[0]);
|
|
41
|
-
checks.push({
|
|
42
|
-
ok: major >= 20,
|
|
43
|
-
label: `Node.js ${process.version}`,
|
|
44
|
-
hint: 'Requires Node >= 20',
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
for (const id of ['untestutils', 'vitest'] as const) {
|
|
48
|
-
checks.push({
|
|
49
|
-
ok: canResolve(id, cwd) || inMono,
|
|
50
|
-
label: `package ${id}`,
|
|
51
|
-
hint: `pnpm add -D ${id}`,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
for (const id of ['@playwright/test', 'playwright-core', 'nuxt'] as const) {
|
|
56
|
-
const ok = canResolve(id, cwd) || (inMono && id !== 'nuxt');
|
|
57
|
-
checks.push({
|
|
58
|
-
ok,
|
|
59
|
-
optional: true,
|
|
60
|
-
label: `optional: ${id}`,
|
|
61
|
-
hint: ok ? undefined : `needed for the matching preset (pnpm add -D ${id})`,
|
|
62
|
-
});
|
|
232
|
+
if (args.recipes) {
|
|
233
|
+
await listRecipes(cwd);
|
|
234
|
+
return;
|
|
63
235
|
}
|
|
64
|
-
|
|
65
|
-
const configCandidates = [
|
|
66
|
-
'vitest.config.ts',
|
|
67
|
-
'vitest.config.mts',
|
|
68
|
-
'vitest.config.js',
|
|
69
|
-
'playwright.config.ts',
|
|
70
|
-
'playwright.config.mts',
|
|
71
|
-
'vitest.unit.config.ts',
|
|
72
|
-
];
|
|
73
|
-
const hasConfig = inMono || configCandidates.some((f) => existsSync(join(cwd, f)));
|
|
74
|
-
checks.push({
|
|
75
|
-
ok: hasConfig,
|
|
76
|
-
label: 'vitest/playwright config',
|
|
77
|
-
hint: 'untestutils init --preset vitest',
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
const hasRecipes =
|
|
81
|
-
inMono ||
|
|
82
|
-
existsSync(join(cwd, 'recipes.ts')) ||
|
|
83
|
-
existsSync(join(cwd, 'tests/recipes.ts')) ||
|
|
84
|
-
existsSync(join(cwd, 'e2e/recipes.ts'));
|
|
85
|
-
checks.push({
|
|
86
|
-
ok: hasRecipes,
|
|
87
|
-
label: 'recipes.ts',
|
|
88
|
-
hint: 'create defineRecipes(...) or run init',
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
let failed = 0;
|
|
92
|
-
for (const c of checks) {
|
|
93
|
-
if (c.ok) {
|
|
94
|
-
consola.success(c.label);
|
|
95
|
-
} else if (c.optional) {
|
|
96
|
-
consola.info(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
97
|
-
} else {
|
|
98
|
-
failed++;
|
|
99
|
-
consola.error(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
236
|
+
const failed = await runDoctorChecks(cwd);
|
|
103
237
|
if (failed > 0) {
|
|
104
238
|
consola.warn(`Issues: ${failed}`);
|
|
105
239
|
process.exitCode = 1;
|
package/src/commands/init.ts
CHANGED
|
@@ -2,9 +2,15 @@ import { defineCommand, type CommandDef } from 'citty';
|
|
|
2
2
|
import { consola } from 'consola';
|
|
3
3
|
import { addDependency } from 'nypm';
|
|
4
4
|
import { resolve } from 'pathe';
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
applyPreset,
|
|
7
|
+
peersForPreset,
|
|
8
|
+
type InitPreset,
|
|
9
|
+
type PackageManagerName,
|
|
10
|
+
} from '../utils/templates';
|
|
6
11
|
|
|
7
12
|
const PRESETS: InitPreset[] = ['vitest', 'playwright', 'nuxt', 'full'];
|
|
13
|
+
const PMS: PackageManagerName[] = ['npm', 'pnpm', 'yarn', 'bun'];
|
|
8
14
|
|
|
9
15
|
const initArgs = {
|
|
10
16
|
preset: {
|
|
@@ -25,9 +31,13 @@ const initArgs = {
|
|
|
25
31
|
},
|
|
26
32
|
install: {
|
|
27
33
|
type: 'boolean',
|
|
28
|
-
description: 'Install peer dependencies via nypm',
|
|
34
|
+
description: 'Install peer dependencies via nypm (use --no-install to skip)',
|
|
29
35
|
default: true,
|
|
30
36
|
},
|
|
37
|
+
pm: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Package manager: pnpm | npm | yarn | bun (default: auto-detect, else pnpm)',
|
|
40
|
+
},
|
|
31
41
|
} as const;
|
|
32
42
|
|
|
33
43
|
export const initCommand: CommandDef<typeof initArgs> = defineCommand({
|
|
@@ -57,20 +67,32 @@ export const initCommand: CommandDef<typeof initArgs> = defineCommand({
|
|
|
57
67
|
consola.start(`Installing dependencies: ${deps.join(', ')}`);
|
|
58
68
|
try {
|
|
59
69
|
// Fresh projects often have no lockfile — nypm cannot auto-detect. Prefer
|
|
60
|
-
// detected PM, else pnpm (matches engines/docs), always as -D.
|
|
70
|
+
// --pm, else detected PM, else pnpm (matches engines/docs), always as -D.
|
|
71
|
+
const pmArg = args.pm ? String(args.pm) : undefined;
|
|
72
|
+
if (pmArg && !PMS.includes(pmArg as PackageManagerName)) {
|
|
73
|
+
consola.error(`Unknown --pm "${pmArg}". Available: ${PMS.join(', ')}`);
|
|
74
|
+
process.exitCode = 1;
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
61
77
|
const { detectPackageManager } = await import('nypm');
|
|
62
|
-
const detected =
|
|
78
|
+
const detected = pmArg
|
|
79
|
+
? undefined
|
|
80
|
+
: await detectPackageManager(cwd, { includeParentDirs: false });
|
|
81
|
+
const packageManager =
|
|
82
|
+
(pmArg as PackageManagerName | undefined) ?? detected?.name ?? 'pnpm';
|
|
63
83
|
await addDependency(deps, {
|
|
64
84
|
cwd,
|
|
65
85
|
silent: false,
|
|
66
|
-
packageManager
|
|
86
|
+
packageManager,
|
|
67
87
|
dev: true,
|
|
68
88
|
});
|
|
69
|
-
consola.success(
|
|
89
|
+
consola.success(`Dependencies installed (${packageManager})`);
|
|
70
90
|
} catch (e) {
|
|
71
91
|
consola.warn(`Could not install automatically: ${e}`);
|
|
72
92
|
consola.info(`Install manually: ${deps.join(' ')}`);
|
|
73
93
|
}
|
|
94
|
+
} else {
|
|
95
|
+
consola.info('Skipped install (--no-install). Install peers manually when ready.');
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
consola.box(
|
|
@@ -79,6 +101,7 @@ export const initCommand: CommandDef<typeof initArgs> = defineCommand({
|
|
|
79
101
|
' 1. Review recipes.ts and fixture app paths',
|
|
80
102
|
' 2. pnpm test / npx vitest run',
|
|
81
103
|
' 3. untestutils doctor — environment check',
|
|
104
|
+
' 4. untestutils doctor --recipes — list recipe ids',
|
|
82
105
|
].join('\n'),
|
|
83
106
|
);
|
|
84
107
|
},
|
package/src/utils/templates.ts
CHANGED
|
@@ -5,7 +5,9 @@ import { writeIfMissing } from './fs';
|
|
|
5
5
|
|
|
6
6
|
export type InitPreset = 'vitest' | 'playwright' | 'nuxt' | 'full';
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn' | 'bun';
|
|
9
|
+
|
|
10
|
+
/** Relative template paths under templates/ */
|
|
9
11
|
const PRESET_FILES: Record<InitPreset, string[]> = {
|
|
10
12
|
vitest: [
|
|
11
13
|
'vitest/vitest.config.ts',
|
|
@@ -19,7 +21,14 @@ const PRESET_FILES: Record<InitPreset, string[]> = {
|
|
|
19
21
|
'playwright/tests/e2e/smoke.spec.ts',
|
|
20
22
|
'vitest/fixtures/basic/index.html',
|
|
21
23
|
],
|
|
22
|
-
nuxt: [
|
|
24
|
+
nuxt: [
|
|
25
|
+
'nuxt/vitest.config.ts',
|
|
26
|
+
'nuxt/recipes.ts',
|
|
27
|
+
'nuxt/tests/e2e/smoke.test.ts',
|
|
28
|
+
'nuxt/fixtures/nuxt/package.json',
|
|
29
|
+
'nuxt/fixtures/nuxt/nuxt.config.ts',
|
|
30
|
+
'nuxt/fixtures/nuxt/app.vue',
|
|
31
|
+
],
|
|
23
32
|
full: [
|
|
24
33
|
'vitest/vitest.config.ts',
|
|
25
34
|
'playwright/playwright.config.ts',
|
|
@@ -64,7 +73,7 @@ export function peersForPreset(preset: InitPreset): string[] {
|
|
|
64
73
|
base.push('@playwright/test', 'playwright-core');
|
|
65
74
|
}
|
|
66
75
|
if (preset === 'nuxt' || preset === 'full') {
|
|
67
|
-
base.push('nuxt');
|
|
76
|
+
base.push('nuxt', '@untestutils/nuxt', 'vitest-environment-untestutils');
|
|
68
77
|
}
|
|
69
78
|
return [...new Set(base)];
|
|
70
79
|
}
|