@icyouo/evt-cli 0.1.0 → 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/README.md +76 -7
- package/README.zh-CN.md +66 -7
- package/data/scanner.example.json +2 -5
- package/package.json +7 -3
- package/scripts/build-package.js +173 -0
- package/scripts/check-api-audit.js +260 -0
- package/scripts/check-api-coverage.js +41 -0
- package/scripts/local-ci.js +38 -0
- package/scripts/sync-api-coverage.js +227 -0
- package/skills/evt-api-scanner/SKILL.md +147 -0
- package/skills/evt-api-scanner/scripts/discover.js +160 -0
- package/skills/evt-api-scanner/scripts/scan.js +231 -0
- package/skills/evt-flow-generator/SKILL.md +139 -0
- package/skills/evt-profile-generator/SKILL.md +122 -0
- package/src/config/serviceScanner.js +60 -10
- package/src/index.js +45 -1
- package/src/util/args.js +3 -0
- package/test/apiAudit.test.js +137 -0
- package/test/duration.test.js +11 -0
- package/test/flow.test.js +215 -0
- package/test/liveTester.test.js +54 -0
- package/test/requestBuilder.test.js +214 -0
- package/test/serviceScanner.test.js +196 -0
- package/test/template.test.js +18 -0
- package/test/yaml.test.js +20 -0
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: evt-profile-generator
|
|
3
|
+
description: Use when an agent needs to create or update evt-cli profile JSON files from project source, environment config, API YAML schemas, documentation, or user-provided credentials so evt-cli can run real APIs and flows.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# evt-profile-generator
|
|
7
|
+
|
|
8
|
+
Use this skill to produce usable evt-cli profiles under `data/profiles/*.json`
|
|
9
|
+
or legacy `profiles/*.json`.
|
|
10
|
+
|
|
11
|
+
## Workflow
|
|
12
|
+
|
|
13
|
+
1. Locate the evt config root.
|
|
14
|
+
- Prefer the user's explicit `--config-root`.
|
|
15
|
+
- Otherwise use `EVT_CLI_ROOT`.
|
|
16
|
+
- Otherwise use `./cli` from the project root.
|
|
17
|
+
2. Read existing profile examples:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
evt profile list --config-root ./cli
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
3. Inspect project source, docs, and environment files for:
|
|
24
|
+
- base URLs by environment
|
|
25
|
+
- static headers, app version, device/platform headers, tenant headers
|
|
26
|
+
- auth header name and token scheme
|
|
27
|
+
- login inputs and test fixtures required by endpoint schemas
|
|
28
|
+
4. Inspect the project's HTTP runtime before writing headers:
|
|
29
|
+
- environment config and app config
|
|
30
|
+
- DI modules or client factories
|
|
31
|
+
- header builders and interceptors
|
|
32
|
+
- auth/session/cache code
|
|
33
|
+
- platform/device providers
|
|
34
|
+
5. Write real profiles such as `data/profiles/dev.json`,
|
|
35
|
+
`data/profiles/android.dev.json`, or `data/profiles/ios.dev.json`.
|
|
36
|
+
6. Validate:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
evt validate --config-root ./cli
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Profile Format
|
|
43
|
+
|
|
44
|
+
Use valid JSON only. Do not add comments.
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"name": "dev",
|
|
49
|
+
"env": "dev",
|
|
50
|
+
"platform": "ANDROID",
|
|
51
|
+
"baseUrls": {
|
|
52
|
+
"default": "https://api.dev.example.com"
|
|
53
|
+
},
|
|
54
|
+
"auth": {
|
|
55
|
+
"header": "Authorization",
|
|
56
|
+
"scheme": "bearer"
|
|
57
|
+
},
|
|
58
|
+
"headers": {
|
|
59
|
+
"Accept": "application/json",
|
|
60
|
+
"Content-Type": "application/json",
|
|
61
|
+
"Platform": "{{profile.platform}}"
|
|
62
|
+
},
|
|
63
|
+
"device": {
|
|
64
|
+
"fingerprintId": "dev-fingerprint"
|
|
65
|
+
},
|
|
66
|
+
"inputs": {
|
|
67
|
+
"email": "user@example.com",
|
|
68
|
+
"password": "Password123."
|
|
69
|
+
},
|
|
70
|
+
"fixtures": {
|
|
71
|
+
"defaults": {
|
|
72
|
+
"page": 1,
|
|
73
|
+
"pageSize": 20
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
## Rules
|
|
80
|
+
|
|
81
|
+
- Keep bundled `*.example.json` generic; write project-specific data to
|
|
82
|
+
non-example profile files.
|
|
83
|
+
- Do not invent production secrets. Use user-provided test credentials,
|
|
84
|
+
documented test values, or safe placeholders.
|
|
85
|
+
- Prefer separate profiles when platforms have different headers, device
|
|
86
|
+
fields, or base URLs.
|
|
87
|
+
- Use exact enum casing and header names from source. If source uses `ANDROID`,
|
|
88
|
+
do not write `android`.
|
|
89
|
+
- Include backing fields for every templated header value, such as
|
|
90
|
+
`{{profile.device.fingerprintId}}`.
|
|
91
|
+
- Only include headers sent by the same runtime client. Do not merge unrelated
|
|
92
|
+
web, server, or third-party headers just because they appear elsewhere in the
|
|
93
|
+
repository.
|
|
94
|
+
- Keep static app headers and device headers in the profile; keep dynamic
|
|
95
|
+
per-request headers in endpoint YAML or flow steps.
|
|
96
|
+
- Put values needed by interactive flows in `inputs`.
|
|
97
|
+
- Put reusable endpoint test values in `fixtures.defaults` or grouped fixture
|
|
98
|
+
objects named after the endpoint domain.
|
|
99
|
+
- Configure token injection with `auth.header` and `auth.scheme`; token values
|
|
100
|
+
themselves belong in evt cache after login, not in the profile.
|
|
101
|
+
- If multiple API hosts exist, add named entries in `baseUrls` and set matching
|
|
102
|
+
endpoint metadata only when the API YAML supports it.
|
|
103
|
+
- Make `baseUrls` names match endpoint `service` values exactly.
|
|
104
|
+
- If auth uses a raw token header, set `"scheme": "raw"`. If it uses an
|
|
105
|
+
Authorization bearer token, set `"scheme": "bearer"`.
|
|
106
|
+
- Prefer values from source or docs over guesses. If a required runtime value is
|
|
107
|
+
unknown, leave a realistic placeholder and make the related flow input
|
|
108
|
+
interactive.
|
|
109
|
+
|
|
110
|
+
## Completion Check
|
|
111
|
+
|
|
112
|
+
After writing the profile, run:
|
|
113
|
+
|
|
114
|
+
```bash
|
|
115
|
+
evt validate --config-root ./cli
|
|
116
|
+
evt profile list --config-root ./cli
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
If endpoint YAML and flows already exist, also dry-run login with the generated
|
|
120
|
+
profile and compare the produced request headers with the source header builder.
|
|
121
|
+
Report placeholder credentials, uncertain headers, and base URLs that were not
|
|
122
|
+
found in source.
|
|
@@ -97,6 +97,7 @@ function normalizeConfig(config, baseDir = repoRoot) {
|
|
|
97
97
|
return {
|
|
98
98
|
...config,
|
|
99
99
|
root,
|
|
100
|
+
apiDir: config.apiDir ? path.resolve(baseDir, config.apiDir) : undefined,
|
|
100
101
|
targets: (config.targets || []).map((target) => ({ ...target }))
|
|
101
102
|
};
|
|
102
103
|
}
|
|
@@ -375,37 +376,85 @@ function normalizeSkillEndpoint(rawEndpoint, target, config) {
|
|
|
375
376
|
}
|
|
376
377
|
|
|
377
378
|
function scanSkillTarget(config, target) {
|
|
378
|
-
|
|
379
|
+
const resolvedTarget = resolveSkillTarget(target);
|
|
380
|
+
if (!resolvedTarget.command) {
|
|
379
381
|
throw new Error("Skill scanner target must define command");
|
|
380
382
|
}
|
|
381
|
-
const cwd =
|
|
382
|
-
const result = spawnSync(
|
|
383
|
+
const cwd = resolvedTarget.cwd ? path.resolve(config.root, resolvedTarget.cwd) : config.root;
|
|
384
|
+
const result = spawnSync(resolvedTarget.command, (resolvedTarget.args || []).map(String), {
|
|
383
385
|
cwd,
|
|
384
386
|
encoding: "utf8",
|
|
385
387
|
shell: false,
|
|
386
388
|
env: {
|
|
387
389
|
...process.env,
|
|
388
|
-
|
|
390
|
+
EVT_SCAN_ROOT: config.root,
|
|
391
|
+
...(resolvedTarget.env || {})
|
|
389
392
|
},
|
|
390
|
-
maxBuffer:
|
|
393
|
+
maxBuffer: resolvedTarget.maxBuffer || 10 * 1024 * 1024
|
|
391
394
|
});
|
|
392
395
|
|
|
393
396
|
if (result.error) {
|
|
394
|
-
throw new Error(`Skill scanner ${
|
|
397
|
+
throw new Error(`Skill scanner ${resolvedTarget.name || resolvedTarget.command} failed: ${result.error.message}`);
|
|
395
398
|
}
|
|
396
399
|
if (result.status !== 0) {
|
|
397
400
|
const stderr = String(result.stderr || "").trim();
|
|
398
|
-
throw new Error(`Skill scanner ${
|
|
401
|
+
throw new Error(`Skill scanner ${resolvedTarget.name || resolvedTarget.command} exited with ${result.status}${stderr ? `: ${stderr}` : ""}`);
|
|
399
402
|
}
|
|
400
403
|
|
|
401
|
-
return parseSkillOutput(result.stdout,
|
|
402
|
-
.map((endpoint) => normalizeSkillEndpoint(endpoint,
|
|
404
|
+
return parseSkillOutput(result.stdout, resolvedTarget)
|
|
405
|
+
.map((endpoint) => normalizeSkillEndpoint(endpoint, resolvedTarget, config));
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
function resolveSkillTarget(target) {
|
|
409
|
+
if (!target.skill) return target;
|
|
410
|
+
if (target.skill !== "evt-api-scanner") return target;
|
|
411
|
+
return {
|
|
412
|
+
...target,
|
|
413
|
+
name: target.name || "evt-api-scanner",
|
|
414
|
+
command: process.execPath,
|
|
415
|
+
args: target.args || [
|
|
416
|
+
path.join(cliRoot, "skills", "evt-api-scanner", "scripts", "scan.js"),
|
|
417
|
+
"--root",
|
|
418
|
+
"."
|
|
419
|
+
]
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function splitTargets(targets) {
|
|
424
|
+
const skillTargets = [];
|
|
425
|
+
const fallbackTargets = [];
|
|
426
|
+
for (const target of targets || []) {
|
|
427
|
+
if (isSkillTarget(target)) {
|
|
428
|
+
skillTargets.push(target);
|
|
429
|
+
} else {
|
|
430
|
+
fallbackTargets.push(target);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return { skillTargets, fallbackTargets };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function scanTargets(config, targets) {
|
|
437
|
+
return targets.flatMap((target) => scanTarget(config, target));
|
|
403
438
|
}
|
|
404
439
|
|
|
405
440
|
function scanServices(options = {}) {
|
|
406
441
|
const config = loadScanConfig(options);
|
|
407
442
|
if (!config.targets || config.targets.length === 0) return [];
|
|
408
|
-
|
|
443
|
+
if (options.preferFallback) {
|
|
444
|
+
return scanTargets(config, config.targets.filter((target) => !isSkillTarget(target)));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
const { skillTargets, fallbackTargets } = splitTargets(config.targets);
|
|
448
|
+
if (skillTargets.length === 0) {
|
|
449
|
+
return scanTargets(config, fallbackTargets);
|
|
450
|
+
}
|
|
451
|
+
try {
|
|
452
|
+
const skillEndpoints = scanTargets(config, skillTargets);
|
|
453
|
+
if (skillEndpoints.length > 0 || fallbackTargets.length === 0) return skillEndpoints;
|
|
454
|
+
} catch (error) {
|
|
455
|
+
if (fallbackTargets.length === 0 || options.strictSkill) throw error;
|
|
456
|
+
}
|
|
457
|
+
return scanTargets(config, fallbackTargets);
|
|
409
458
|
}
|
|
410
459
|
|
|
411
460
|
function compareScanToRegistry(scanned, registry) {
|
|
@@ -422,5 +471,6 @@ module.exports = {
|
|
|
422
471
|
loadScanConfig,
|
|
423
472
|
defaultScanConfig,
|
|
424
473
|
parseCliScanOptions,
|
|
474
|
+
resolveSkillTarget,
|
|
425
475
|
repoRoot
|
|
426
476
|
};
|
package/src/index.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
+
const path = require("node:path");
|
|
2
|
+
const { spawnSync } = require("node:child_process");
|
|
1
3
|
const { parseArgs } = require("./util/args");
|
|
2
4
|
const { parseJsonObject, stableJson } = require("./util/json");
|
|
3
5
|
const { redact } = require("./util/redact");
|
|
4
|
-
const { setConfigRoot } = require("./util/paths");
|
|
6
|
+
const { cliRoot, setConfigRoot } = require("./util/paths");
|
|
5
7
|
const {
|
|
6
8
|
listApiFiles,
|
|
7
9
|
listProfiles,
|
|
@@ -16,6 +18,9 @@ const { runFlow } = require("./flow/runner");
|
|
|
16
18
|
const { toCurl } = require("./http/requestBuilder");
|
|
17
19
|
const { compareScanToRegistry, scanServices } = require("./config/serviceScanner");
|
|
18
20
|
const { runLiveApiTest } = require("./api/liveTester");
|
|
21
|
+
const { runSyncApiCoverage } = require("../scripts/sync-api-coverage");
|
|
22
|
+
const { runApiCoverage } = require("../scripts/check-api-coverage");
|
|
23
|
+
const { runApiAudit } = require("../scripts/check-api-audit");
|
|
19
24
|
|
|
20
25
|
function print(value, json = false) {
|
|
21
26
|
if (json || typeof value !== "string") {
|
|
@@ -32,7 +37,11 @@ function usage() {
|
|
|
32
37
|
" evt profile show <name>",
|
|
33
38
|
" evt api list",
|
|
34
39
|
" evt api show <id>",
|
|
40
|
+
" evt api discover [--config-root ./cli]",
|
|
35
41
|
" evt api scan [--missing] [--scan-config path/to/scanner.json]",
|
|
42
|
+
" evt api sync [--config-root ./cli]",
|
|
43
|
+
" evt api coverage [--config-root ./cli]",
|
|
44
|
+
" evt api audit [--config-root ./cli] [--strict]",
|
|
36
45
|
" evt api call <id> [--profile local] [--set k=v] [--body '{...}'] [--dry-run]",
|
|
37
46
|
" evt api test-all [--profile local] [--include-dangerous] [--only namespace]",
|
|
38
47
|
" evt validate",
|
|
@@ -42,6 +51,17 @@ function usage() {
|
|
|
42
51
|
].join("\n");
|
|
43
52
|
}
|
|
44
53
|
|
|
54
|
+
function runNodeScript(relativeScript, args) {
|
|
55
|
+
const result = spawnSync(process.execPath, [path.join(cliRoot, relativeScript), ...args], {
|
|
56
|
+
cwd: process.cwd(),
|
|
57
|
+
stdio: "inherit",
|
|
58
|
+
shell: false
|
|
59
|
+
});
|
|
60
|
+
if (result.status !== 0) {
|
|
61
|
+
throw new Error(`${relativeScript} failed with exit code ${result.status || 1}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
function commonRuntime(options) {
|
|
46
66
|
const profile = loadProfile(options.profile || "local");
|
|
47
67
|
const cachePath = resolveCachePath(options.cache);
|
|
@@ -73,6 +93,30 @@ async function handleApi(tokens) {
|
|
|
73
93
|
const sub = tokens[0];
|
|
74
94
|
const options = parseArgs(tokens.slice(1));
|
|
75
95
|
setConfigRoot(options.configRoot);
|
|
96
|
+
|
|
97
|
+
if (sub === "discover") {
|
|
98
|
+
const args = tokens.slice(1);
|
|
99
|
+
if (!options.configRoot && !args.includes("--config-root")) {
|
|
100
|
+
args.push("--config-root", "./cli");
|
|
101
|
+
}
|
|
102
|
+
runNodeScript("skills/evt-api-scanner/scripts/discover.js", args);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (sub === "sync") {
|
|
106
|
+
runSyncApiCoverage(tokens.slice(1));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (sub === "coverage") {
|
|
110
|
+
const result = runApiCoverage(tokens.slice(1));
|
|
111
|
+
if (result.failed) throw new Error("API coverage failed");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
if (sub === "audit") {
|
|
115
|
+
const result = runApiAudit(tokens.slice(1));
|
|
116
|
+
if (result.failed) throw new Error("API audit failed");
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
76
120
|
const registry = loadApiRegistry();
|
|
77
121
|
|
|
78
122
|
if (sub === "list") {
|
package/src/util/args.js
CHANGED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const fs = require("node:fs");
|
|
4
|
+
const os = require("node:os");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { spawnSync } = require("node:child_process");
|
|
7
|
+
const { runApiAudit } = require("../scripts/check-api-audit");
|
|
8
|
+
|
|
9
|
+
function writeFile(root, relative, content) {
|
|
10
|
+
const file = path.join(root, relative);
|
|
11
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
12
|
+
fs.writeFileSync(file, content);
|
|
13
|
+
return file;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fixtureRoot() {
|
|
17
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), "evt-api-audit-"));
|
|
18
|
+
writeFile(root, "src/AuthService.kt", `
|
|
19
|
+
class AuthService(val http: Http) {
|
|
20
|
+
suspend fun login(): Resp<LoginData> {
|
|
21
|
+
return http.post("/api/login")
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
`);
|
|
25
|
+
writeFile(root, "data/scanner.json", JSON.stringify({
|
|
26
|
+
root,
|
|
27
|
+
targets: [
|
|
28
|
+
{
|
|
29
|
+
language: "kotlin",
|
|
30
|
+
paths: ["src"],
|
|
31
|
+
namespaceStripSuffixes: ["Service"]
|
|
32
|
+
}
|
|
33
|
+
]
|
|
34
|
+
}, null, 2));
|
|
35
|
+
return root;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test("api audit flags scanner skeletons in strict mode", () => {
|
|
39
|
+
const root = fixtureRoot();
|
|
40
|
+
writeFile(root, "data/apis/auth.yaml", `
|
|
41
|
+
namespace: auth
|
|
42
|
+
|
|
43
|
+
endpoints:
|
|
44
|
+
login:
|
|
45
|
+
method: "POST"
|
|
46
|
+
path: "/api/login"
|
|
47
|
+
auth: false
|
|
48
|
+
schema: {}
|
|
49
|
+
response:
|
|
50
|
+
data:
|
|
51
|
+
type: "object"
|
|
52
|
+
`);
|
|
53
|
+
|
|
54
|
+
const result = runApiAudit(["--config-root", root, "--strict"], { silent: true });
|
|
55
|
+
|
|
56
|
+
assert.equal(result.failed, true);
|
|
57
|
+
assert.equal(result.payload.counts.emptySchema, 1);
|
|
58
|
+
assert.equal(result.payload.counts.genericResponse, 1);
|
|
59
|
+
assert.match(result.payload.failures.join("\\n"), /empty schema ratio/);
|
|
60
|
+
assert.match(result.payload.failures.join("\\n"), /generic response ratio/);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("api audit passes enriched endpoint definitions", () => {
|
|
64
|
+
const root = fixtureRoot();
|
|
65
|
+
writeFile(root, "data/apis/auth.yaml", `
|
|
66
|
+
namespace: auth
|
|
67
|
+
|
|
68
|
+
endpoints:
|
|
69
|
+
login:
|
|
70
|
+
method: "POST"
|
|
71
|
+
path: "/api/login"
|
|
72
|
+
auth: false
|
|
73
|
+
schema:
|
|
74
|
+
body:
|
|
75
|
+
email:
|
|
76
|
+
type: "string"
|
|
77
|
+
required: true
|
|
78
|
+
password:
|
|
79
|
+
type: "string"
|
|
80
|
+
required: true
|
|
81
|
+
response:
|
|
82
|
+
envelope: "Resp"
|
|
83
|
+
data:
|
|
84
|
+
type: "object"
|
|
85
|
+
model: "LoginData"
|
|
86
|
+
fields:
|
|
87
|
+
token: "string"
|
|
88
|
+
`);
|
|
89
|
+
|
|
90
|
+
const result = runApiAudit(["--config-root", root, "--strict"], { silent: true });
|
|
91
|
+
|
|
92
|
+
assert.equal(result.failed, false);
|
|
93
|
+
assert.equal(result.payload.counts.emptySchema, 0);
|
|
94
|
+
assert.equal(result.payload.counts.genericResponse, 0);
|
|
95
|
+
assert.deepEqual(result.payload.failures, []);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("cli api audit accepts strict as a boolean flag", () => {
|
|
99
|
+
const root = fixtureRoot();
|
|
100
|
+
writeFile(root, "data/apis/auth.yaml", `
|
|
101
|
+
namespace: auth
|
|
102
|
+
|
|
103
|
+
endpoints:
|
|
104
|
+
login:
|
|
105
|
+
method: "POST"
|
|
106
|
+
path: "/api/login"
|
|
107
|
+
auth: false
|
|
108
|
+
schema:
|
|
109
|
+
body:
|
|
110
|
+
email:
|
|
111
|
+
type: "string"
|
|
112
|
+
required: true
|
|
113
|
+
response:
|
|
114
|
+
envelope: "Resp"
|
|
115
|
+
data:
|
|
116
|
+
type: "object"
|
|
117
|
+
model: "LoginData"
|
|
118
|
+
fields:
|
|
119
|
+
token: "string"
|
|
120
|
+
`);
|
|
121
|
+
|
|
122
|
+
const result = spawnSync(process.execPath, [
|
|
123
|
+
path.join(__dirname, "..", "bin", "evt.js"),
|
|
124
|
+
"api",
|
|
125
|
+
"audit",
|
|
126
|
+
"--config-root",
|
|
127
|
+
root,
|
|
128
|
+
"--strict"
|
|
129
|
+
], {
|
|
130
|
+
encoding: "utf8"
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
assert.equal(result.status, 0, result.stderr || result.stdout);
|
|
134
|
+
const payload = JSON.parse(result.stdout);
|
|
135
|
+
assert.equal(payload.ok, true);
|
|
136
|
+
assert.equal(payload.strict, true);
|
|
137
|
+
});
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { parseDuration } = require("../src/util/duration");
|
|
4
|
+
|
|
5
|
+
test("parses duration values", () => {
|
|
6
|
+
assert.equal(parseDuration(1000), 1000);
|
|
7
|
+
assert.equal(parseDuration("1000"), 1000);
|
|
8
|
+
assert.equal(parseDuration("500ms"), 500);
|
|
9
|
+
assert.equal(parseDuration("2s"), 2000);
|
|
10
|
+
assert.equal(parseDuration("1m"), 60000);
|
|
11
|
+
});
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
const test = require("node:test");
|
|
2
|
+
const assert = require("node:assert/strict");
|
|
3
|
+
const { runFlow } = require("../src/flow/runner");
|
|
4
|
+
const { loadApiRegistry, loadProfile } = require("../src/config/loaders");
|
|
5
|
+
|
|
6
|
+
test("runs login flow in dry-run mode without prompt", async () => {
|
|
7
|
+
const result = await runFlow({
|
|
8
|
+
name: "login",
|
|
9
|
+
inputs: {
|
|
10
|
+
email: { required: true },
|
|
11
|
+
password: { required: true },
|
|
12
|
+
code: { default: "" },
|
|
13
|
+
googleCode: { default: "" }
|
|
14
|
+
},
|
|
15
|
+
steps: [
|
|
16
|
+
{
|
|
17
|
+
id: "login",
|
|
18
|
+
call: "auth.login",
|
|
19
|
+
body: {
|
|
20
|
+
email: "{{inputs.email}}",
|
|
21
|
+
password: "{{inputs.password}}"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}, {
|
|
26
|
+
registry: loadApiRegistry(),
|
|
27
|
+
profile: loadProfile("local"),
|
|
28
|
+
cache: {},
|
|
29
|
+
cachePath: "/tmp/evt-cli-test-cache.json",
|
|
30
|
+
set: {
|
|
31
|
+
email: "user@example.com",
|
|
32
|
+
password: "secret"
|
|
33
|
+
},
|
|
34
|
+
dryRun: true,
|
|
35
|
+
noInteractive: true
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
assert.equal(result.flow, "login");
|
|
39
|
+
assert.equal(result.timeline.filter((item) => item.call).length, 1);
|
|
40
|
+
assert.equal(result.steps.login.request.method, "POST");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("fails when required cache save value is missing", async () => {
|
|
44
|
+
const cachePath = "/tmp/evt-cli-required-cache-test.json";
|
|
45
|
+
const originalFetch = global.fetch;
|
|
46
|
+
global.fetch = async () => ({
|
|
47
|
+
ok: true,
|
|
48
|
+
status: 200,
|
|
49
|
+
text: async () => JSON.stringify({ code: 0, data: { info: { email: "user@example.com" } } })
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await assert.rejects(
|
|
54
|
+
runFlow({
|
|
55
|
+
name: "save-required",
|
|
56
|
+
steps: [
|
|
57
|
+
{
|
|
58
|
+
id: "login",
|
|
59
|
+
call: "auth.login",
|
|
60
|
+
body: {
|
|
61
|
+
email: "user@example.com",
|
|
62
|
+
password: "secret"
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
save: {
|
|
67
|
+
cache: {
|
|
68
|
+
token: "{{steps.login.response.data.token}}"
|
|
69
|
+
},
|
|
70
|
+
required: ["cache.token"]
|
|
71
|
+
}
|
|
72
|
+
}, {
|
|
73
|
+
registry: loadApiRegistry(),
|
|
74
|
+
profile: loadProfile("local"),
|
|
75
|
+
cache: {},
|
|
76
|
+
cachePath,
|
|
77
|
+
set: {},
|
|
78
|
+
dryRun: false,
|
|
79
|
+
noInteractive: true
|
|
80
|
+
}),
|
|
81
|
+
/required save value: cache\.token/
|
|
82
|
+
);
|
|
83
|
+
} finally {
|
|
84
|
+
global.fetch = originalFetch;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test("supports step expect and extract variables", async () => {
|
|
89
|
+
const originalFetch = global.fetch;
|
|
90
|
+
global.fetch = async () => ({
|
|
91
|
+
ok: true,
|
|
92
|
+
status: 200,
|
|
93
|
+
text: async () => JSON.stringify({ code: 0, data: { token: "token-value" } })
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
const result = await runFlow({
|
|
98
|
+
name: "expect-extract",
|
|
99
|
+
steps: [
|
|
100
|
+
{
|
|
101
|
+
id: "login",
|
|
102
|
+
call: "auth.login",
|
|
103
|
+
body: {
|
|
104
|
+
email: "user@example.com",
|
|
105
|
+
password: "secret"
|
|
106
|
+
},
|
|
107
|
+
expect: [
|
|
108
|
+
{ path: "response.code", equals: 0 },
|
|
109
|
+
{ path: "response.data.token", exists: true }
|
|
110
|
+
],
|
|
111
|
+
extract: {
|
|
112
|
+
token: "response.data.token"
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
]
|
|
116
|
+
}, {
|
|
117
|
+
registry: loadApiRegistry(),
|
|
118
|
+
profile: loadProfile("local"),
|
|
119
|
+
cache: {},
|
|
120
|
+
cachePath: "/tmp/evt-cli-extract-cache-test.json",
|
|
121
|
+
set: {},
|
|
122
|
+
dryRun: false,
|
|
123
|
+
noInteractive: true
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
assert.deepEqual(result.vars, ["token"]);
|
|
127
|
+
} finally {
|
|
128
|
+
global.fetch = originalFetch;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("updates flow cache for later authenticated steps", async () => {
|
|
133
|
+
const originalFetch = global.fetch;
|
|
134
|
+
const seen = [];
|
|
135
|
+
global.fetch = async (url, options) => {
|
|
136
|
+
seen.push({ url, options });
|
|
137
|
+
if (seen.length === 1) {
|
|
138
|
+
return {
|
|
139
|
+
ok: true,
|
|
140
|
+
status: 200,
|
|
141
|
+
text: async () => JSON.stringify({ code: 0, data: { token: "token-value" } })
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
ok: true,
|
|
146
|
+
status: 200,
|
|
147
|
+
text: async () => JSON.stringify({ code: 0, data: { email: "user@example.com" } })
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await runFlow({
|
|
153
|
+
name: "flow-cache",
|
|
154
|
+
steps: [
|
|
155
|
+
{
|
|
156
|
+
id: "login",
|
|
157
|
+
call: "auth.login",
|
|
158
|
+
body: {
|
|
159
|
+
email: "user@example.com",
|
|
160
|
+
password: "secret"
|
|
161
|
+
},
|
|
162
|
+
extract: {
|
|
163
|
+
token: "response.data.token"
|
|
164
|
+
},
|
|
165
|
+
cache: {
|
|
166
|
+
token: "{{vars.token}}"
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
{
|
|
170
|
+
id: "user",
|
|
171
|
+
call: "todo.list"
|
|
172
|
+
}
|
|
173
|
+
]
|
|
174
|
+
}, {
|
|
175
|
+
registry: loadApiRegistry(),
|
|
176
|
+
profile: loadProfile("local"),
|
|
177
|
+
cache: {},
|
|
178
|
+
cachePath: "/tmp/evt-cli-flow-cache-test.json",
|
|
179
|
+
set: {},
|
|
180
|
+
dryRun: false,
|
|
181
|
+
noInteractive: true
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
assert.equal(seen[1].options.headers.Authorization, "Bearer token-value");
|
|
185
|
+
} finally {
|
|
186
|
+
global.fetch = originalFetch;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("skips response expectations during dry-run", async () => {
|
|
191
|
+
const result = await runFlow({
|
|
192
|
+
name: "dry-run-expect",
|
|
193
|
+
steps: [
|
|
194
|
+
{
|
|
195
|
+
id: "oauth",
|
|
196
|
+
call: "auth.login",
|
|
197
|
+
expect: { path: "response.code", equals: 0 },
|
|
198
|
+
extract: {
|
|
199
|
+
token: "response.data.token"
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
]
|
|
203
|
+
}, {
|
|
204
|
+
registry: loadApiRegistry(),
|
|
205
|
+
profile: loadProfile("local"),
|
|
206
|
+
cache: {},
|
|
207
|
+
cachePath: "/tmp/evt-cli-dry-run-expect-cache-test.json",
|
|
208
|
+
set: {},
|
|
209
|
+
dryRun: true,
|
|
210
|
+
noInteractive: true
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
assert.equal(result.flow, "dry-run-expect");
|
|
214
|
+
assert.deepEqual(result.vars, []);
|
|
215
|
+
});
|