@kirrosh/apitool 0.4.3

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.
Files changed (191) hide show
  1. package/.github/workflows/ci.yml +27 -0
  2. package/.github/workflows/release.yml +97 -0
  3. package/.mcp.json +9 -0
  4. package/APITOOL.md +195 -0
  5. package/BACKLOG.md +62 -0
  6. package/CHANGELOG.md +88 -0
  7. package/LICENSE +21 -0
  8. package/README.md +105 -0
  9. package/bun.lock +291 -0
  10. package/docs/GLOSSARY.md +182 -0
  11. package/docs/INDEX.md +21 -0
  12. package/docs/agent.md +135 -0
  13. package/docs/archive/APITOOL-pre-M22.md +831 -0
  14. package/docs/archive/BACKLOG-AI-NATIVE.md +56 -0
  15. package/docs/archive/M1-M2-parser-runner.md +216 -0
  16. package/docs/archive/M4-M7-reporter-cli.md +179 -0
  17. package/docs/archive/M5-M7-storage-junit.md +300 -0
  18. package/docs/archive/M6-webui.md +339 -0
  19. package/docs/ci.md +274 -0
  20. package/docs/generation-issues.md +67 -0
  21. package/generated/.env.yaml +3 -0
  22. package/install.ps1 +80 -0
  23. package/install.sh +113 -0
  24. package/package.json +46 -0
  25. package/scripts/run-mocked-tests.ts +45 -0
  26. package/seed-demo.ts +53 -0
  27. package/self-tests/auth.yaml +18 -0
  28. package/self-tests/collections-crud.yaml +46 -0
  29. package/self-tests/environments-crud.yaml +48 -0
  30. package/self-tests/export.yaml +32 -0
  31. package/self-tests/runs.yaml +16 -0
  32. package/src/bun-types.d.ts +5 -0
  33. package/src/cli/commands/add-api.ts +51 -0
  34. package/src/cli/commands/ai-generate.ts +106 -0
  35. package/src/cli/commands/chat.ts +43 -0
  36. package/src/cli/commands/ci-init.ts +126 -0
  37. package/src/cli/commands/collections.ts +41 -0
  38. package/src/cli/commands/coverage.ts +65 -0
  39. package/src/cli/commands/doctor.ts +127 -0
  40. package/src/cli/commands/envs.ts +218 -0
  41. package/src/cli/commands/init.ts +84 -0
  42. package/src/cli/commands/mcp.ts +16 -0
  43. package/src/cli/commands/run.ts +137 -0
  44. package/src/cli/commands/runs.ts +108 -0
  45. package/src/cli/commands/serve.ts +22 -0
  46. package/src/cli/commands/update.ts +142 -0
  47. package/src/cli/commands/validate.ts +18 -0
  48. package/src/cli/index.ts +500 -0
  49. package/src/cli/output.ts +24 -0
  50. package/src/cli/runtime.ts +7 -0
  51. package/src/core/agent/agent-loop.ts +116 -0
  52. package/src/core/agent/context-manager.ts +41 -0
  53. package/src/core/agent/system-prompt.ts +33 -0
  54. package/src/core/agent/tools/diagnose-failure.ts +51 -0
  55. package/src/core/agent/tools/explore-api.ts +40 -0
  56. package/src/core/agent/tools/index.ts +48 -0
  57. package/src/core/agent/tools/manage-environment.ts +40 -0
  58. package/src/core/agent/tools/query-results.ts +40 -0
  59. package/src/core/agent/tools/run-tests.ts +38 -0
  60. package/src/core/agent/tools/send-request.ts +44 -0
  61. package/src/core/agent/tools/validate-tests.ts +23 -0
  62. package/src/core/agent/types.ts +22 -0
  63. package/src/core/generator/ai/ai-generator.ts +61 -0
  64. package/src/core/generator/ai/llm-client.ts +159 -0
  65. package/src/core/generator/ai/output-parser.ts +307 -0
  66. package/src/core/generator/ai/prompt-builder.ts +153 -0
  67. package/src/core/generator/ai/types.ts +56 -0
  68. package/src/core/generator/coverage-scanner.ts +87 -0
  69. package/src/core/generator/data-factory.ts +115 -0
  70. package/src/core/generator/index.ts +10 -0
  71. package/src/core/generator/openapi-reader.ts +142 -0
  72. package/src/core/generator/schema-utils.ts +52 -0
  73. package/src/core/generator/serializer.ts +189 -0
  74. package/src/core/generator/types.ts +47 -0
  75. package/src/core/parser/filter.ts +14 -0
  76. package/src/core/parser/index.ts +21 -0
  77. package/src/core/parser/schema.ts +175 -0
  78. package/src/core/parser/types.ts +50 -0
  79. package/src/core/parser/variables.ts +146 -0
  80. package/src/core/parser/yaml-parser.ts +85 -0
  81. package/src/core/reporter/console.ts +175 -0
  82. package/src/core/reporter/index.ts +23 -0
  83. package/src/core/reporter/json.ts +9 -0
  84. package/src/core/reporter/junit.ts +78 -0
  85. package/src/core/reporter/types.ts +12 -0
  86. package/src/core/runner/assertions.ts +172 -0
  87. package/src/core/runner/execute-run.ts +75 -0
  88. package/src/core/runner/executor.ts +150 -0
  89. package/src/core/runner/http-client.ts +69 -0
  90. package/src/core/runner/index.ts +12 -0
  91. package/src/core/runner/types.ts +48 -0
  92. package/src/core/setup-api.ts +97 -0
  93. package/src/core/utils.ts +9 -0
  94. package/src/db/queries.ts +868 -0
  95. package/src/db/schema.ts +215 -0
  96. package/src/mcp/server.ts +47 -0
  97. package/src/mcp/tools/ci-init.ts +57 -0
  98. package/src/mcp/tools/coverage-analysis.ts +58 -0
  99. package/src/mcp/tools/explore-api.ts +84 -0
  100. package/src/mcp/tools/generate-missing-tests.ts +80 -0
  101. package/src/mcp/tools/generate-tests-guide.ts +353 -0
  102. package/src/mcp/tools/manage-environment.ts +123 -0
  103. package/src/mcp/tools/manage-server.ts +87 -0
  104. package/src/mcp/tools/query-db.ts +141 -0
  105. package/src/mcp/tools/run-tests.ts +66 -0
  106. package/src/mcp/tools/save-test-suite.ts +164 -0
  107. package/src/mcp/tools/send-request.ts +53 -0
  108. package/src/mcp/tools/setup-api.ts +49 -0
  109. package/src/mcp/tools/validate-tests.ts +42 -0
  110. package/src/tui/chat-ui.ts +150 -0
  111. package/src/web/routes/api.ts +234 -0
  112. package/src/web/routes/dashboard.ts +348 -0
  113. package/src/web/routes/runs.ts +64 -0
  114. package/src/web/schemas.ts +121 -0
  115. package/src/web/server.ts +134 -0
  116. package/src/web/static/htmx.min.js +1 -0
  117. package/src/web/static/style.css +265 -0
  118. package/src/web/views/layout.ts +46 -0
  119. package/src/web/views/results.ts +209 -0
  120. package/tests/agent/agent-loop.test.ts +61 -0
  121. package/tests/agent/context-manager.test.ts +59 -0
  122. package/tests/agent/system-prompt.test.ts +42 -0
  123. package/tests/agent/tools/diagnose-failure.test.ts +85 -0
  124. package/tests/agent/tools/explore-api.test.ts +59 -0
  125. package/tests/agent/tools/manage-environment.test.ts +78 -0
  126. package/tests/agent/tools/query-results.test.ts +77 -0
  127. package/tests/agent/tools/run-tests.test.ts +89 -0
  128. package/tests/agent/tools/send-request.test.ts +78 -0
  129. package/tests/agent/tools/validate-tests.test.ts +59 -0
  130. package/tests/ai/ai-generator.integration.test.ts +131 -0
  131. package/tests/ai/llm-client.test.ts +145 -0
  132. package/tests/ai/output-parser.test.ts +132 -0
  133. package/tests/ai/prompt-builder.test.ts +67 -0
  134. package/tests/ai/types.test.ts +55 -0
  135. package/tests/cli/args.test.ts +63 -0
  136. package/tests/cli/chat.test.ts +38 -0
  137. package/tests/cli/ci-init.test.ts +112 -0
  138. package/tests/cli/commands.test.ts +316 -0
  139. package/tests/cli/coverage.test.ts +58 -0
  140. package/tests/cli/doctor.test.ts +39 -0
  141. package/tests/cli/envs.test.ts +181 -0
  142. package/tests/cli/init.test.ts +80 -0
  143. package/tests/cli/runs.test.ts +94 -0
  144. package/tests/cli/safe-run.test.ts +103 -0
  145. package/tests/cli/update.test.ts +32 -0
  146. package/tests/core/generator/schema-utils.test.ts +108 -0
  147. package/tests/core/parser/nested-assertions.test.ts +80 -0
  148. package/tests/core/runner/root-body-assertions.test.ts +70 -0
  149. package/tests/db/chat-queries.test.ts +88 -0
  150. package/tests/db/chat-schema.test.ts +37 -0
  151. package/tests/db/environments.test.ts +131 -0
  152. package/tests/db/queries.test.ts +409 -0
  153. package/tests/db/schema.test.ts +141 -0
  154. package/tests/fixtures/.env.yaml +3 -0
  155. package/tests/fixtures/auth-token-test.yaml +8 -0
  156. package/tests/fixtures/bail/suite-a.yaml +6 -0
  157. package/tests/fixtures/bail/suite-b.yaml +6 -0
  158. package/tests/fixtures/crud.yaml +35 -0
  159. package/tests/fixtures/invalid-missing-name.yaml +5 -0
  160. package/tests/fixtures/invalid-no-method.yaml +6 -0
  161. package/tests/fixtures/petstore-auth.json +295 -0
  162. package/tests/fixtures/petstore-simple.json +151 -0
  163. package/tests/fixtures/post-only.yaml +12 -0
  164. package/tests/fixtures/simple.yaml +6 -0
  165. package/tests/fixtures/valid/.env.yaml +1 -0
  166. package/tests/fixtures/valid/a.yaml +5 -0
  167. package/tests/fixtures/valid/b.yml +5 -0
  168. package/tests/generator/coverage-scanner.test.ts +129 -0
  169. package/tests/generator/data-factory.test.ts +133 -0
  170. package/tests/generator/openapi-reader.test.ts +131 -0
  171. package/tests/integration/auth-flow.test.ts +217 -0
  172. package/tests/mcp/coverage-analysis.test.ts +64 -0
  173. package/tests/mcp/explore-api-schemas.test.ts +105 -0
  174. package/tests/mcp/explore-api.test.ts +49 -0
  175. package/tests/mcp/generate-missing-tests.test.ts +69 -0
  176. package/tests/mcp/manage-environment.test.ts +89 -0
  177. package/tests/mcp/save-test-suite.test.ts +116 -0
  178. package/tests/mcp/send-request.test.ts +79 -0
  179. package/tests/mcp/setup-api.test.ts +106 -0
  180. package/tests/mcp/tools.test.ts +248 -0
  181. package/tests/parser/schema.test.ts +134 -0
  182. package/tests/parser/variables.test.ts +227 -0
  183. package/tests/parser/yaml-parser.test.ts +69 -0
  184. package/tests/reporter/console.test.ts +256 -0
  185. package/tests/reporter/json.test.ts +98 -0
  186. package/tests/reporter/junit.test.ts +284 -0
  187. package/tests/runner/assertions.test.ts +262 -0
  188. package/tests/runner/executor.test.ts +310 -0
  189. package/tests/runner/http-client.test.ts +138 -0
  190. package/tests/web/routes.test.ts +160 -0
  191. package/tsconfig.json +31 -0
