codz-cli 0.0.0-stage → 0.1.2
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/BETA.md +91 -0
- package/LICENSE +21 -0
- package/README.md +525 -2
- package/assets/gradle-wrapper/README.txt +2 -0
- package/assets/gradle-wrapper/gradle-wrapper.jar +0 -0
- package/assets/gradle-wrapper/gradlew +251 -0
- package/assets/gradle-wrapper/gradlew.bat +94 -0
- package/bin/codz.js +33 -0
- package/dist/adapters/android.js +321 -0
- package/dist/adapters/androidSetup.js +277 -0
- package/dist/adapters/common.js +114 -0
- package/dist/adapters/dartGen.js +83 -0
- package/dist/adapters/flutter.js +291 -0
- package/dist/adapters/flutterGen.js +204 -0
- package/dist/adapters/flutterScan.js +312 -0
- package/dist/adapters/flutterSetup.js +252 -0
- package/dist/adapters/flutterStyles.js +130 -0
- package/dist/adapters/generic.js +20 -0
- package/dist/adapters/getx.js +423 -0
- package/dist/adapters/index.js +28 -0
- package/dist/adapters/ios.js +440 -0
- package/dist/adapters/kotlinGen.js +110 -0
- package/dist/adapters/models.js +116 -0
- package/dist/adapters/nextjs.js +243 -0
- package/dist/adapters/nextjsGen.js +285 -0
- package/dist/adapters/nextjsScan.js +364 -0
- package/dist/adapters/nextjsSetup.js +119 -0
- package/dist/adapters/react.js +89 -0
- package/dist/adapters/reactNative.js +109 -0
- package/dist/adapters/reactNativeSetup.js +236 -0
- package/dist/adapters/reactQuery.js +89 -0
- package/dist/adapters/reactSetup.js +150 -0
- package/dist/adapters/source.js +165 -0
- package/dist/cli.js +1175 -0
- package/dist/core/cache.js +76 -0
- package/dist/core/changeset.js +404 -0
- package/dist/core/commands.js +239 -0
- package/dist/core/config.js +45 -0
- package/dist/core/diff.js +143 -0
- package/dist/core/events.js +41 -0
- package/dist/core/fsutil.js +122 -0
- package/dist/core/git.js +36 -0
- package/dist/core/jsonEdit.js +113 -0
- package/dist/core/merge.js +159 -0
- package/dist/core/proc.js +23 -0
- package/dist/core/safe.js +75 -0
- package/dist/core/templates.js +100 -0
- package/dist/core/tsAst.js +112 -0
- package/dist/core/types.js +12 -0
- package/dist/core/validateConfig.js +172 -0
- package/dist/core/workflow.js +72 -0
- package/dist/core/workspace.js +80 -0
- package/dist/core/yaml.js +126 -0
- package/dist/core/yamlDoc.js +367 -0
- package/dist/detector.js +95 -0
- package/dist/input/apiSpec.js +395 -0
- package/dist/input/curl.js +259 -0
- package/dist/input/endpoint.js +220 -0
- package/dist/input/har.js +120 -0
- package/dist/input/infer.js +174 -0
- package/dist/input/naming.js +145 -0
- package/dist/input/paste.js +130 -0
- package/dist/skills/androidSkeleton.js +292 -0
- package/dist/skills/androidTests.js +221 -0
- package/dist/skills/androidUi.js +561 -0
- package/dist/skills/androidViews.js +536 -0
- package/dist/skills/apiAdd.js +99 -0
- package/dist/skills/apiDiff.js +271 -0
- package/dist/skills/apiEdit.js +547 -0
- package/dist/skills/apiImport.js +127 -0
- package/dist/skills/apiList.js +35 -0
- package/dist/skills/apiSync.js +221 -0
- package/dist/skills/auth.js +1304 -0
- package/dist/skills/completion.js +142 -0
- package/dist/skills/config.js +93 -0
- package/dist/skills/create.js +638 -0
- package/dist/skills/createDemo.js +1026 -0
- package/dist/skills/doctor.js +304 -0
- package/dist/skills/doctorFix.js +214 -0
- package/dist/skills/explain.js +61 -0
- package/dist/skills/feature.js +418 -0
- package/dist/skills/featureEdit.js +225 -0
- package/dist/skills/featureLinks.js +179 -0
- package/dist/skills/init.js +66 -0
- package/dist/skills/iosUi.js +468 -0
- package/dist/skills/locale.js +335 -0
- package/dist/skills/mcp.js +175 -0
- package/dist/skills/mock.js +482 -0
- package/dist/skills/module.js +23 -0
- package/dist/skills/plans.js +66 -0
- package/dist/skills/prefill.js +84 -0
- package/dist/skills/reactNativeUi.js +388 -0
- package/dist/skills/reactUi.js +352 -0
- package/dist/skills/report.js +75 -0
- package/dist/skills/route.js +230 -0
- package/dist/skills/screen.js +597 -0
- package/dist/skills/screenForm.js +410 -0
- package/dist/skills/setup.js +28 -0
- package/dist/skills/setupDoctor.js +226 -0
- package/dist/skills/templatesCmd.js +32 -0
- package/dist/skills/testgen.js +331 -0
- package/dist/skills/theme.js +388 -0
- package/dist/starters/auth.js +144 -0
- package/dist/starters/commands.js +283 -0
- package/dist/starters/engine.js +315 -0
- package/dist/starters/home.js +42 -0
- package/dist/starters/http.js +93 -0
- package/dist/starters/manifest.js +285 -0
- package/dist/starters/paths.js +70 -0
- package/dist/starters/publish.js +150 -0
- package/dist/starters/registry.js +258 -0
- package/dist/starters/sign.js +72 -0
- package/dist/starters/source.js +98 -0
- package/dist/starters/strictJson.js +148 -0
- package/dist/starters/tar.js +180 -0
- package/docs/COMMANDS.md +527 -0
- package/examples/openapi.yaml +319 -0
- package/package.json +57 -4
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// doctor --setup: can codz run here? Environment and configuration checks,
|
|
2
|
+
// separate from the project-health checks of plain `codz doctor`. Read-only.
|
|
3
|
+
//
|
|
4
|
+
// Every check returns pass, warn or fail with a message and, when useful, a hint.
|
|
5
|
+
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { TOOL_DIR } from "../core/cache.js";
|
|
8
|
+
import { CONFIG_FILE } from "../core/config.js";
|
|
9
|
+
import { exists, readJson, readText, walk } from "../core/fsutil.js";
|
|
10
|
+
import { dirtyFiles, isGitRepo } from "../core/git.js";
|
|
11
|
+
import { PATH_KEYS, validateConfig } from "../core/validateConfig.js";
|
|
12
|
+
import { runTool } from "../core/proc.js";
|
|
13
|
+
// From source Node runs the TypeScript itself (22.18+); the npm build (dist/) needs 20+.
|
|
14
|
+
const FROM_SOURCE = import.meta.url.endsWith('.ts');
|
|
15
|
+
const MIN_NODE = FROM_SOURCE ? [22, 18] : [20, 0];
|
|
16
|
+
const has = (cmd, args = ['--version']) => {
|
|
17
|
+
const r = runTool(cmd, args, { timeout: 10000 });
|
|
18
|
+
return r.status === 0 ? (r.stdout || r.stderr).trim().split('\n')[0] : null;
|
|
19
|
+
};
|
|
20
|
+
function pkgDeps(root) {
|
|
21
|
+
const pkg = readJson(join(root, 'package.json'));
|
|
22
|
+
return { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
23
|
+
}
|
|
24
|
+
const pubspec = (root) => readText(join(root, 'pubspec.yaml')) ?? '';
|
|
25
|
+
export const CHECKS = [
|
|
26
|
+
{
|
|
27
|
+
id: 'node_version',
|
|
28
|
+
run() {
|
|
29
|
+
const [maj, min] = process.versions.node.split('.').map(Number);
|
|
30
|
+
const ok = maj > MIN_NODE[0] || (maj === MIN_NODE[0] && min >= MIN_NODE[1]);
|
|
31
|
+
return ok
|
|
32
|
+
? { status: 'pass', message: `v${process.versions.node}` }
|
|
33
|
+
: { status: 'fail', message: `Node v${process.versions.node} is older than ${MIN_NODE.join('.')}.`, hint: FROM_SOURCE ? 'Run from source, codz needs Node 22.18 or newer (or run npm run build to use Node 20+). Install it with nvm, fnm or from nodejs.org.' : 'codz needs Node 20 or newer. Install it with nvm, fnm or from nodejs.org.' };
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
id: 'git_present',
|
|
38
|
+
run({ root }) {
|
|
39
|
+
if (!has('git'))
|
|
40
|
+
return { status: 'warn', message: 'git is not installed.', hint: 'codz still works, but it cannot warn you about uncommitted changes before writing.' };
|
|
41
|
+
return isGitRepo(root) ? { status: 'pass', message: 'git repository' } : { status: 'warn', message: 'This project is not a git repository.', hint: 'Run git init and commit, so every codz change is a reviewable diff.' };
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'git_clean',
|
|
46
|
+
run({ root }) {
|
|
47
|
+
if (!isGitRepo(root))
|
|
48
|
+
return { status: 'pass', message: 'not a git repository (skipped)' };
|
|
49
|
+
const dirty = dirtyFiles(root).filter((f) => f !== CONFIG_FILE);
|
|
50
|
+
return dirty.length
|
|
51
|
+
? { status: 'warn', message: `${dirty.length} uncommitted change${dirty.length === 1 ? '' : 's'} (${dirty.slice(0, 3).join(', ')}${dirty.length > 3 ? ', …' : ''}).`, hint: 'Commands that write refuse a dirty tree unless you pass --allow-dirty.' }
|
|
52
|
+
: { status: 'pass', message: 'working tree clean' };
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: 'config_found',
|
|
57
|
+
run({ configText }) {
|
|
58
|
+
return configText !== null ? { status: 'pass', message: CONFIG_FILE } : { status: 'fail', message: `No ${CONFIG_FILE} in this folder or above it.`, hint: 'Create it with: codz init' };
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
id: 'config_valid',
|
|
63
|
+
run({ root, configText }) {
|
|
64
|
+
if (configText === null)
|
|
65
|
+
return { status: 'fail', message: 'no config to check', hint: 'codz init' };
|
|
66
|
+
const issues = validateConfig(configText, root).filter((i) => !i.key || !PATH_KEYS.has(i.key));
|
|
67
|
+
const errors = issues.filter((i) => i.level === 'error');
|
|
68
|
+
if (errors.length)
|
|
69
|
+
return { status: 'fail', message: errors.map((e) => e.message).join(' '), hint: errors[0].hint ?? 'See every problem with: codz config check' };
|
|
70
|
+
if (issues.length)
|
|
71
|
+
return { status: 'warn', message: issues.map((e) => e.message).join(' '), hint: 'See every problem with: codz config check' };
|
|
72
|
+
return { status: 'pass', message: 'parses; known keys and values' };
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
id: 'config_paths',
|
|
77
|
+
run({ root, configText }) {
|
|
78
|
+
if (configText === null)
|
|
79
|
+
return { status: 'pass', message: 'no config (skipped)' };
|
|
80
|
+
let issues;
|
|
81
|
+
try {
|
|
82
|
+
issues = validateConfig(configText, root).filter((i) => i.key && PATH_KEYS.has(i.key));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return { status: 'pass', message: 'skipped' };
|
|
86
|
+
}
|
|
87
|
+
const errors = issues.filter((i) => i.level === 'error');
|
|
88
|
+
if (errors.length)
|
|
89
|
+
return { status: 'fail', message: errors.map((e) => e.message).join(' '), hint: errors[0].hint };
|
|
90
|
+
if (issues.length)
|
|
91
|
+
return { status: 'warn', message: issues.map((e) => e.message).join(' '), hint: issues[0].hint };
|
|
92
|
+
return { status: 'pass', message: 'every path exists inside the project' };
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: 'gitignore',
|
|
97
|
+
run({ root }) {
|
|
98
|
+
if (isGitRepo(root)) {
|
|
99
|
+
const r = spawnSync('git', ['check-ignore', '-q', `${TOOL_DIR}/cache/map.json`], { cwd: root });
|
|
100
|
+
return r.status === 0 ? { status: 'pass', message: `${TOOL_DIR}/ is ignored` } : { status: 'warn', message: `${TOOL_DIR}/ is not in .gitignore.`, hint: `Add a line "${TOOL_DIR}/" to .gitignore; it holds the cache, backups and history.` };
|
|
101
|
+
}
|
|
102
|
+
const gi = readText(join(root, '.gitignore')) ?? '';
|
|
103
|
+
return /^\/?\.codz\/?$/m.test(gi) ? { status: 'pass', message: `${TOOL_DIR}/ is ignored` } : { status: 'warn', message: `${TOOL_DIR}/ is not in .gitignore.`, hint: `Add a line "${TOOL_DIR}/" to .gitignore.` };
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
id: 'framework_supported',
|
|
108
|
+
run({ adapter }) {
|
|
109
|
+
return adapter.id === 'generic'
|
|
110
|
+
? { status: 'warn', message: 'No Next.js or Flutter project was found, so only explain and doctor work.', hint: 'Run codz from the folder with package.json (Next.js) or pubspec.yaml (Flutter).' }
|
|
111
|
+
: { status: 'pass', message: `${adapter.id}@${adapter.version}` };
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
id: 'unsupported_libs',
|
|
116
|
+
run({ root, adapter }) {
|
|
117
|
+
const found = [];
|
|
118
|
+
if (adapter.id === 'nextjs') {
|
|
119
|
+
if (!exists(join(root, 'app')) && !exists(join(root, 'src/app')) && (exists(join(root, 'pages')) || exists(join(root, 'src/pages'))))
|
|
120
|
+
found.push('Pages Router (api add and mocks expect the App Router; route add works)');
|
|
121
|
+
if (pkgDeps(root).swr)
|
|
122
|
+
found.push('SWR (hooks are written for TanStack Query only)');
|
|
123
|
+
}
|
|
124
|
+
else if (adapter.id === 'flutter') {
|
|
125
|
+
const spec = pubspec(root);
|
|
126
|
+
if (/^\s+http:/m.test(spec) && !/^\s+dio:/m.test(spec))
|
|
127
|
+
found.push('the http package (api add writes Dio code)');
|
|
128
|
+
if (/^\s+chopper:/m.test(spec))
|
|
129
|
+
found.push('Chopper (api add writes Dio repositories or Retrofit clients)');
|
|
130
|
+
}
|
|
131
|
+
return found.length
|
|
132
|
+
? { status: 'warn', message: `Not supported yet: ${found.join('; ')}.`, hint: 'codz still runs; generated code follows its own style for these.' }
|
|
133
|
+
: { status: 'pass', message: 'none found' };
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{
|
|
137
|
+
id: 'formatter',
|
|
138
|
+
run({ root, adapter }) {
|
|
139
|
+
if (adapter.id === 'nextjs' || adapter.id === 'react-native') {
|
|
140
|
+
return exists(join(root, 'node_modules/.bin/prettier')) || exists(join(root, 'node_modules/prettier'))
|
|
141
|
+
? { status: 'pass', message: 'prettier' }
|
|
142
|
+
: { status: 'warn', message: 'Prettier is not installed in this project. Code is written in the detected style but not formatted.', hint: 'npm i -D prettier' };
|
|
143
|
+
}
|
|
144
|
+
if (adapter.id === 'flutter') {
|
|
145
|
+
const v = has('dart');
|
|
146
|
+
return v ? { status: 'pass', message: v } : { status: 'warn', message: 'Dart SDK not found. Code will be written but not formatted.', hint: 'Install Flutter and make sure dart is on your PATH.' };
|
|
147
|
+
}
|
|
148
|
+
return { status: 'pass', message: 'not needed' };
|
|
149
|
+
},
|
|
150
|
+
},
|
|
151
|
+
{
|
|
152
|
+
id: 'analyzer',
|
|
153
|
+
run({ root, adapter }) {
|
|
154
|
+
if (adapter.id === 'nextjs' || adapter.id === 'react-native') {
|
|
155
|
+
return exists(join(root, 'node_modules/typescript'))
|
|
156
|
+
? { status: 'pass', message: 'typescript' }
|
|
157
|
+
: { status: 'warn', message: 'typescript is not installed, so changes are not checked for compile errors after applying.', hint: 'npm install (TypeScript is in most Next.js projects already)' };
|
|
158
|
+
}
|
|
159
|
+
if (adapter.id === 'flutter') {
|
|
160
|
+
if (!has('dart'))
|
|
161
|
+
return { status: 'warn', message: 'Dart SDK not found, so changes are not analyzed after applying.', hint: 'Install Flutter.' };
|
|
162
|
+
return exists(join(root, '.dart_tool/package_config.json'))
|
|
163
|
+
? { status: 'pass', message: 'dart analyze' }
|
|
164
|
+
: { status: 'warn', message: 'flutter pub get has not been run, so dart analyze is skipped.', hint: 'flutter pub get' };
|
|
165
|
+
}
|
|
166
|
+
return { status: 'pass', message: 'not needed' };
|
|
167
|
+
},
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: 'scan_cache',
|
|
171
|
+
run({ root }) {
|
|
172
|
+
const path = join(root, TOOL_DIR, 'cache', 'map.json');
|
|
173
|
+
if (!exists(path))
|
|
174
|
+
return { status: 'pass', message: 'no cache yet; it is built on the next command' };
|
|
175
|
+
const cache = readJson(path);
|
|
176
|
+
if (!cache || !cache.map)
|
|
177
|
+
return { status: 'warn', message: 'The scan cache is unreadable.', hint: 'It is rebuilt automatically; to force it now: codz explain --rescan' };
|
|
178
|
+
return { status: 'pass', message: 'readable (rebuilt automatically when files or codz change)' };
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
{
|
|
182
|
+
id: 'history_state',
|
|
183
|
+
run({ root }) {
|
|
184
|
+
const leftovers = walk(root, '.', { maxFiles: 50000 }).filter((f) => f.endsWith('.codz-tmp'));
|
|
185
|
+
if (leftovers.length)
|
|
186
|
+
return { status: 'fail', message: `Half-written files from an interrupted apply: ${leftovers.slice(0, 3).join(', ')}.`, hint: 'Delete the .codz-tmp files; the real files were not replaced.' };
|
|
187
|
+
const histPath = join(root, TOOL_DIR, 'history.json');
|
|
188
|
+
if (!exists(histPath))
|
|
189
|
+
return { status: 'pass', message: 'no changes applied yet' };
|
|
190
|
+
const hist = readJson(histPath);
|
|
191
|
+
if (!Array.isArray(hist))
|
|
192
|
+
return { status: 'fail', message: `${TOOL_DIR}/history.json is unreadable, so undo cannot work.`, hint: `Move ${TOOL_DIR}/history.json aside; codz starts a new history.` };
|
|
193
|
+
const last = [...hist].reverse().find((h) => !h.undone);
|
|
194
|
+
if (last && !exists(join(root, TOOL_DIR, 'backups', last.id)))
|
|
195
|
+
return { status: 'warn', message: `The backup for the last change (${last.id}) is missing, so it cannot be undone.` };
|
|
196
|
+
return { status: 'pass', message: `${hist.length} change${hist.length === 1 ? '' : 's'} in history` };
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
];
|
|
200
|
+
export function runSetupDoctor(root, adapter) {
|
|
201
|
+
const ctx = { root, adapter, configText: readText(join(root, CONFIG_FILE)) };
|
|
202
|
+
const checks = CHECKS.map((c) => {
|
|
203
|
+
try {
|
|
204
|
+
return { id: c.id, ...c.run(ctx) };
|
|
205
|
+
}
|
|
206
|
+
catch (e) {
|
|
207
|
+
return { id: c.id, status: 'warn', message: `The check could not run: ${e.message}` };
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
const summary = { pass: 0, warn: 0, fail: 0 };
|
|
211
|
+
for (const c of checks)
|
|
212
|
+
summary[c.status]++;
|
|
213
|
+
return { checks, summary };
|
|
214
|
+
}
|
|
215
|
+
export function renderSetupDoctor(r, header, color) {
|
|
216
|
+
const paint = (s, t) => (!color ? t : s === 'fail' ? `\x1b[31m${t}\x1b[0m` : s === 'warn' ? `\x1b[33m${t}\x1b[0m` : `\x1b[32m${t}\x1b[0m`);
|
|
217
|
+
const width = Math.max(...r.checks.map((c) => c.id.length));
|
|
218
|
+
const lines = [header];
|
|
219
|
+
for (const c of r.checks) {
|
|
220
|
+
lines.push(` ${paint(c.status, c.status.padEnd(4))} ${c.id.padEnd(width)} ${c.message}`);
|
|
221
|
+
if (c.hint && c.status !== 'pass')
|
|
222
|
+
lines.push(` ${' '.repeat(4)} ${' '.repeat(width)} → ${c.hint}`);
|
|
223
|
+
}
|
|
224
|
+
lines.push(`Summary: ${r.summary.pass} pass, ${r.summary.warn} warn, ${r.summary.fail} fail`);
|
|
225
|
+
return lines.join('\n');
|
|
226
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// templates list | templates init [id...]: see which templates the project
|
|
2
|
+
// customizes, and start one from a file that reproduces codz's own output.
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
5
|
+
import { exists } from "../core/fsutil.js";
|
|
6
|
+
import { TEMPLATES, templatesFolder } from "../core/templates.js";
|
|
7
|
+
import { ToolError } from "../core/types.js";
|
|
8
|
+
export function listTemplates(root) {
|
|
9
|
+
const folder = templatesFolder(root);
|
|
10
|
+
return { folder, templates: TEMPLATES.map((t) => ({ id: t.id, about: t.about, custom: exists(join(root, folder, `${t.id}.tmpl`)), placeholders: Object.keys(t.vars) })) };
|
|
11
|
+
}
|
|
12
|
+
export function planTemplatesInit(root, ids) {
|
|
13
|
+
const folder = templatesFolder(root);
|
|
14
|
+
const unknown = ids.filter((id) => !TEMPLATES.some((t) => t.id === id));
|
|
15
|
+
if (unknown.length)
|
|
16
|
+
throw new ToolError('bad_template', `Unknown template ${unknown.join(', ')}.`, `Templates: ${TEMPLATES.map((t) => t.id).join(', ')}`);
|
|
17
|
+
const chosen = TEMPLATES.filter((t) => !ids.length || ids.includes(t.id));
|
|
18
|
+
const files = new Map();
|
|
19
|
+
const summary = [];
|
|
20
|
+
for (const t of chosen) {
|
|
21
|
+
const file = `${folder}/${t.id}.tmpl`;
|
|
22
|
+
if (exists(join(root, file)))
|
|
23
|
+
continue;
|
|
24
|
+
files.set(file, t.example);
|
|
25
|
+
summary.push(`Create ${file}: ${t.about} (placeholders: ${Object.keys(t.vars).map((v) => `{{${v}}}`).join(', ')})`);
|
|
26
|
+
}
|
|
27
|
+
if (!files.size)
|
|
28
|
+
summary.push(`Every chosen template already exists in ${folder}.`);
|
|
29
|
+
else
|
|
30
|
+
summary.push('Edit them and commit them; codz uses them from the next command on. {{default}} is what codz would write.');
|
|
31
|
+
return buildChangeSet(root, 'templates init', { files, summary, warnings: [], generated: [] });
|
|
32
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// test add <endpoint>... | --all: unit tests for API functions, with the
|
|
2
|
+
// network faked so they run offline and fast.
|
|
3
|
+
//
|
|
4
|
+
// Next.js Vitest (or Jest when the project uses it): fetch, or the axios
|
|
5
|
+
// instance's method, is replaced with a spy that answers example
|
|
6
|
+
// data; the test checks the method, the URL and the parsed result,
|
|
7
|
+
// and (for fetch) that a failed response throws.
|
|
8
|
+
// Flutter flutter_test: Dio gets a fake HttpClientAdapter; the test checks
|
|
9
|
+
// the method, the path and the parsed result, and that a 500 throws
|
|
10
|
+
// a DioException.
|
|
11
|
+
//
|
|
12
|
+
// Example data comes from the models, like mock add. One test file per API
|
|
13
|
+
// file; endpoints that already have a test are skipped.
|
|
14
|
+
import { join, posix } from 'node:path';
|
|
15
|
+
import { loadMap } from "../core/cache.js";
|
|
16
|
+
import { buildChangeSet } from "../core/changeset.js";
|
|
17
|
+
import { loadConfig } from "../core/config.js";
|
|
18
|
+
import { readJson, readText, walk } from "../core/fsutil.js";
|
|
19
|
+
import { ToolError } from "../core/types.js";
|
|
20
|
+
import { readMethod, splitParams } from "../adapters/getx.js";
|
|
21
|
+
import { importPath } from "../adapters/nextjs.js";
|
|
22
|
+
import { findDeclaration } from "./apiEdit.js";
|
|
23
|
+
import { exampleFor, unwrapsData } from "./mock.js";
|
|
24
|
+
function pick(map, opts) {
|
|
25
|
+
if (opts.all)
|
|
26
|
+
return map.endpoints;
|
|
27
|
+
if (!opts.endpoints.length)
|
|
28
|
+
throw new ToolError('bad_args', 'Which endpoints? Usage: codz test add <endpoint>... or --all');
|
|
29
|
+
return opts.endpoints.map((n) => {
|
|
30
|
+
const ep = map.endpoints.find((e) => e.name === n) ?? map.endpoints.find((e) => e.name.toLowerCase() === n.toLowerCase());
|
|
31
|
+
if (!ep)
|
|
32
|
+
throw new ToolError('no_endpoint', `No endpoint called ${n} was found.`, `Endpoints: ${map.endpoints.map((e) => e.name).join(', ')}`);
|
|
33
|
+
return ep;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
export async function planTestAdd(root, adapter, opts) {
|
|
37
|
+
const { map } = await loadMap(root, adapter, loadConfig(root).conventions);
|
|
38
|
+
const eps = pick(map, opts);
|
|
39
|
+
if (!eps.length)
|
|
40
|
+
throw new ToolError('no_endpoints', 'This project has no endpoints to test yet.', 'Add one with: codz api add');
|
|
41
|
+
if (adapter.id === 'nextjs' || adapter.id === 'react-native' || adapter.id === 'react')
|
|
42
|
+
return nextTests(root, map, eps);
|
|
43
|
+
if (adapter.id === 'flutter')
|
|
44
|
+
return flutterTests(root, map, eps);
|
|
45
|
+
if (adapter.id === 'android')
|
|
46
|
+
return (await import("./androidTests.js")).androidTests(root, map, eps);
|
|
47
|
+
throw new ToolError('not_supported', `test add writes tests for Next.js, React, React Native, Flutter and Android; ${adapter.id === 'ios' ? 'iOS (XCTest) is not supported yet' : `this project uses the ${adapter.id} adapter`}.`, adapter.id === 'ios' ? 'Test the APIClient methods with XCTest and a URLProtocol stub for now; codz will write these later.' : undefined);
|
|
48
|
+
}
|
|
49
|
+
/** Argument values for a call: path parameters get 1 or 'abc', the body gets example data. */
|
|
50
|
+
function exampleArg(type, name, map) {
|
|
51
|
+
if (/^(number|int|double)$/.test(type))
|
|
52
|
+
return 1;
|
|
53
|
+
if (/^(string|String)$/.test(type))
|
|
54
|
+
return name === 'id' || /Id$/.test(name) ? 'abc' : 'text';
|
|
55
|
+
return exampleFor(type.replace(/^Partial<(.+)>$/, '$1'), map, name);
|
|
56
|
+
}
|
|
57
|
+
/** A JavaScript literal in the project's quote style. */
|
|
58
|
+
function tsLiteral(v, q) {
|
|
59
|
+
if (typeof v === 'string')
|
|
60
|
+
return q === "'" ? `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'` : JSON.stringify(v);
|
|
61
|
+
if (v === null || typeof v !== 'object')
|
|
62
|
+
return String(v);
|
|
63
|
+
if (Array.isArray(v))
|
|
64
|
+
return `[${v.map((x) => tsLiteral(x, q)).join(', ')}]`;
|
|
65
|
+
const IDENT = /^[A-Za-z_$][\w$]*$/;
|
|
66
|
+
return `{ ${Object.entries(v).map(([k, x]) => `${IDENT.test(k) ? k : tsLiteral(k, q)}: ${tsLiteral(x, q)}`).join(', ')} }`.replace('{ }', '{}');
|
|
67
|
+
}
|
|
68
|
+
const fill = (path, values) => path.replace(/\{([^/}]+)\}/g, (_m, n) => String(values.get(n) ?? values.get(n.replace(/[-_](\w)/g, (_x, ch) => ch.toUpperCase())) ?? 1));
|
|
69
|
+
// ---------------- Next.js ----------------
|
|
70
|
+
function nextTests(root, map, eps) {
|
|
71
|
+
const c = map.conventions;
|
|
72
|
+
const pkg = readJson(join(root, 'package.json')) ?? {};
|
|
73
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
74
|
+
// React Native apps test with Jest (jest-expo on Expo) even before it is installed; web apps default to Vitest.
|
|
75
|
+
const rn = map.framework === 'react-native';
|
|
76
|
+
const jest = !deps.vitest && (!!(deps.jest || deps['@types/jest'] || deps['jest-expo']) || rn);
|
|
77
|
+
const spy = jest ? 'jest' : 'vi';
|
|
78
|
+
const existingTests = walk(root, '.', { exts: ['.ts', '.tsx'] }).filter((f) => /\.(test|spec)\.tsx?$/.test(f));
|
|
79
|
+
const inTestsFolder = existingTests.length > 0 && existingTests.filter((f) => f.includes('/__tests__/')).length * 2 > existingTests.length;
|
|
80
|
+
const q = c.quote === 'double' ? '"' : "'";
|
|
81
|
+
const semi = c.semicolons === 'false' ? '' : ';';
|
|
82
|
+
const files = new Map();
|
|
83
|
+
const summary = [];
|
|
84
|
+
const warnings = [];
|
|
85
|
+
let warned204 = false;
|
|
86
|
+
if (!deps.vitest && !jest)
|
|
87
|
+
warnings.push('No test runner found in package.json; the tests are written for Vitest. Add it with: npm i -D vitest (then run npx vitest).');
|
|
88
|
+
if (jest && !deps.jest && !deps['jest-expo'])
|
|
89
|
+
warnings.push(c.flavor === 'expo' ? 'No test runner found in package.json; the tests are written for Jest. Add it with: npx expo install jest-expo jest (then run npx jest).' : 'No test runner found in package.json; the tests are written for Jest. Add it with: npm i -D jest (then run npx jest).');
|
|
90
|
+
const axios = c.http === 'axios';
|
|
91
|
+
const inst = (c.axiosInstance || 'axios').replace(/^default:/, '');
|
|
92
|
+
const byFile = new Map();
|
|
93
|
+
for (const ep of eps)
|
|
94
|
+
byFile.set(ep.file, [...(byFile.get(ep.file) ?? []), ep]);
|
|
95
|
+
for (const [file, list] of byFile) {
|
|
96
|
+
const dir = posix.dirname(file);
|
|
97
|
+
const base = posix.basename(file).replace(/\.(tsx?|jsx?)$/, '');
|
|
98
|
+
const testFile = inTestsFolder ? `${dir}/__tests__/${base}.test.ts` : `${dir}/${base}.test.ts`;
|
|
99
|
+
let text = files.get(testFile) ?? readText(join(root, testFile)) ?? '';
|
|
100
|
+
const src = readText(join(root, file)) ?? '';
|
|
101
|
+
const helperText = c.httpHelperFile ? readText(join(root, c.httpHelperFile)) ?? files.get(c.httpHelperFile) ?? '' : '';
|
|
102
|
+
const jsonOnly = /\.json\(\)/.test(helperText) && !/\b204\b|content-length|no content/i.test(helperText);
|
|
103
|
+
const names = [];
|
|
104
|
+
const blocks = [];
|
|
105
|
+
for (const ep of list) {
|
|
106
|
+
if (new RegExp(`describe\\(\\s*['"\`]${ep.name}['"\`]`).test(text))
|
|
107
|
+
continue;
|
|
108
|
+
const decl = findDeclaration(src, ep.name, 'ts');
|
|
109
|
+
if (!decl) {
|
|
110
|
+
warnings.push(`Could not read ${ep.name} in ${file}; no test written for it.`);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
const args = splitParams(decl.params).map((p) => /^\s*(\w+)(\??)\s*:\s*([\s\S]+)$/.exec(p)).filter((m) => !!m);
|
|
114
|
+
const values = new Map();
|
|
115
|
+
const callArgs = [];
|
|
116
|
+
for (const [, name, optional, type] of args) {
|
|
117
|
+
if (optional)
|
|
118
|
+
continue;
|
|
119
|
+
const v = exampleArg(type.trim(), name, map);
|
|
120
|
+
values.set(name, v);
|
|
121
|
+
callArgs.push(tsLiteral(v, q));
|
|
122
|
+
}
|
|
123
|
+
const type = (ep.response ?? '').replace(/^Promise<(.*)>$/, '$1');
|
|
124
|
+
const voidish = !type || /^(void|unknown)$/.test(type) || ep.method === 'DELETE';
|
|
125
|
+
const value = voidish ? null : exampleFor(type, map);
|
|
126
|
+
const body = unwrapsData(src.slice(decl.start, decl.end), c) ? { data: value } : value;
|
|
127
|
+
const url = fill(ep.path, values);
|
|
128
|
+
const call = `${ep.name}(${callArgs.join(', ')})`;
|
|
129
|
+
// A helper that always reads JSON throws on 204 No Content: test with an empty JSON reply, and say so once.
|
|
130
|
+
const no204 = voidish && !axios && jsonOnly;
|
|
131
|
+
if (no204 && !warned204) {
|
|
132
|
+
warned204 = true;
|
|
133
|
+
warnings.push(`${c.httpHelperFile}: ${c.httpHelper}() always reads the body as JSON, so a 204 No Content reply (common for DELETE) will throw "Unexpected end of JSON input". The tests reply 200 with null; to handle 204, return undefined when res.status === 204 before res.json().`);
|
|
134
|
+
}
|
|
135
|
+
const L = [`describe(${q}${ep.name}${q}, () => {`];
|
|
136
|
+
if (axios) {
|
|
137
|
+
const verb = ep.method.toLowerCase();
|
|
138
|
+
L.push(` it(${q}calls ${ep.method} ${ep.path} through ${inst} and returns the data${q}, async () => {`, ` const body = ${tsLiteral(body, q)}${semi}`, ` const request = ${spy}.spyOn(${inst}, ${q}${verb}${q}).mockResolvedValue({ data: body } as never)${semi}`, ` const result = await ${call}${semi}`, ` expect(String(request.mock.calls[0][0])).toContain(${q}${url}${q})${semi}`, voidish ? ` expect(result).toBeUndefined()${semi}` : ` expect(result).toEqual(${unwrapsData(src.slice(decl.start, decl.end), c) ? 'body.data' : 'body'})${semi}`, ' })' + semi);
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
L.push(` it(${q}calls ${ep.method} ${ep.path} and returns the parsed body${q}, async () => {`, ` const body = ${tsLiteral(body, q)}${semi}`, ` const fetchMock = ${spy}.spyOn(globalThis, ${q}fetch${q}).mockResolvedValue(new Response(${voidish ? (no204 ? `${q}null${q}` : 'null') : 'JSON.stringify(body)'}, { status: ${voidish && !no204 ? 204 : 200}, headers: { ${q}Content-Type${q}: ${q}application/json${q} } }))${semi}`, ` const result = await ${call}${semi}`, ` const [url, init] = fetchMock.mock.calls[0]${semi}`, ` expect(String(url)).toContain(${q}${url}${q})${semi}`, ` expect(init?.method ?? ${q}GET${q}).toBe(${q}${ep.method}${q})${semi}`, voidish ? null : ` expect(result).toEqual(${unwrapsData(src.slice(decl.start, decl.end), c) ? 'body.data' : 'body'})${semi}`, ' })' + semi, '', ` it(${q}throws when the server answers with an error${q}, async () => {`, ` ${spy}.spyOn(globalThis, ${q}fetch${q}).mockResolvedValue(new Response(${q}nope${q}, { status: 500 }))${semi}`, ` await expect(${call}).rejects.toThrow()${semi}`, ' })' + semi);
|
|
142
|
+
}
|
|
143
|
+
L.push('})' + semi);
|
|
144
|
+
blocks.push(L.filter((l) => l !== null).join('\n'));
|
|
145
|
+
names.push(ep.name);
|
|
146
|
+
}
|
|
147
|
+
if (!names.length)
|
|
148
|
+
continue;
|
|
149
|
+
const own = inTestsFolder ? `../${base}` : `./${base}`;
|
|
150
|
+
if (!text) {
|
|
151
|
+
const header = [];
|
|
152
|
+
// Jest's globals are imported from @jest/globals (installed with Jest), so they are typed without @types/jest.
|
|
153
|
+
if (!jest || !deps['@types/jest'] || deps['@jest/globals'])
|
|
154
|
+
header.push(`import { afterEach, describe, expect, it, ${spy} } from ${q}${jest ? '@jest/globals' : 'vitest'}${q}${semi}`);
|
|
155
|
+
if (axios && inst !== 'axios' && c.axiosInstanceFile) {
|
|
156
|
+
const p = importPath(testFile, c.axiosInstanceFile, { ...c, importStyle: 'relative' });
|
|
157
|
+
header.push((c.axiosInstance ?? '').startsWith('default:') ? `import ${inst} from ${q}${p}${q}${semi}` : `import { ${inst} } from ${q}${p}${q}${semi}`);
|
|
158
|
+
}
|
|
159
|
+
else if (axios)
|
|
160
|
+
header.push(`import axios from ${q}axios${q}${semi}`);
|
|
161
|
+
header.push(`import { ${names.sort().join(', ')} } from ${q}${own}${q}${semi}`, '', `afterEach(() => {`, ` ${spy}.restoreAllMocks()${semi}`, `})${semi}`);
|
|
162
|
+
text = header.join('\n');
|
|
163
|
+
}
|
|
164
|
+
else {
|
|
165
|
+
// Add the tested functions to the import of the API file.
|
|
166
|
+
const m = new RegExp(`import \\{([^}]*)\\} from ['"]${own.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&')}['"];?`).exec(text);
|
|
167
|
+
if (m) {
|
|
168
|
+
const have = m[1].split(',').map((x) => x.trim()).filter(Boolean);
|
|
169
|
+
text = text.replace(m[0], `import { ${[...new Set([...have, ...names])].sort().join(', ')} } from ${q}${own}${q}${semi}`);
|
|
170
|
+
}
|
|
171
|
+
else
|
|
172
|
+
text = `import { ${names.sort().join(', ')} } from ${q}${own}${q}${semi}\n${text}`;
|
|
173
|
+
}
|
|
174
|
+
text = `${text.replace(/\s*$/, '\n')}\n${blocks.join('\n\n')}\n`;
|
|
175
|
+
files.set(testFile, text);
|
|
176
|
+
summary.push(`${readText(join(root, testFile)) === null ? 'Create' : 'Update'} ${testFile}: tests for ${names.join(', ')}`);
|
|
177
|
+
}
|
|
178
|
+
if (!files.size)
|
|
179
|
+
summary.push('Every chosen endpoint already has a test.');
|
|
180
|
+
else
|
|
181
|
+
summary.push(`Run them with: ${jest ? 'npx jest' : 'npx vitest run'}`);
|
|
182
|
+
return buildChangeSet(root, 'test add', { files, summary, warnings, generated: [] });
|
|
183
|
+
}
|
|
184
|
+
// ---------------- Flutter ----------------
|
|
185
|
+
function dartLiteral(v, indent = '') {
|
|
186
|
+
if (v === null || v === undefined)
|
|
187
|
+
return 'null';
|
|
188
|
+
if (typeof v === 'string')
|
|
189
|
+
return `'${v.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\$/g, '\\$')}'`;
|
|
190
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
191
|
+
return String(v);
|
|
192
|
+
const inner = `${indent} `;
|
|
193
|
+
if (Array.isArray(v))
|
|
194
|
+
return v.length ? `[\n${v.map((x) => `${inner}${dartLiteral(x, inner)},`).join('\n')}\n${indent}]` : '[]';
|
|
195
|
+
const entries = Object.entries(v);
|
|
196
|
+
return entries.length ? `{\n${entries.map(([k, x]) => `${inner}${dartLiteral(k)}: ${dartLiteral(x, inner)},`).join('\n')}\n${indent}}` : '<String, dynamic>{}';
|
|
197
|
+
}
|
|
198
|
+
const FAKE_ADAPTER = [
|
|
199
|
+
'/// Answers every request with [status] and [body], and remembers the request.',
|
|
200
|
+
'class _FakeAdapter implements HttpClientAdapter {',
|
|
201
|
+
' _FakeAdapter(this.status, this.body);',
|
|
202
|
+
'',
|
|
203
|
+
' final int status;',
|
|
204
|
+
' final Object? body;',
|
|
205
|
+
' RequestOptions? last;',
|
|
206
|
+
'',
|
|
207
|
+
' @override',
|
|
208
|
+
' Future<ResponseBody> fetch(RequestOptions options, Stream<Uint8List>? requestStream, Future<void>? cancelFuture) async {',
|
|
209
|
+
' last = options;',
|
|
210
|
+
' return ResponseBody.fromString(jsonEncode(body), status, headers: {',
|
|
211
|
+
' Headers.contentTypeHeader: [Headers.jsonContentType],',
|
|
212
|
+
' });',
|
|
213
|
+
' }',
|
|
214
|
+
'',
|
|
215
|
+
' @override',
|
|
216
|
+
' void close({bool force = false}) {}',
|
|
217
|
+
'}',
|
|
218
|
+
].join('\n');
|
|
219
|
+
function flutterTests(root, map, eps) {
|
|
220
|
+
const c = map.conventions;
|
|
221
|
+
const pkg = c.packageName;
|
|
222
|
+
const files = new Map();
|
|
223
|
+
const summary = [];
|
|
224
|
+
const warnings = [];
|
|
225
|
+
const pubspec = readText(join(root, 'pubspec.yaml')) ?? '';
|
|
226
|
+
if (!/^\s+flutter_test:/m.test(pubspec))
|
|
227
|
+
warnings.push('flutter_test is not in dev_dependencies; add it (it ships with the Flutter SDK): dev_dependencies: flutter_test: sdk: flutter');
|
|
228
|
+
const byFile = new Map();
|
|
229
|
+
for (const ep of eps)
|
|
230
|
+
byFile.set(ep.file, [...(byFile.get(ep.file) ?? []), ep]);
|
|
231
|
+
for (const [file, list] of byFile) {
|
|
232
|
+
const testFile = `test/${file.replace(/^lib\//, '').replace(/\.dart$/, '_test.dart')}`;
|
|
233
|
+
const existing = files.get(testFile) ?? readText(join(root, testFile));
|
|
234
|
+
const src = readText(join(root, file)) ?? '';
|
|
235
|
+
const groups = [];
|
|
236
|
+
const names = [];
|
|
237
|
+
const modelFiles = new Set();
|
|
238
|
+
for (const ep of list) {
|
|
239
|
+
const sig = readMethod(src, ep.name);
|
|
240
|
+
if (!sig?.repoClass) {
|
|
241
|
+
warnings.push(`Could not read ${ep.name} in ${file}; no test written for it.`);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (existing && new RegExp(`group\\(\\s*'${sig.repoClass}\\.${ep.name}'`).test(existing))
|
|
245
|
+
continue;
|
|
246
|
+
const ctorTakesDio = new RegExp(`\\b${sig.repoClass}\\s*\\(\\s*(?:this\\.\\w+|Dio\\s+\\w+)\\s*[,)]|factory\\s+${sig.repoClass}\\s*\\(\\s*Dio\\b`).test(src);
|
|
247
|
+
if (!ctorTakesDio) {
|
|
248
|
+
warnings.push(`${sig.repoClass} does not take its Dio in the constructor, so ${ep.name} cannot be tested with a fake adapter; no test written.`);
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
const values = new Map();
|
|
252
|
+
const args = [];
|
|
253
|
+
for (const part of splitParams(sig.params.replace(/@\w+(?:\([^)]*\))?\s*/g, ''))) {
|
|
254
|
+
if (part.startsWith('{') || part.startsWith('['))
|
|
255
|
+
continue; // optional query parameters
|
|
256
|
+
const m = /^([\w<>?, ]+?)\s+(\w+)$/.exec(part.trim());
|
|
257
|
+
if (!m)
|
|
258
|
+
continue;
|
|
259
|
+
const [type, name] = [m[1].trim(), m[2]];
|
|
260
|
+
if (name === 'body') {
|
|
261
|
+
const model = map.models.find((x) => x.name === type.replace(/\?$/, ''));
|
|
262
|
+
if (model) {
|
|
263
|
+
args.push(`${model.name}.fromJson(${dartLiteral(exampleFor(model.name, map), ' ')})`);
|
|
264
|
+
if (model.file !== file)
|
|
265
|
+
modelFiles.add(model.file);
|
|
266
|
+
}
|
|
267
|
+
else
|
|
268
|
+
args.push(dartLiteral(exampleFor(type, map, name)));
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const v = /^int\??$/.test(type) ? 1 : /^double\??$/.test(type) ? 1.5 : 'abc';
|
|
272
|
+
values.set(name, v);
|
|
273
|
+
args.push(dartLiteral(v));
|
|
274
|
+
}
|
|
275
|
+
const type = sig.returnType;
|
|
276
|
+
const voidish = /^void$/.test(type) || ep.method === 'DELETE';
|
|
277
|
+
const value = voidish ? null : exampleFor(type, map);
|
|
278
|
+
const decl = findDeclaration(src, ep.name, 'dart');
|
|
279
|
+
const body = decl && unwrapsData(src.slice(decl.start, decl.end), c) ? { data: value } : value;
|
|
280
|
+
const list2 = /^List<(.+)>$/.exec(type);
|
|
281
|
+
const model = map.models.find((m) => m.name === (list2?.[1] ?? type).replace(/\?$/, ''));
|
|
282
|
+
// Imported only when the test names it (isA<Model>); a list is checked by its length.
|
|
283
|
+
if (model && model.file !== file && !list2 && !voidish)
|
|
284
|
+
modelFiles.add(model.file);
|
|
285
|
+
const expectResult = voidish ? null : list2 ? ` expect(result, hasLength(${Array.isArray(value) ? value.length : 1}));` : model ? ` expect(result, isA<${model.name}>());` : ' expect(result, isNotNull);';
|
|
286
|
+
const call = `${sig.repoClass}(dio).${ep.name}(${args.join(', ')})`;
|
|
287
|
+
groups.push([
|
|
288
|
+
` group('${sig.repoClass}.${ep.name}', () {`,
|
|
289
|
+
` test('calls ${ep.method} ${ep.path} and parses the response', () async {`,
|
|
290
|
+
` final adapter = _FakeAdapter(${voidish ? 204 : 200}, ${dartLiteral(body, ' ')});`,
|
|
291
|
+
" final dio = Dio(BaseOptions(baseUrl: 'https://api.test'))..httpClientAdapter = adapter;",
|
|
292
|
+
` ${voidish ? '' : 'final result = '}await ${call};`,
|
|
293
|
+
` expect(adapter.last!.method, '${ep.method}');`,
|
|
294
|
+
` expect(adapter.last!.uri.path, endsWith('${fill(ep.path, values).replace(/^https?:\/\/[^/]+/, '')}'));`,
|
|
295
|
+
expectResult,
|
|
296
|
+
' });',
|
|
297
|
+
'',
|
|
298
|
+
" test('throws when the server fails', () async {",
|
|
299
|
+
" final dio = Dio(BaseOptions(baseUrl: 'https://api.test'))..httpClientAdapter = _FakeAdapter(500, {'error': 'nope'});",
|
|
300
|
+
` await expectLater(${call}, throwsA(isA<DioException>()));`,
|
|
301
|
+
' });',
|
|
302
|
+
' });',
|
|
303
|
+
]
|
|
304
|
+
.filter((l) => l !== null)
|
|
305
|
+
.join('\n'));
|
|
306
|
+
names.push(ep.name);
|
|
307
|
+
}
|
|
308
|
+
if (!names.length)
|
|
309
|
+
continue;
|
|
310
|
+
let text;
|
|
311
|
+
const uri = (f) => (pkg ? `package:${pkg}/${f.replace(/^lib\//, '')}` : posix.relative(posix.dirname(testFile), f));
|
|
312
|
+
if (existing) {
|
|
313
|
+
const close = existing.lastIndexOf('}');
|
|
314
|
+
text = `${existing.slice(0, close).replace(/\s*$/, '\n')}\n${groups.join('\n\n')}\n}\n`;
|
|
315
|
+
for (const f of modelFiles)
|
|
316
|
+
if (!existing.includes(uri(f)))
|
|
317
|
+
text = text.replace(/(import 'package:flutter_test\/flutter_test.dart';\n)/, `$1import '${uri(f)}';\n`);
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
const imports = ["import 'dart:convert';", "import 'dart:typed_data';", '', "import 'package:dio/dio.dart';", "import 'package:flutter_test/flutter_test.dart';", `import '${uri(file)}';`, ...[...modelFiles].sort().map((f) => `import '${uri(f)}';`)];
|
|
321
|
+
text = [...imports, '', FAKE_ADAPTER, '', 'void main() {', groups.join('\n\n'), '}', ''].join('\n');
|
|
322
|
+
}
|
|
323
|
+
files.set(testFile, text);
|
|
324
|
+
summary.push(`${existing ? 'Update' : 'Create'} ${testFile}: tests for ${names.join(', ')}`);
|
|
325
|
+
}
|
|
326
|
+
if (!files.size)
|
|
327
|
+
summary.push('Every chosen endpoint already has a test.');
|
|
328
|
+
else
|
|
329
|
+
summary.push('Run them with: flutter test');
|
|
330
|
+
return buildChangeSet(root, 'test add', { files, summary, warnings, generated: [] });
|
|
331
|
+
}
|