@untestutils/cli 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.
- package/LICENSE +21 -0
- package/bin.mjs +12 -0
- package/package.json +60 -0
- package/src/commands/ai.ts +90 -0
- package/src/commands/convert.ts +58 -0
- package/src/commands/cover.ts +74 -0
- package/src/commands/doctor.ts +107 -0
- package/src/commands/fix.ts +113 -0
- package/src/commands/index.ts +17 -0
- package/src/commands/init.ts +74 -0
- package/src/commands/perf.ts +77 -0
- package/src/main.ts +46 -0
- package/src/run.ts +7 -0
- package/src/utils/fs.ts +37 -0
- package/src/utils/templates.ts +64 -0
- package/src/utils/workspace.ts +80 -0
- package/templates/nuxt/recipes.ts +14 -0
- package/templates/nuxt/tests/e2e/nuxt-smoke.test.ts +9 -0
- package/templates/nuxt/tests/e2e/smoke.test.ts +9 -0
- package/templates/nuxt/tests/unit/smoke.test.ts +8 -0
- package/templates/nuxt/vitest.config.ts +21 -0
- package/templates/nuxt/vitest.unit.config.ts +16 -0
- package/templates/playwright/playwright.config.ts +17 -0
- package/templates/playwright/recipes.ts +12 -0
- package/templates/playwright/tests/e2e/smoke.spec.ts +8 -0
- package/templates/vitest/recipes.ts +13 -0
- package/templates/vitest/tests/e2e/smoke.test.ts +8 -0
- package/templates/vitest/vitest.config.ts +16 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 s00d
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/bin.mjs
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const result = spawnSync(
|
|
8
|
+
process.execPath,
|
|
9
|
+
['--import', 'tsx', join(here, 'src/run.ts'), ...process.argv.slice(2)],
|
|
10
|
+
{ stdio: 'inherit' },
|
|
11
|
+
);
|
|
12
|
+
process.exit(result.status ?? 1);
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@untestutils/cli",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "CLI for untestutils: init, convert, ai, doctor, and monorepo commands",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://s00d.github.io/untestutils/",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/s00d/untestutils.git",
|
|
10
|
+
"directory": "packages/cli"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"untestutils": "./bin.mjs"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"src",
|
|
17
|
+
"bin.mjs",
|
|
18
|
+
"templates"
|
|
19
|
+
],
|
|
20
|
+
"type": "module",
|
|
21
|
+
"sideEffects": false,
|
|
22
|
+
"main": "./src/main.ts",
|
|
23
|
+
"types": "./src/main.ts",
|
|
24
|
+
"exports": {
|
|
25
|
+
".": {
|
|
26
|
+
"types": "./src/main.ts",
|
|
27
|
+
"import": "./src/main.ts",
|
|
28
|
+
"default": "./src/main.ts"
|
|
29
|
+
},
|
|
30
|
+
"./bin.mjs": "./bin.mjs",
|
|
31
|
+
"./package.json": "./package.json"
|
|
32
|
+
},
|
|
33
|
+
"publishConfig": {
|
|
34
|
+
"access": "public"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"citty": "^0.2.2",
|
|
38
|
+
"consola": "^3.4.2",
|
|
39
|
+
"nypm": "^0.6.10",
|
|
40
|
+
"pathe": "^2.0.3",
|
|
41
|
+
"tinyglobby": "^0.2.17",
|
|
42
|
+
"tsx": "^4.23.13",
|
|
43
|
+
"@untestutils/ai": "0.5.0",
|
|
44
|
+
"@untestutils/core": "0.5.0",
|
|
45
|
+
"@untestutils/perf": "0.5.0"
|
|
46
|
+
},
|
|
47
|
+
"devDependencies": {
|
|
48
|
+
"@types/node": "^26.6.1",
|
|
49
|
+
"publint": "^0.3.24",
|
|
50
|
+
"typescript": "^6.0.3"
|
|
51
|
+
},
|
|
52
|
+
"engines": {
|
|
53
|
+
"node": ">=20"
|
|
54
|
+
},
|
|
55
|
+
"scripts": {
|
|
56
|
+
"cli": "tsx ./src/run.ts",
|
|
57
|
+
"build": "node -e \"console.log('[@untestutils/cli] src+tsx — no emit')\"",
|
|
58
|
+
"publint": "publint --strict"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { resolve } from 'pathe';
|
|
4
|
+
import { aiTest, aiTestFromFile } from '@untestutils/ai';
|
|
5
|
+
import { writeText } from '../utils/fs';
|
|
6
|
+
|
|
7
|
+
export const aiCommand = defineCommand({
|
|
8
|
+
meta: {
|
|
9
|
+
name: 'ai',
|
|
10
|
+
alias: ['generate'],
|
|
11
|
+
description: 'Generate an e2e test from a prompt or markdown file',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
prompt: {
|
|
15
|
+
type: 'string',
|
|
16
|
+
description: 'Scenario text',
|
|
17
|
+
alias: 'p',
|
|
18
|
+
},
|
|
19
|
+
file: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
description: 'Markdown with frontmatter (id/root/recipes/focus)',
|
|
22
|
+
alias: 'f',
|
|
23
|
+
},
|
|
24
|
+
id: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Generation id',
|
|
27
|
+
default: 'cli',
|
|
28
|
+
},
|
|
29
|
+
root: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Project root',
|
|
32
|
+
default: '.',
|
|
33
|
+
},
|
|
34
|
+
recipes: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Comma-separated recipe ids',
|
|
37
|
+
default: '',
|
|
38
|
+
},
|
|
39
|
+
focus: {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'Comma-separated focus file hints',
|
|
42
|
+
default: '',
|
|
43
|
+
},
|
|
44
|
+
out: {
|
|
45
|
+
type: 'string',
|
|
46
|
+
description: 'Copy generated file to this path',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
async run({ args }) {
|
|
50
|
+
const root = resolve(String(args.root));
|
|
51
|
+
const recipes = String(args.recipes || '')
|
|
52
|
+
.split(',')
|
|
53
|
+
.map((s) => s.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
const focus = String(args.focus || '')
|
|
56
|
+
.split(',')
|
|
57
|
+
.map((s) => s.trim())
|
|
58
|
+
.filter(Boolean);
|
|
59
|
+
|
|
60
|
+
let result;
|
|
61
|
+
if (args.file) {
|
|
62
|
+
consola.start(`AI from file ${args.file}`);
|
|
63
|
+
result = await aiTestFromFile(resolve(String(args.file)), {
|
|
64
|
+
id: String(args.id),
|
|
65
|
+
root,
|
|
66
|
+
recipes,
|
|
67
|
+
focus,
|
|
68
|
+
});
|
|
69
|
+
} else if (args.prompt) {
|
|
70
|
+
consola.start('AI generation from prompt');
|
|
71
|
+
result = await aiTest({
|
|
72
|
+
id: String(args.id),
|
|
73
|
+
prompt: String(args.prompt),
|
|
74
|
+
root,
|
|
75
|
+
recipes,
|
|
76
|
+
focus,
|
|
77
|
+
});
|
|
78
|
+
} else {
|
|
79
|
+
consola.error('Provide --prompt or --file');
|
|
80
|
+
process.exitCode = 1;
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
consola.success(`Generated: ${result.genPath}`);
|
|
85
|
+
if (args.out) {
|
|
86
|
+
await writeText(resolve(String(args.out)), result.code);
|
|
87
|
+
consola.success(`Copied to ${args.out}`);
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { resolve, dirname, basename, join } from 'pathe';
|
|
4
|
+
import { convertTestFile } from '@untestutils/ai';
|
|
5
|
+
import { writeText, pathExists } from '../utils/fs';
|
|
6
|
+
import { copyFile } from 'node:fs/promises';
|
|
7
|
+
|
|
8
|
+
export const convertCommand = defineCommand({
|
|
9
|
+
meta: {
|
|
10
|
+
name: 'convert',
|
|
11
|
+
description: 'Convert existing tests to the untestutils API via AI',
|
|
12
|
+
},
|
|
13
|
+
args: {
|
|
14
|
+
path: {
|
|
15
|
+
type: 'positional',
|
|
16
|
+
description: 'Test file path',
|
|
17
|
+
required: true,
|
|
18
|
+
},
|
|
19
|
+
'in-place': {
|
|
20
|
+
type: 'boolean',
|
|
21
|
+
description: 'Overwrite source (writes .bak backup)',
|
|
22
|
+
default: false,
|
|
23
|
+
alias: 'i',
|
|
24
|
+
},
|
|
25
|
+
root: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Project root for AI tools',
|
|
28
|
+
default: '.',
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
async run({ args }) {
|
|
32
|
+
const file = resolve(String(args.path));
|
|
33
|
+
if (!(await pathExists(file))) {
|
|
34
|
+
consola.error(`File not found: ${file}`);
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
consola.start(`Converting ${file}`);
|
|
40
|
+
const result = await convertTestFile({
|
|
41
|
+
path: file,
|
|
42
|
+
root: resolve(String(args.root)),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
if (args['in-place']) {
|
|
46
|
+
const bak = `${file}.bak`;
|
|
47
|
+
await copyFile(file, bak);
|
|
48
|
+
await writeText(file, result.code);
|
|
49
|
+
consola.success(`Overwrote ${file} (backup: ${bak})`);
|
|
50
|
+
} else {
|
|
51
|
+
const dir = dirname(file);
|
|
52
|
+
const base = basename(file).replace(/(\.test|\.spec)?(\.[cm]?[jt]sx?)$/, '');
|
|
53
|
+
const out = join(dir, `${base}.untestutils.test.ts`);
|
|
54
|
+
await writeText(out, result.code);
|
|
55
|
+
consola.success(`Wrote ${out}`);
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { resolve } from 'pathe';
|
|
4
|
+
import { coverMissingTests } from '@untestutils/ai';
|
|
5
|
+
|
|
6
|
+
export const coverCommand = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'cover',
|
|
9
|
+
alias: ['write-tests'],
|
|
10
|
+
description: 'Write missing e2e tests via AI (explores recipes/pages; optional browser)',
|
|
11
|
+
},
|
|
12
|
+
args: {
|
|
13
|
+
root: {
|
|
14
|
+
type: 'string',
|
|
15
|
+
description: 'Project root',
|
|
16
|
+
default: '.',
|
|
17
|
+
},
|
|
18
|
+
recipes: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Comma-separated recipe ids',
|
|
21
|
+
default: '',
|
|
22
|
+
},
|
|
23
|
+
focus: {
|
|
24
|
+
type: 'string',
|
|
25
|
+
description: 'Comma-separated focus paths',
|
|
26
|
+
default: '',
|
|
27
|
+
},
|
|
28
|
+
'base-url': {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Live app URL for browser_snapshot tools',
|
|
31
|
+
},
|
|
32
|
+
browser: {
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
description: 'Force browser tools',
|
|
35
|
+
default: false,
|
|
36
|
+
},
|
|
37
|
+
'out-dir': {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Directory for new tests',
|
|
40
|
+
default: 'tests/e2e',
|
|
41
|
+
},
|
|
42
|
+
write: {
|
|
43
|
+
type: 'boolean',
|
|
44
|
+
description: 'Write files into the project',
|
|
45
|
+
default: true,
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
async run({ args }) {
|
|
49
|
+
const recipes = String(args.recipes || '')
|
|
50
|
+
.split(',')
|
|
51
|
+
.map((s) => s.trim())
|
|
52
|
+
.filter(Boolean);
|
|
53
|
+
const focus = String(args.focus || '')
|
|
54
|
+
.split(',')
|
|
55
|
+
.map((s) => s.trim())
|
|
56
|
+
.filter(Boolean);
|
|
57
|
+
|
|
58
|
+
consola.start('Covering missing tests with AI');
|
|
59
|
+
const result = await coverMissingTests({
|
|
60
|
+
root: resolve(String(args.root)),
|
|
61
|
+
recipes,
|
|
62
|
+
focus,
|
|
63
|
+
baseURL: args['base-url'] ? String(args['base-url']) : undefined,
|
|
64
|
+
browser: Boolean(args.browser || args['base-url']),
|
|
65
|
+
outDir: String(args['out-dir']),
|
|
66
|
+
write: Boolean(args.write),
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
for (const f of result.files) {
|
|
70
|
+
consola.success(f.path);
|
|
71
|
+
}
|
|
72
|
+
consola.info(`fingerprint ${result.fingerprint.slice(0, 12)}…`);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { existsSync } from 'node:fs';
|
|
4
|
+
import { resolve, join } from 'pathe';
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { findMonorepoRoot } from '../utils/workspace';
|
|
7
|
+
|
|
8
|
+
type Check = { ok: boolean; label: string; hint?: string; optional?: boolean };
|
|
9
|
+
|
|
10
|
+
function canResolve(id: string, cwd: string): boolean {
|
|
11
|
+
try {
|
|
12
|
+
const req = createRequire(join(cwd, 'package.json'));
|
|
13
|
+
req.resolve(id);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const doctorCommand = defineCommand({
|
|
21
|
+
meta: {
|
|
22
|
+
name: 'doctor',
|
|
23
|
+
description: 'Check untestutils environment and configs',
|
|
24
|
+
},
|
|
25
|
+
args: {
|
|
26
|
+
cwd: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Project directory',
|
|
29
|
+
default: '.',
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
async run({ args }) {
|
|
33
|
+
const cwd = resolve(String(args.cwd));
|
|
34
|
+
const checks: Check[] = [];
|
|
35
|
+
const mono = findMonorepoRoot(cwd);
|
|
36
|
+
|
|
37
|
+
const major = Number(process.versions.node.split('.')[0]);
|
|
38
|
+
checks.push({
|
|
39
|
+
ok: major >= 20,
|
|
40
|
+
label: `Node.js ${process.version}`,
|
|
41
|
+
hint: 'Requires Node >= 20',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
for (const id of ['untestutils', 'vitest'] as const) {
|
|
45
|
+
checks.push({
|
|
46
|
+
ok: canResolve(id, cwd) || Boolean(mono),
|
|
47
|
+
label: `package ${id}`,
|
|
48
|
+
hint: `pnpm add -D ${id}`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
for (const id of ['@playwright/test', 'playwright-core', 'nuxt'] as const) {
|
|
53
|
+
const ok = canResolve(id, cwd) || (Boolean(mono) && id !== 'nuxt');
|
|
54
|
+
checks.push({
|
|
55
|
+
ok,
|
|
56
|
+
optional: true,
|
|
57
|
+
label: `optional: ${id}`,
|
|
58
|
+
hint: ok ? undefined : `needed for the matching preset (pnpm add -D ${id})`,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const configCandidates = [
|
|
63
|
+
'vitest.config.ts',
|
|
64
|
+
'vitest.config.mts',
|
|
65
|
+
'vitest.config.js',
|
|
66
|
+
'playwright.config.ts',
|
|
67
|
+
'playwright.config.mts',
|
|
68
|
+
'vitest.unit.config.ts',
|
|
69
|
+
];
|
|
70
|
+
const hasConfig = Boolean(mono) || configCandidates.some((f) => existsSync(join(cwd, f)));
|
|
71
|
+
checks.push({
|
|
72
|
+
ok: hasConfig,
|
|
73
|
+
label: 'vitest/playwright config',
|
|
74
|
+
hint: 'untestutils init --preset vitest',
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const hasRecipes =
|
|
78
|
+
Boolean(mono) ||
|
|
79
|
+
existsSync(join(cwd, 'recipes.ts')) ||
|
|
80
|
+
existsSync(join(cwd, 'tests/recipes.ts')) ||
|
|
81
|
+
existsSync(join(cwd, 'e2e/recipes.ts'));
|
|
82
|
+
checks.push({
|
|
83
|
+
ok: hasRecipes,
|
|
84
|
+
label: 'recipes.ts',
|
|
85
|
+
hint: 'create defineRecipes(...) or run init',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
let failed = 0;
|
|
89
|
+
for (const c of checks) {
|
|
90
|
+
if (c.ok) {
|
|
91
|
+
consola.success(c.label);
|
|
92
|
+
} else if (c.optional) {
|
|
93
|
+
consola.info(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
94
|
+
} else {
|
|
95
|
+
failed++;
|
|
96
|
+
consola.error(`${c.label}${c.hint ? ` — ${c.hint}` : ''}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (failed > 0) {
|
|
101
|
+
consola.warn(`Issues: ${failed}`);
|
|
102
|
+
process.exitCode = 1;
|
|
103
|
+
} else {
|
|
104
|
+
consola.success('Environment looks good');
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { resolve } from 'pathe';
|
|
5
|
+
import { execSync } from 'node:child_process';
|
|
6
|
+
import { fixBrokenTests } from '@untestutils/ai';
|
|
7
|
+
import { pathExists, writeText } from '../utils/fs';
|
|
8
|
+
|
|
9
|
+
export const fixCommand = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'fix',
|
|
12
|
+
description: 'Fix a broken untestutils test via AI (failure log + optional browser tools)',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
path: {
|
|
16
|
+
type: 'positional',
|
|
17
|
+
description: 'Failing test file',
|
|
18
|
+
required: true,
|
|
19
|
+
},
|
|
20
|
+
log: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Path to failure log file (or use --run)',
|
|
23
|
+
alias: 'l',
|
|
24
|
+
},
|
|
25
|
+
run: {
|
|
26
|
+
type: 'string',
|
|
27
|
+
description: 'Shell command that fails (stdout/stderr captured as log)',
|
|
28
|
+
},
|
|
29
|
+
root: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
description: 'Project root',
|
|
32
|
+
default: '.',
|
|
33
|
+
},
|
|
34
|
+
recipes: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Comma-separated recipe ids',
|
|
37
|
+
default: '',
|
|
38
|
+
},
|
|
39
|
+
'base-url': {
|
|
40
|
+
type: 'string',
|
|
41
|
+
description: 'App base URL for Playwright page tools',
|
|
42
|
+
},
|
|
43
|
+
browser: {
|
|
44
|
+
type: 'boolean',
|
|
45
|
+
description: 'Enable browser tools even without --base-url',
|
|
46
|
+
default: false,
|
|
47
|
+
},
|
|
48
|
+
write: {
|
|
49
|
+
type: 'boolean',
|
|
50
|
+
description: 'Overwrite the failing file in place',
|
|
51
|
+
default: true,
|
|
52
|
+
},
|
|
53
|
+
out: {
|
|
54
|
+
type: 'string',
|
|
55
|
+
description: 'Write fixed source to this path instead',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
async run({ args }) {
|
|
59
|
+
const file = resolve(String(args.path));
|
|
60
|
+
if (!(await pathExists(file))) {
|
|
61
|
+
consola.error(`File not found: ${file}`);
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let failureLog = '';
|
|
67
|
+
if (args.log) {
|
|
68
|
+
failureLog = await readFile(resolve(String(args.log)), 'utf8');
|
|
69
|
+
} else if (args.run) {
|
|
70
|
+
consola.start(`Running: ${args.run}`);
|
|
71
|
+
try {
|
|
72
|
+
execSync(String(args.run), {
|
|
73
|
+
encoding: 'utf8',
|
|
74
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
75
|
+
cwd: resolve(String(args.root)),
|
|
76
|
+
});
|
|
77
|
+
consola.warn('Command succeeded — nothing to fix');
|
|
78
|
+
return;
|
|
79
|
+
} catch (e: unknown) {
|
|
80
|
+
const err = e as { stdout?: string; stderr?: string; message?: string };
|
|
81
|
+
failureLog = `${err.stdout ?? ''}\n${err.stderr ?? ''}\n${err.message ?? ''}`;
|
|
82
|
+
}
|
|
83
|
+
} else {
|
|
84
|
+
consola.error('Provide --log <file> or --run "<test command>"');
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const recipes = String(args.recipes || '')
|
|
90
|
+
.split(',')
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
|
|
94
|
+
consola.start(`Fixing ${file}`);
|
|
95
|
+
const result = await fixBrokenTests({
|
|
96
|
+
path: file,
|
|
97
|
+
root: resolve(String(args.root)),
|
|
98
|
+
failureLog,
|
|
99
|
+
recipes,
|
|
100
|
+
baseURL: args['base-url'] ? String(args['base-url']) : undefined,
|
|
101
|
+
browser: Boolean(args.browser || args['base-url']),
|
|
102
|
+
write: false,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const outPath = args.out ? resolve(String(args.out)) : file;
|
|
106
|
+
if (args.write || args.out) {
|
|
107
|
+
await writeText(outPath, result.code);
|
|
108
|
+
consola.success(`Wrote ${outPath}`);
|
|
109
|
+
} else {
|
|
110
|
+
consola.success(`Cached fix at ${result.genPath}`);
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { initCommand } from './init';
|
|
2
|
+
import { convertCommand } from './convert';
|
|
3
|
+
import { aiCommand } from './ai';
|
|
4
|
+
import { doctorCommand } from './doctor';
|
|
5
|
+
import { fixCommand } from './fix';
|
|
6
|
+
import { coverCommand } from './cover';
|
|
7
|
+
import { perfCommand } from './perf';
|
|
8
|
+
|
|
9
|
+
export const commands = {
|
|
10
|
+
init: initCommand,
|
|
11
|
+
convert: convertCommand,
|
|
12
|
+
ai: aiCommand,
|
|
13
|
+
fix: fixCommand,
|
|
14
|
+
cover: coverCommand,
|
|
15
|
+
doctor: doctorCommand,
|
|
16
|
+
perf: perfCommand,
|
|
17
|
+
};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { consola } from 'consola';
|
|
3
|
+
import { addDependency } from 'nypm';
|
|
4
|
+
import { resolve } from 'pathe';
|
|
5
|
+
import { applyPreset, peersForPreset, type InitPreset } from '../utils/templates';
|
|
6
|
+
|
|
7
|
+
const PRESETS: InitPreset[] = ['vitest', 'playwright', 'nuxt', 'full'];
|
|
8
|
+
|
|
9
|
+
export const initCommand = defineCommand({
|
|
10
|
+
meta: {
|
|
11
|
+
name: 'init',
|
|
12
|
+
description: 'Add base untestutils configs and example tests to a project',
|
|
13
|
+
},
|
|
14
|
+
args: {
|
|
15
|
+
preset: {
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'Preset: vitest | playwright | nuxt | full',
|
|
18
|
+
default: 'vitest',
|
|
19
|
+
alias: 'p',
|
|
20
|
+
},
|
|
21
|
+
cwd: {
|
|
22
|
+
type: 'string',
|
|
23
|
+
description: 'Project directory',
|
|
24
|
+
default: '.',
|
|
25
|
+
},
|
|
26
|
+
force: {
|
|
27
|
+
type: 'boolean',
|
|
28
|
+
description: 'Overwrite existing files',
|
|
29
|
+
default: false,
|
|
30
|
+
},
|
|
31
|
+
install: {
|
|
32
|
+
type: 'boolean',
|
|
33
|
+
description: 'Install peer dependencies via nypm',
|
|
34
|
+
default: true,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
async run({ args }) {
|
|
38
|
+
const preset = String(args.preset) as InitPreset;
|
|
39
|
+
if (!PRESETS.includes(preset)) {
|
|
40
|
+
consola.error(`Unknown preset "${preset}". Available: ${PRESETS.join(', ')}`);
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
const cwd = resolve(String(args.cwd));
|
|
45
|
+
consola.start(`Initializing untestutils (preset=${preset}) in ${cwd}`);
|
|
46
|
+
const written = await applyPreset(cwd, preset, { force: Boolean(args.force) });
|
|
47
|
+
if (written.length === 0) {
|
|
48
|
+
consola.info('No new files — already present (or use --force)');
|
|
49
|
+
} else {
|
|
50
|
+
consola.success(`Created files: ${written.length}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (args.install) {
|
|
54
|
+
const deps = peersForPreset(preset);
|
|
55
|
+
consola.start(`Installing dependencies: ${deps.join(', ')}`);
|
|
56
|
+
try {
|
|
57
|
+
await addDependency(deps, { cwd, silent: false });
|
|
58
|
+
consola.success('Dependencies installed');
|
|
59
|
+
} catch (e) {
|
|
60
|
+
consola.warn(`Could not install automatically: ${e}`);
|
|
61
|
+
consola.info(`Install manually: ${deps.join(' ')}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
consola.box(
|
|
66
|
+
[
|
|
67
|
+
'Next steps:',
|
|
68
|
+
' 1. Review recipes.ts and fixture app paths',
|
|
69
|
+
' 2. pnpm test / npx vitest run',
|
|
70
|
+
' 3. untestutils doctor — environment check',
|
|
71
|
+
].join('\n'),
|
|
72
|
+
);
|
|
73
|
+
},
|
|
74
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { defineCommand } from 'citty';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import { resolve } from 'pathe';
|
|
4
|
+
import { consola } from 'consola';
|
|
5
|
+
|
|
6
|
+
export const perfCommand = defineCommand({
|
|
7
|
+
meta: {
|
|
8
|
+
name: 'perf',
|
|
9
|
+
description: [
|
|
10
|
+
'Run a build+load performance suite from a config module.',
|
|
11
|
+
'',
|
|
12
|
+
'Examples:',
|
|
13
|
+
' untestutils perf --config ./perf.config.ts',
|
|
14
|
+
' untestutils perf --config ./perf.config.ts --only micro --skip-load',
|
|
15
|
+
' untestutils perf --config ./perf.config.ts --runs 3 --json',
|
|
16
|
+
].join('\n'),
|
|
17
|
+
},
|
|
18
|
+
args: {
|
|
19
|
+
config: {
|
|
20
|
+
type: 'string',
|
|
21
|
+
required: true,
|
|
22
|
+
description: 'Path to perf.config.ts exporting definePerfSuite(...) or default suite',
|
|
23
|
+
},
|
|
24
|
+
only: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: 'Target id(s), comma-separated',
|
|
27
|
+
},
|
|
28
|
+
runs: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: 'Override consecutive runs',
|
|
31
|
+
},
|
|
32
|
+
skipLoad: {
|
|
33
|
+
type: 'boolean',
|
|
34
|
+
default: false,
|
|
35
|
+
description: 'Measure builds only (skip autocannon/artillery)',
|
|
36
|
+
},
|
|
37
|
+
json: {
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
default: false,
|
|
40
|
+
description: 'Also write JSON report under artifactsDir',
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
async run({ args }) {
|
|
44
|
+
const configPath = resolve(args.config);
|
|
45
|
+
const mod = (await import(pathToFileURL(configPath).href)) as {
|
|
46
|
+
default?: unknown;
|
|
47
|
+
suite?: unknown;
|
|
48
|
+
};
|
|
49
|
+
const suite = (mod.default ?? mod.suite) as import('@untestutils/perf').PerfSuite | undefined;
|
|
50
|
+
if (!suite || !Array.isArray((suite as { targets?: unknown }).targets)) {
|
|
51
|
+
consola.error('Config must default-export a PerfSuite (definePerfSuite({ targets: [...] }))');
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const { runPerfSuite } = await import('@untestutils/perf');
|
|
57
|
+
const only = args.only
|
|
58
|
+
? args.only
|
|
59
|
+
.split(',')
|
|
60
|
+
.map((s: string) => s.trim())
|
|
61
|
+
.filter(Boolean)
|
|
62
|
+
: undefined;
|
|
63
|
+
const runs = args.runs ? Number(args.runs) : undefined;
|
|
64
|
+
if (runs !== undefined && (!Number.isFinite(runs) || runs < 1)) {
|
|
65
|
+
consola.error('--runs must be a positive number');
|
|
66
|
+
process.exitCode = 1;
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
await runPerfSuite(suite, {
|
|
71
|
+
only,
|
|
72
|
+
runs,
|
|
73
|
+
skipLoad: args.skipLoad,
|
|
74
|
+
json: args.json,
|
|
75
|
+
});
|
|
76
|
+
},
|
|
77
|
+
});
|
package/src/main.ts
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { defineCommand } from 'citty';
|
|
5
|
+
import { commands } from './commands';
|
|
6
|
+
|
|
7
|
+
function readOwnVersion(): string {
|
|
8
|
+
try {
|
|
9
|
+
const req = createRequire(import.meta.url);
|
|
10
|
+
// published: dist/cli-run.mjs → ../package.json ; monorepo facade/cli package.json
|
|
11
|
+
for (const cand of [
|
|
12
|
+
join(dirname(fileURLToPath(import.meta.url)), '../package.json'),
|
|
13
|
+
join(dirname(fileURLToPath(import.meta.url)), '../../package.json'),
|
|
14
|
+
]) {
|
|
15
|
+
try {
|
|
16
|
+
const pkg = req(cand) as { name?: string; version?: string };
|
|
17
|
+
if (pkg.version && (pkg.name === 'untestutils' || pkg.name === '@untestutils/cli')) {
|
|
18
|
+
return pkg.version;
|
|
19
|
+
}
|
|
20
|
+
} catch {
|
|
21
|
+
/* try next */
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
} catch {
|
|
25
|
+
/* fall through */
|
|
26
|
+
}
|
|
27
|
+
return '0.0.0';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const main = defineCommand({
|
|
31
|
+
meta: {
|
|
32
|
+
name: 'untestutils',
|
|
33
|
+
version: readOwnVersion(),
|
|
34
|
+
description: [
|
|
35
|
+
'CLI for untestutils: init, AI generate/fix/cover, doctor, monorepo helpers.',
|
|
36
|
+
'',
|
|
37
|
+
'Examples:',
|
|
38
|
+
' untestutils init --preset vitest',
|
|
39
|
+
' untestutils fix tests/e2e/broken.test.ts --run "pnpm vitest run tests/e2e/broken.test.ts"',
|
|
40
|
+
' untestutils cover --base-url http://127.0.0.1:3000 --recipes site',
|
|
41
|
+
' untestutils doctor',
|
|
42
|
+
' untestutils --help',
|
|
43
|
+
].join('\n'),
|
|
44
|
+
},
|
|
45
|
+
subCommands: commands,
|
|
46
|
+
});
|
package/src/run.ts
ADDED
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { mkdir, writeFile, readFile, access } from 'node:fs/promises';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { dirname } from 'pathe';
|
|
4
|
+
import { consola } from 'consola';
|
|
5
|
+
|
|
6
|
+
export async function pathExists(path: string): Promise<boolean> {
|
|
7
|
+
try {
|
|
8
|
+
await access(path, constants.F_OK);
|
|
9
|
+
return true;
|
|
10
|
+
} catch {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function writeIfMissing(
|
|
16
|
+
path: string,
|
|
17
|
+
content: string,
|
|
18
|
+
opts: { force?: boolean } = {},
|
|
19
|
+
): Promise<'wrote' | 'skipped'> {
|
|
20
|
+
if (!opts.force && (await pathExists(path))) {
|
|
21
|
+
consola.info(`skip (exists): ${path}`);
|
|
22
|
+
return 'skipped';
|
|
23
|
+
}
|
|
24
|
+
await mkdir(dirname(path), { recursive: true });
|
|
25
|
+
await writeFile(path, content, 'utf8');
|
|
26
|
+
consola.success(`wrote: ${path}`);
|
|
27
|
+
return 'wrote';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readText(path: string): Promise<string> {
|
|
31
|
+
return readFile(path, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeText(path: string, content: string): Promise<void> {
|
|
35
|
+
await mkdir(dirname(path), { recursive: true });
|
|
36
|
+
await writeFile(path, content, 'utf8');
|
|
37
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { join } from 'pathe';
|
|
3
|
+
import { templatesRoot } from './workspace';
|
|
4
|
+
import { writeIfMissing } from './fs';
|
|
5
|
+
|
|
6
|
+
export type InitPreset = 'vitest' | 'playwright' | 'nuxt' | 'full';
|
|
7
|
+
|
|
8
|
+
/** Relative template paths under src/templates */
|
|
9
|
+
const PRESET_FILES: Record<InitPreset, string[]> = {
|
|
10
|
+
vitest: ['vitest/vitest.config.ts', 'vitest/recipes.ts', 'vitest/tests/e2e/smoke.test.ts'],
|
|
11
|
+
playwright: [
|
|
12
|
+
'playwright/playwright.config.ts',
|
|
13
|
+
'playwright/recipes.ts',
|
|
14
|
+
'playwright/tests/e2e/smoke.spec.ts',
|
|
15
|
+
],
|
|
16
|
+
nuxt: ['nuxt/vitest.config.ts', 'nuxt/recipes.ts', 'nuxt/tests/e2e/smoke.test.ts'],
|
|
17
|
+
full: [
|
|
18
|
+
'vitest/vitest.config.ts',
|
|
19
|
+
'playwright/playwright.config.ts',
|
|
20
|
+
'nuxt/recipes.ts',
|
|
21
|
+
'vitest/tests/e2e/smoke.test.ts',
|
|
22
|
+
'playwright/tests/e2e/smoke.spec.ts',
|
|
23
|
+
'nuxt/tests/e2e/nuxt-smoke.test.ts',
|
|
24
|
+
],
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function destFromTemplate(rel: string): string {
|
|
28
|
+
const parts = rel.split('/');
|
|
29
|
+
parts.shift();
|
|
30
|
+
return parts.join('/');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function applyPreset(
|
|
34
|
+
cwd: string,
|
|
35
|
+
preset: InitPreset,
|
|
36
|
+
opts: { force?: boolean } = {},
|
|
37
|
+
): Promise<string[]> {
|
|
38
|
+
const root = templatesRoot();
|
|
39
|
+
const files = PRESET_FILES[preset];
|
|
40
|
+
const written: string[] = [];
|
|
41
|
+
const seen = new Set<string>();
|
|
42
|
+
|
|
43
|
+
for (const rel of files) {
|
|
44
|
+
const destRel = destFromTemplate(rel);
|
|
45
|
+
if (seen.has(destRel)) continue;
|
|
46
|
+
seen.add(destRel);
|
|
47
|
+
const content = await readFile(join(root, rel), 'utf8');
|
|
48
|
+
const dest = join(cwd, destRel);
|
|
49
|
+
const result = await writeIfMissing(dest, content, opts);
|
|
50
|
+
if (result === 'wrote') written.push(destRel);
|
|
51
|
+
}
|
|
52
|
+
return written;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function peersForPreset(preset: InitPreset): string[] {
|
|
56
|
+
const base = ['untestutils', 'vitest'];
|
|
57
|
+
if (preset === 'playwright' || preset === 'full') {
|
|
58
|
+
base.push('@playwright/test', 'playwright-core');
|
|
59
|
+
}
|
|
60
|
+
if (preset === 'nuxt' || preset === 'full') {
|
|
61
|
+
base.push('nuxt');
|
|
62
|
+
}
|
|
63
|
+
return [...new Set(base)];
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { join, dirname } from 'pathe';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
4
|
+
|
|
5
|
+
/** Untestutils monorepo root when the CLI runs inside this repository. */
|
|
6
|
+
export function findMonorepoRoot(start = process.cwd()): string | null {
|
|
7
|
+
let dir = start;
|
|
8
|
+
for (let i = 0; i < 12; i++) {
|
|
9
|
+
if (
|
|
10
|
+
existsSync(join(dir, 'pnpm-workspace.yaml')) &&
|
|
11
|
+
existsSync(join(dir, 'packages/untestutils/package.json'))
|
|
12
|
+
) {
|
|
13
|
+
return dir;
|
|
14
|
+
}
|
|
15
|
+
const parent = dirname(dir);
|
|
16
|
+
if (parent === dir) break;
|
|
17
|
+
dir = parent;
|
|
18
|
+
}
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function cliPackageRoot(): string {
|
|
23
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
for (let i = 0; i < 6; i++) {
|
|
25
|
+
const pkgPath = join(dir, 'package.json');
|
|
26
|
+
if (existsSync(pkgPath)) {
|
|
27
|
+
try {
|
|
28
|
+
const name = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string }).name;
|
|
29
|
+
if (name === '@untestutils/cli') return dir;
|
|
30
|
+
} catch {
|
|
31
|
+
/* continue */
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
const parent = dirname(dir);
|
|
35
|
+
if (parent === dir) break;
|
|
36
|
+
dir = parent;
|
|
37
|
+
}
|
|
38
|
+
return join(dirname(fileURLToPath(import.meta.url)), '../..');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function templatesRoot(): string {
|
|
42
|
+
const mono = findMonorepoRoot();
|
|
43
|
+
if (mono) {
|
|
44
|
+
const fromMono = join(mono, 'packages/cli/templates');
|
|
45
|
+
if (existsSync(fromMono)) return fromMono;
|
|
46
|
+
}
|
|
47
|
+
const root = cliPackageRoot();
|
|
48
|
+
for (const c of [
|
|
49
|
+
join(root, 'templates'),
|
|
50
|
+
join(root, 'dist/templates'),
|
|
51
|
+
join(root, 'src/templates'),
|
|
52
|
+
]) {
|
|
53
|
+
if (existsSync(c)) return c;
|
|
54
|
+
}
|
|
55
|
+
// bundled into untestutils facade (legacy) or published package
|
|
56
|
+
let dir = dirname(fileURLToPath(import.meta.url));
|
|
57
|
+
for (let i = 0; i < 6; i++) {
|
|
58
|
+
const pkgPath = join(dir, 'package.json');
|
|
59
|
+
if (existsSync(pkgPath)) {
|
|
60
|
+
try {
|
|
61
|
+
const name = (JSON.parse(readFileSync(pkgPath, 'utf8')) as { name?: string }).name;
|
|
62
|
+
if (name === 'untestutils' || name === '@untestutils/cli') {
|
|
63
|
+
for (const c of [
|
|
64
|
+
join(dir, 'templates'),
|
|
65
|
+
join(dir, 'dist/templates'),
|
|
66
|
+
join(dir, 'src/templates'),
|
|
67
|
+
]) {
|
|
68
|
+
if (existsSync(c)) return c;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
/* continue */
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const parent = dirname(dir);
|
|
76
|
+
if (parent === dir) break;
|
|
77
|
+
dir = parent;
|
|
78
|
+
}
|
|
79
|
+
return join(cliPackageRoot(), 'templates');
|
|
80
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { defineRecipes } from 'untestutils';
|
|
2
|
+
import { nuxt } from 'untestutils/nuxt';
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
|
|
5
|
+
/** root — Nuxt app directory (e.g. playground or fixtures/nuxt). */
|
|
6
|
+
export const recipes = defineRecipes(
|
|
7
|
+
{
|
|
8
|
+
app: nuxt({
|
|
9
|
+
id: 'app',
|
|
10
|
+
root: resolve('./fixtures/nuxt'),
|
|
11
|
+
}),
|
|
12
|
+
},
|
|
13
|
+
import.meta.url,
|
|
14
|
+
);
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, test, expect, useHarness } from 'untestutils/vitest';
|
|
2
|
+
|
|
3
|
+
describe('nuxt smoke', () => {
|
|
4
|
+
test('app returns HTML', async () => {
|
|
5
|
+
const app = await useHarness('app');
|
|
6
|
+
const res = await fetch(app.url!);
|
|
7
|
+
expect(res.ok).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { describe, test, expect, useHarness } from 'untestutils/vitest';
|
|
2
|
+
|
|
3
|
+
describe('nuxt smoke', () => {
|
|
4
|
+
test('app returns HTML', async () => {
|
|
5
|
+
const app = await useHarness('app');
|
|
6
|
+
const res = await fetch(app.url!);
|
|
7
|
+
expect(res.ok).toBe(true);
|
|
8
|
+
});
|
|
9
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { untestutils } from 'untestutils/vitest/plugin';
|
|
3
|
+
import { recipes } from './recipes';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* E2e harness project. Keep unit Nuxt env in a *separate* Vitest config
|
|
7
|
+
* (see vitest.unit.config.ts) — do not mix `untestutils()` plugin with
|
|
8
|
+
* `environment: 'untestutils'` in one project.
|
|
9
|
+
*/
|
|
10
|
+
export default defineConfig({
|
|
11
|
+
plugins: [
|
|
12
|
+
untestutils({
|
|
13
|
+
recipes,
|
|
14
|
+
prewarm: ['app'],
|
|
15
|
+
}),
|
|
16
|
+
],
|
|
17
|
+
test: {
|
|
18
|
+
include: ['tests/e2e/**/*.test.ts'],
|
|
19
|
+
testTimeout: 120_000,
|
|
20
|
+
},
|
|
21
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { fileURLToPath } from 'node:url';
|
|
2
|
+
import { defineVitestProject } from 'untestutils/config';
|
|
3
|
+
|
|
4
|
+
/** In-process Nuxt unit project — separate from e2e harness config. */
|
|
5
|
+
export default defineVitestProject({
|
|
6
|
+
test: {
|
|
7
|
+
name: 'unit-nuxt',
|
|
8
|
+
include: ['tests/unit/**/*.{test,spec}.ts'],
|
|
9
|
+
environmentOptions: {
|
|
10
|
+
nuxt: {
|
|
11
|
+
rootDir: fileURLToPath(new URL('.', import.meta.url)),
|
|
12
|
+
domEnvironment: 'happy-dom',
|
|
13
|
+
},
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from '@playwright/test';
|
|
2
|
+
import { createPlaywrightConfig } from 'untestutils/playwright';
|
|
3
|
+
import { recipes } from './recipes';
|
|
4
|
+
|
|
5
|
+
export default defineConfig(
|
|
6
|
+
createPlaywrightConfig({
|
|
7
|
+
recipes,
|
|
8
|
+
session: 'app-pw',
|
|
9
|
+
prewarm: ['basic'],
|
|
10
|
+
workers: 2,
|
|
11
|
+
fullyParallel: true,
|
|
12
|
+
testDir: './tests/e2e',
|
|
13
|
+
use: {
|
|
14
|
+
headless: true,
|
|
15
|
+
},
|
|
16
|
+
}),
|
|
17
|
+
);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { defineRecipes, staticDir } from 'untestutils';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/** Replace root with your fixture app path (static folder or Nuxt). */
|
|
5
|
+
export const recipes = defineRecipes(
|
|
6
|
+
{
|
|
7
|
+
basic: staticDir({
|
|
8
|
+
id: 'basic',
|
|
9
|
+
root: resolve('./fixtures/basic'),
|
|
10
|
+
}),
|
|
11
|
+
},
|
|
12
|
+
import.meta.url,
|
|
13
|
+
);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { defineConfig } from 'vitest/config';
|
|
2
|
+
import { untestutils } from 'untestutils/vitest/plugin';
|
|
3
|
+
import { recipes } from './recipes';
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
plugins: [
|
|
7
|
+
untestutils({
|
|
8
|
+
recipes,
|
|
9
|
+
prewarm: ['basic'],
|
|
10
|
+
}),
|
|
11
|
+
],
|
|
12
|
+
test: {
|
|
13
|
+
include: ['tests/e2e/**/*.test.ts'],
|
|
14
|
+
testTimeout: 60_000,
|
|
15
|
+
},
|
|
16
|
+
});
|