@@ -0,0 +1,87 @@
1
+ import { Glob } from "bun";
2
+ import type { EndpointInfo } from "./types.ts";
3
+
4
+ export interface CoveredEndpoint {
5
+ method: string;
6
+ path: string;
7
+ file: string;
8
+ }
9
+
10
+ const HTTP_METHODS = ["GET", "POST", "PUT", "PATCH", "DELETE"];
11
+
12
+ /**
13
+ * Scan YAML test files in outputDir and extract method+path from each test step.
14
+ * Uses simple regex to avoid importing the full parser.
15
+ */
16
+ export async function scanCoveredEndpoints(outputDir: string): Promise<CoveredEndpoint[]> {
17
+ const covered: CoveredEndpoint[] = [];
18
+
19
+ const glob = new Glob("**/*.yaml");
20
+ for await (const file of glob.scan({ cwd: outputDir, absolute: true })) {
21
+ try {
22
+ const content = await Bun.file(file).text();
23
+ const lines = content.split("\n");
24
+
25
+ for (const line of lines) {
26
+ const trimmed = line.trim();
27
+ for (const method of HTTP_METHODS) {
28
+ // Match lines like "POST: /users" or "GET: /users/{{user_id}}"
29
+ if (trimmed.startsWith(`${method}:`) || trimmed.startsWith(`${method} :`)) {
30
+ const path = trimmed.slice(trimmed.indexOf(":") + 1).trim().replace(/^["']|["']$/g, "");
31
+ if (path) {
32
+ covered.push({ method, path: normalizePath(path), file });
33
+ }
34
+ }
35
+ }
36
+ }
37
+ } catch {
38
+ // Skip unreadable files
39
+ }
40
+ }
41
+
42
+ return covered;
43
+ }
44
+
45
+ /**
46
+ * Normalize path for comparison:
47
+ * - Replace {{variable}} with {*}
48
+ * - Replace {paramName} with {*}
49
+ * - Remove trailing slashes
50
+ */
51
+ function normalizePath(path: string): string {
52
+ return path
53
+ .replace(/\{\{[^}]+\}\}/g, "{*}") // {{var}} → {*}
54
+ .replace(/\{[^}]+\}/g, "{*}") // {id} → {*}
55
+ .replace(/\/+$/, "");
56
+ }
57
+
58
+ /**
59
+ * Convert a spec path to a regex that matches both parameterized and concrete paths.
60
+ * e.g. /pet/{petId} matches /pet/100001 and /pet/{{pet_id}}
61
+ */
62
+ function specPathToRegex(specPath: string): RegExp {
63
+ const pattern = specPath
64
+ .split("/")
65
+ .map((seg) =>
66
+ /^\{[^}]+\}$/.test(seg)
67
+ ? "[^/]+"
68
+ : seg.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
69
+ )
70
+ .join("/");
71
+ return new RegExp(`^${pattern}$`);
72
+ }
73
+
74
+ /**
75
+ * Filter endpoints that don't yet have test coverage.
76
+ */
77
+ export function filterUncoveredEndpoints(
78
+ all: EndpointInfo[],
79
+ covered: CoveredEndpoint[],
80
+ ): EndpointInfo[] {
81
+ return all.filter((ep) => {
82
+ const specRegex = specPathToRegex(ep.path);
83
+ return !covered.some(
84
+ (c) => c.method === ep.method && specRegex.test(normalizePath(c.path)),
85
+ );
86
+ });
87
+ }
@@ -0,0 +1,115 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+
3
+ /**
4
+ * Recursively generates test data from an OpenAPI schema.
5
+ * Uses heuristic placeholders ({{$...}} generators) where possible.
6
+ */
7
+ export function generateFromSchema(
8
+ schema: OpenAPIV3.SchemaObject,
9
+ propertyName?: string,
10
+ ): unknown {
11
+ // allOf: merge all schemas
12
+ if (schema.allOf) {
13
+ const merged: OpenAPIV3.SchemaObject = { type: "object", properties: {} };
14
+ for (const sub of schema.allOf) {
15
+ const s = sub as OpenAPIV3.SchemaObject;
16
+ if (s.properties) {
17
+ merged.properties = { ...merged.properties, ...s.properties };
18
+ }
19
+ }
20
+ return generateFromSchema(merged, propertyName);
21
+ }
22
+
23
+ // oneOf / anyOf: use first variant
24
+ if (schema.oneOf) {
25
+ return generateFromSchema(schema.oneOf[0] as OpenAPIV3.SchemaObject, propertyName);
26
+ }
27
+ if (schema.anyOf) {
28
+ return generateFromSchema(schema.anyOf[0] as OpenAPIV3.SchemaObject, propertyName);
29
+ }
30
+
31
+ // enum: first value
32
+ if (schema.enum && schema.enum.length > 0) {
33
+ return schema.enum[0];
34
+ }
35
+
36
+ switch (schema.type) {
37
+ case "string":
38
+ return guessStringPlaceholder(schema, propertyName);
39
+
40
+ case "integer":
41
+ return guessIntPlaceholder(propertyName);
42
+
43
+ case "number":
44
+ return "{{$randomInt}}";
45
+
46
+ case "boolean":
47
+ return true;
48
+
49
+ case "array": {
50
+ if (schema.items) {
51
+ const item = generateFromSchema(schema.items as OpenAPIV3.SchemaObject);
52
+ return [item];
53
+ }
54
+ return [];
55
+ }
56
+
57
+ case "object":
58
+ default: {
59
+ // Treat unknown type with properties as object
60
+ if (schema.properties) {
61
+ const obj: Record<string, unknown> = {};
62
+ for (const [key, propSchema] of Object.entries(schema.properties)) {
63
+ obj[key] = generateFromSchema(propSchema as OpenAPIV3.SchemaObject, key);
64
+ }
65
+ return obj;
66
+ }
67
+ // Record type: additionalProperties defines value schema
68
+ if (schema.additionalProperties && typeof schema.additionalProperties === "object") {
69
+ const valSchema = schema.additionalProperties as OpenAPIV3.SchemaObject;
70
+ return { key1: generateFromSchema(valSchema, "key1"), key2: generateFromSchema(valSchema, "key2") };
71
+ }
72
+ if (schema.additionalProperties === true) {
73
+ return { key1: "value1", key2: "value2" };
74
+ }
75
+ // Bare object with no properties
76
+ if (schema.type === "object") {
77
+ return {};
78
+ }
79
+ return "{{$randomString}}";
80
+ }
81
+ }
82
+ }
83
+
84
+ function guessStringPlaceholder(schema: OpenAPIV3.SchemaObject, name?: string): string {
85
+ // Format-based
86
+ if (schema.format === "email") return "{{$randomEmail}}";
87
+ if (schema.format === "uuid") return "{{$uuid}}";
88
+ if (schema.format === "date-time" || schema.format === "date") return "2025-01-01T00:00:00Z";
89
+
90
+ // Name-based heuristics
91
+ if (name) {
92
+ const lower = name.toLowerCase();
93
+ if (lower === "email" || lower.endsWith("_email") || lower.endsWith("Email")) {
94
+ return "{{$randomEmail}}";
95
+ }
96
+ if (lower === "id" || lower === "uuid" || lower.endsWith("_id") || lower.endsWith("id")) {
97
+ return "{{$uuid}}";
98
+ }
99
+ if (lower === "name" || lower.endsWith("_name") || lower.endsWith("Name")) {
100
+ return "{{$randomName}}";
101
+ }
102
+ }
103
+
104
+ return "{{$randomString}}";
105
+ }
106
+
107
+ function guessIntPlaceholder(name?: string): string {
108
+ if (name) {
109
+ const lower = name.toLowerCase();
110
+ if (lower === "id" || lower.endsWith("_id") || lower.endsWith("Id")) {
111
+ return "{{$randomInt}}";
112
+ }
113
+ }
114
+ return "{{$randomInt}}";
115
+ }
@@ -0,0 +1,10 @@
1
+ export { readOpenApiSpec, extractEndpoints, extractSecuritySchemes } from "./openapi-reader.ts";
2
+ export { serializeSuite, isRelativeUrl, sanitizeEnvName, resolveSpecPath } from "./serializer.ts";
3
+ export type { RawSuite, RawStep } from "./serializer.ts";
4
+ export { generateFromSchema } from "./data-factory.ts";
5
+ export { generateWithAI } from "./ai/ai-generator.ts";
6
+ export { resolveProviderConfig, PROVIDER_DEFAULTS } from "./ai/types.ts";
7
+ export type { AIProviderConfig, AIGenerateOptions, AIGenerateResult } from "./ai/types.ts";
8
+ export { scanCoveredEndpoints, filterUncoveredEndpoints } from "./coverage-scanner.ts";
9
+ export type { CoveredEndpoint } from "./coverage-scanner.ts";
10
+ export type { EndpointInfo, ResponseInfo, GenerateOptions, SecuritySchemeInfo, CrudGroup } from "./types.ts";
@@ -0,0 +1,142 @@
1
+ import { dereference } from "@readme/openapi-parser";
2
+ import type { OpenAPIV3 } from "openapi-types";
3
+ import type { EndpointInfo, ResponseInfo, SecuritySchemeInfo } from "./types.ts";
4
+
5
+ const HTTP_METHODS = ["get", "post", "put", "patch", "delete"] as const;
6
+
7
+ export async function readOpenApiSpec(specPath: string): Promise<OpenAPIV3.Document> {
8
+ // For HTTP URLs, fetch the spec first then dereference the parsed object
9
+ if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
10
+ const resp = await fetch(specPath);
11
+ if (!resp.ok) throw new Error(`Failed to fetch spec: ${resp.status} ${resp.statusText}`);
12
+ const spec = await resp.json();
13
+ const api = await dereference(spec);
14
+ return api as OpenAPIV3.Document;
15
+ }
16
+ const api = await dereference(specPath);
17
+ return api as OpenAPIV3.Document;
18
+ }
19
+
20
+ export function extractSecuritySchemes(doc: OpenAPIV3.Document): SecuritySchemeInfo[] {
21
+ const schemes: SecuritySchemeInfo[] = [];
22
+ const securitySchemes = doc.components?.securitySchemes;
23
+ if (!securitySchemes) return schemes;
24
+
25
+ for (const [name, schemeObj] of Object.entries(securitySchemes)) {
26
+ const scheme = schemeObj as OpenAPIV3.SecuritySchemeObject;
27
+ const info: SecuritySchemeInfo = {
28
+ name,
29
+ type: scheme.type as SecuritySchemeInfo["type"],
30
+ };
31
+ if (scheme.type === "http") {
32
+ info.scheme = scheme.scheme;
33
+ info.bearerFormat = scheme.bearerFormat;
34
+ }
35
+ if (scheme.type === "apiKey") {
36
+ info.in = scheme.in;
37
+ info.apiKeyName = scheme.name;
38
+ }
39
+ schemes.push(info);
40
+ }
41
+ return schemes;
42
+ }
43
+
44
+ export function extractEndpoints(doc: OpenAPIV3.Document): EndpointInfo[] {
45
+ const endpoints: EndpointInfo[] = [];
46
+
47
+ if (!doc.paths) return endpoints;
48
+
49
+ for (const [path, pathItem] of Object.entries(doc.paths)) {
50
+ if (!pathItem) continue;
51
+
52
+ for (const method of HTTP_METHODS) {
53
+ const operation = pathItem[method] as OpenAPIV3.OperationObject | undefined;
54
+ if (!operation) continue;
55
+
56
+ const parameters: OpenAPIV3.ParameterObject[] = [];
57
+
58
+ // Path-level parameters
59
+ if (pathItem.parameters) {
60
+ for (const p of pathItem.parameters) {
61
+ parameters.push(p as OpenAPIV3.ParameterObject);
62
+ }
63
+ }
64
+
65
+ // Operation-level parameters (override path-level)
66
+ if (operation.parameters) {
67
+ for (const p of operation.parameters) {
68
+ const param = p as OpenAPIV3.ParameterObject;
69
+ const existingIdx = parameters.findIndex(
70
+ (existing) => existing.name === param.name && existing.in === param.in,
71
+ );
72
+ if (existingIdx >= 0) {
73
+ parameters[existingIdx] = param;
74
+ } else {
75
+ parameters.push(param);
76
+ }
77
+ }
78
+ }
79
+
80
+ // Request body schema + content type
81
+ let requestBodySchema: OpenAPIV3.SchemaObject | undefined;
82
+ let requestBodyContentType: string | undefined;
83
+ if (operation.requestBody) {
84
+ const rb = operation.requestBody as OpenAPIV3.RequestBodyObject;
85
+ if (rb.content) {
86
+ // Prefer application/json, fall back to first available
87
+ const contentTypes = Object.keys(rb.content);
88
+ requestBodyContentType = contentTypes.includes("application/json")
89
+ ? "application/json"
90
+ : contentTypes[0];
91
+ const chosen = rb.content[requestBodyContentType!];
92
+ if (chosen?.schema) {
93
+ requestBodySchema = chosen.schema as OpenAPIV3.SchemaObject;
94
+ }
95
+ }
96
+ }
97
+
98
+ // Responses
99
+ const responses: ResponseInfo[] = [];
100
+ const responseContentTypesSet = new Set<string>();
101
+ if (operation.responses) {
102
+ for (const [statusCode, responseObj] of Object.entries(operation.responses)) {
103
+ const resp = responseObj as OpenAPIV3.ResponseObject;
104
+ const info: ResponseInfo = {
105
+ statusCode: parseInt(statusCode, 10),
106
+ description: resp.description || "",
107
+ };
108
+ if (resp.content) {
109
+ for (const ct of Object.keys(resp.content)) {
110
+ responseContentTypesSet.add(ct);
111
+ }
112
+ const jsonContent = resp.content["application/json"];
113
+ if (jsonContent?.schema) {
114
+ info.schema = jsonContent.schema as OpenAPIV3.SchemaObject;
115
+ }
116
+ }
117
+ responses.push(info);
118
+ }
119
+ }
120
+
121
+ // Security: operation-level overrides doc-level
122
+ const securityReqs = operation.security ?? doc.security ?? [];
123
+ const security = securityReqs.flatMap((req) => Object.keys(req));
124
+
125
+ endpoints.push({
126
+ path,
127
+ method: method.toUpperCase(),
128
+ operationId: operation.operationId,
129
+ summary: operation.summary,
130
+ tags: operation.tags ?? [],
131
+ parameters,
132
+ requestBodySchema,
133
+ requestBodyContentType,
134
+ responseContentTypes: [...responseContentTypesSet],
135
+ responses,
136
+ security,
137
+ });
138
+ }
139
+ }
140
+
141
+ return endpoints;
142
+ }
@@ -0,0 +1,52 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+
3
+ /**
4
+ * Returns true if the schema is effectively `any` — no type, no properties, no constraints.
5
+ */
6
+ export function isAnySchema(schema: OpenAPIV3.SchemaObject | undefined): boolean {
7
+ if (!schema) return false;
8
+ return Object.keys(schema).length === 0 ||
9
+ (!schema.type && !schema.properties && !schema.enum && !schema.oneOf && !schema.allOf && !schema.anyOf);
10
+ }
11
+
12
+ /**
13
+ * Compress an OpenAPI schema into a concise human-readable string.
14
+ * E.g. { name: string (req), age: integer, tags: [string] }
15
+ */
16
+ export function compressSchema(schema: OpenAPIV3.SchemaObject, depth = 0): string {
17
+ if (depth > 2) return "{...}";
18
+
19
+ if (schema.type === "object" && schema.properties) {
20
+ const required = new Set(schema.required ?? []);
21
+ const fields = Object.entries(schema.properties).map(([key, propObj]) => {
22
+ const prop = propObj as OpenAPIV3.SchemaObject;
23
+ const type = prop.type ?? "any";
24
+ const flags: string[] = [];
25
+ if (required.has(key)) flags.push("req");
26
+ if (prop.format) flags.push(prop.format);
27
+ if (prop.enum) flags.push(`enum: ${prop.enum.join("|")}`);
28
+ const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
29
+ return `${key}: ${type}${flagStr}`;
30
+ });
31
+ return `{ ${fields.join(", ")} }`;
32
+ }
33
+
34
+ if (schema.type === "array") {
35
+ const items = schema.items as OpenAPIV3.SchemaObject | undefined;
36
+ if (items) return `[${compressSchema(items, depth + 1)}]`;
37
+ return "[]";
38
+ }
39
+
40
+ return schema.type ?? "any";
41
+ }
42
+
43
+ /**
44
+ * Format an OpenAPI parameter into a concise string.
45
+ * E.g. "limit: integer" or "id: string (req)"
46
+ */
47
+ export function formatParam(p: OpenAPIV3.ParameterObject): string {
48
+ const schema = p.schema as OpenAPIV3.SchemaObject | undefined;
49
+ const type = schema?.type ?? "string";
50
+ const req = p.required ? " (req)" : "";
51
+ return `${p.name}: ${type}${req}`;
52
+ }
@@ -0,0 +1,189 @@
1
+ import { resolve } from "path";
2
+
3
+ // ──────────────────────────────────────────────
4
+ // Utility functions (moved from skeleton.ts)
5
+ // ──────────────────────────────────────────────
6
+
7
+ export function isRelativeUrl(url: string): boolean {
8
+ return url.startsWith("/") && !url.includes("://");
9
+ }
10
+
11
+ export function resolveSpecPath(specPath: string): string {
12
+ if (specPath.startsWith("http://") || specPath.startsWith("https://")) {
13
+ return specPath;
14
+ }
15
+ return resolve(specPath);
16
+ }
17
+
18
+ export function sanitizeEnvName(name: string): string {
19
+ return name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 30);
20
+ }
21
+
22
+ // ──────────────────────────────────────────────
23
+ // Types for raw suite serialization
24
+ // ──────────────────────────────────────────────
25
+
26
+ export interface RawStep {
27
+ name: string;
28
+ [methodKey: string]: unknown;
29
+ expect: {
30
+ status?: number;
31
+ body?: Record<string, Record<string, string>>;
32
+ };
33
+ }
34
+
35
+ export interface RawSuite {
36
+ name: string;
37
+ folder?: string;
38
+ fileStem?: string;
39
+ base_url?: string;
40
+ headers?: Record<string, string>;
41
+ tests: RawStep[];
42
+ }
43
+
44
+ // ──────────────────────────────────────────────
45
+ // YAML serializer
46
+ // ──────────────────────────────────────────────
47
+
48
+ export function serializeSuite(suite: RawSuite): string {
49
+ const lines: string[] = [];
50
+ lines.push(`name: ${yamlScalar(suite.name)}`);
51
+ if (suite.base_url) {
52
+ lines.push(`base_url: ${yamlScalar(suite.base_url)}`);
53
+ }
54
+ if (suite.headers && Object.keys(suite.headers).length > 0) {
55
+ lines.push("headers:");
56
+ for (const [hk, hv] of Object.entries(suite.headers)) {
57
+ lines.push(` ${hk}: ${yamlScalar(String(hv))}`);
58
+ }
59
+ }
60
+ lines.push("tests:");
61
+
62
+ for (const test of suite.tests) {
63
+ lines.push(` - name: ${yamlScalar(test.name)}`);
64
+
65
+ // Write method-as-key (the shorthand)
66
+ for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
67
+ if (method in test) {
68
+ lines.push(` ${method}: ${test[method]}`);
69
+ }
70
+ }
71
+
72
+ // headers
73
+ if (test.headers && Object.keys(test.headers as Record<string, string>).length > 0) {
74
+ lines.push(" headers:");
75
+ for (const [hk, hv] of Object.entries(test.headers as Record<string, string>)) {
76
+ lines.push(` ${hk}: ${yamlScalar(String(hv))}`);
77
+ }
78
+ }
79
+
80
+ // json body
81
+ if (test.json !== undefined) {
82
+ lines.push(" json:");
83
+ serializeValue(test.json, 3, lines);
84
+ }
85
+
86
+ // query
87
+ if (test.query) {
88
+ lines.push(" query:");
89
+ serializeValue(test.query, 3, lines);
90
+ }
91
+
92
+ // expect
93
+ lines.push(" expect:");
94
+ if (test.expect.status !== undefined) {
95
+ lines.push(` status: ${test.expect.status}`);
96
+ }
97
+ if (test.expect.body) {
98
+ lines.push(" body:");
99
+ for (const [key, rule] of Object.entries(test.expect.body)) {
100
+ lines.push(` ${key}:`);
101
+ for (const [rk, rv] of Object.entries(rule)) {
102
+ lines.push(` ${rk}: ${yamlScalar(String(rv))}`);
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ return lines.join("\n") + "\n";
109
+ }
110
+
111
+ function serializeValue(value: unknown, indent: number, lines: string[]): void {
112
+ const prefix = " ".repeat(indent);
113
+
114
+ if (value === null || value === undefined) {
115
+ lines.push(`${prefix}null`);
116
+ return;
117
+ }
118
+
119
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
120
+ lines.push(`${prefix}${yamlScalar(String(value))}`);
121
+ return;
122
+ }
123
+
124
+ if (Array.isArray(value)) {
125
+ for (const item of value) {
126
+ if (typeof item === "object" && item !== null && !Array.isArray(item)) {
127
+ const entries = Object.entries(item as Record<string, unknown>);
128
+ if (entries.length > 0) {
129
+ const [firstKey, firstVal] = entries[0]!;
130
+ lines.push(`${prefix}- ${firstKey}: ${formatInlineValue(firstVal)}`);
131
+ for (let i = 1; i < entries.length; i++) {
132
+ const [k, v] = entries[i]!;
133
+ lines.push(`${prefix} ${k}: ${formatInlineValue(v)}`);
134
+ }
135
+ } else {
136
+ lines.push(`${prefix}- {}`);
137
+ }
138
+ } else {
139
+ lines.push(`${prefix}- ${formatInlineValue(item)}`);
140
+ }
141
+ }
142
+ return;
143
+ }
144
+
145
+ if (typeof value === "object") {
146
+ for (const [key, val] of Object.entries(value as Record<string, unknown>)) {
147
+ if (typeof val === "object" && val !== null) {
148
+ lines.push(`${prefix}${key}:`);
149
+ serializeValue(val, indent + 1, lines);
150
+ } else {
151
+ lines.push(`${prefix}${key}: ${formatInlineValue(val)}`);
152
+ }
153
+ }
154
+ }
155
+ }
156
+
157
+ function formatInlineValue(val: unknown): string {
158
+ if (val === null || val === undefined) return "null";
159
+ if (typeof val === "string") return yamlScalar(val);
160
+ return String(val);
161
+ }
162
+
163
+ function yamlScalar(value: string): string {
164
+ if (
165
+ value === "" ||
166
+ value === "true" ||
167
+ value === "false" ||
168
+ value === "null" ||
169
+ value.includes(":") ||
170
+ value.includes("#") ||
171
+ value.includes("\n") ||
172
+ value.includes("'") ||
173
+ value.includes('"') ||
174
+ value.includes("{") ||
175
+ value.includes("}") ||
176
+ value.includes("[") ||
177
+ value.includes("]") ||
178
+ value.startsWith("&") ||
179
+ value.startsWith("*") ||
180
+ value.startsWith("!") ||
181
+ value.startsWith("%") ||
182
+ value.startsWith("@") ||
183
+ value.startsWith("`") ||
184
+ /^\d+$/.test(value)
185
+ ) {
186
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
187
+ }
188
+ return value;
189
+ }
@@ -0,0 +1,47 @@
1
+ import type { OpenAPIV3 } from "openapi-types";
2
+
3
+ export interface ResponseInfo {
4
+ statusCode: number;
5
+ description: string;
6
+ schema?: OpenAPIV3.SchemaObject;
7
+ }
8
+
9
+ export interface EndpointInfo {
10
+ path: string;
11
+ method: string;
12
+ operationId?: string;
13
+ summary?: string;
14
+ tags: string[];
15
+ parameters: OpenAPIV3.ParameterObject[];
16
+ requestBodySchema?: OpenAPIV3.SchemaObject;
17
+ requestBodyContentType?: string;
18
+ responseContentTypes: string[];
19
+ responses: ResponseInfo[];
20
+ security: string[];
21
+ }
22
+
23
+ export interface SecuritySchemeInfo {
24
+ name: string;
25
+ type: "http" | "apiKey" | "oauth2" | "openIdConnect";
26
+ scheme?: string;
27
+ bearerFormat?: string;
28
+ in?: string;
29
+ apiKeyName?: string;
30
+ }
31
+
32
+ export interface CrudGroup {
33
+ resource: string;
34
+ basePath: string;
35
+ itemPath: string;
36
+ idParam: string;
37
+ create?: EndpointInfo;
38
+ list?: EndpointInfo;
39
+ read?: EndpointInfo;
40
+ update?: EndpointInfo;
41
+ delete?: EndpointInfo;
42
+ }
43
+
44
+ export interface GenerateOptions {
45
+ specPath: string;
46
+ outputDir: string;
47
+ }
@@ -0,0 +1,14 @@
1
+ import type { TestSuite } from "./types.ts";
2
+
3
+ /**
4
+ * Filter suites by tags (OR logic, case-insensitive).
5
+ * Suites without tags are excluded when filtering is active.
6
+ */
7
+ export function filterSuitesByTags(suites: TestSuite[], tags: string[]): TestSuite[] {
8
+ if (tags.length === 0) return suites;
9
+ const normalizedTags = tags.map(t => t.toLowerCase());
10
+ return suites.filter(suite => {
11
+ if (!suite.tags || suite.tags.length === 0) return false;
12
+ return suite.tags.some(t => normalizedTags.includes(t.toLowerCase()));
13
+ });
14
+ }
@@ -0,0 +1,21 @@
1
+ export type {
2
+ HttpMethod,
3
+ AssertionRule,
4
+ TestStepExpect,
5
+ TestStep,
6
+ SuiteConfig,
7
+ TestSuite,
8
+ Environment,
9
+ } from "./types.ts";
10
+
11
+ export { validateSuite, DEFAULT_CONFIG } from "./schema.ts";
12
+ export {
13
+ GENERATORS,
14
+ substituteString,
15
+ substituteDeep,
16
+ substituteStep,
17
+ extractVariableReferences,
18
+ loadEnvironment,
19
+ } from "./variables.ts";
20
+ export { parse, parseFile, parseDirectory } from "./yaml-parser.ts";
21
+ export { filterSuitesByTags } from "./filter.ts";