@njeybe/nativ 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isNoSqlEngine } from '../db/types.js';
|
|
4
|
+
import { loadContractEngine, loadContractTables } from '../db/diff.js';
|
|
5
|
+
import { buildSchemaNode, DEFAULT_TEST_BASE_URL, DEFAULT_TEST_FRAMEWORK, DEFAULT_TEST_OUTPUT_DIR, isTestFramework, TEST_FRAMEWORKS, } from './test-generator-types.js';
|
|
6
|
+
/**
|
|
7
|
+
* Contract-to-test generator (nativ test gen).
|
|
8
|
+
*
|
|
9
|
+
* Contracts are compiled into framework-agnostic TestSuiteSpecs, then rendered by a
|
|
10
|
+
* TestFrameworkAdapter. Generated endpoint tests never send a well-formed request to a
|
|
11
|
+
* mutating endpoint: POST/PUT/PATCH/DELETE are only exercised with invalid or anonymous
|
|
12
|
+
* requests, so running the suite cannot write data or trigger side effects.
|
|
13
|
+
*/
|
|
14
|
+
/** Present in the header of every generated file; files without it are never overwritten. */
|
|
15
|
+
export const GENERATED_MARKER = 'Code generated by nativ test gen';
|
|
16
|
+
const GENERATED_MARKER_PATTERN = /code generated by nativ test gen/i;
|
|
17
|
+
export class TestGenError extends Error {
|
|
18
|
+
code;
|
|
19
|
+
constructor(code, message) {
|
|
20
|
+
super(message);
|
|
21
|
+
this.code = code;
|
|
22
|
+
this.name = 'TestGenError';
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
// ─── Options & contract loading ────────────────────────────────────────────
|
|
26
|
+
export function resolveTestGenOptions(options) {
|
|
27
|
+
const targetDir = path.resolve(options.targetDir);
|
|
28
|
+
const framework = options.framework ?? DEFAULT_TEST_FRAMEWORK;
|
|
29
|
+
if (!isTestFramework(framework)) {
|
|
30
|
+
throw new TestGenError('INVALID_FRAMEWORK', `Unsupported framework "${framework}". Expected one of: ${TEST_FRAMEWORKS.join(', ')}`);
|
|
31
|
+
}
|
|
32
|
+
const baseUrl = (options.baseUrl ?? DEFAULT_TEST_BASE_URL).trim().replace(/\/+$/, '');
|
|
33
|
+
let parsed = null;
|
|
34
|
+
try {
|
|
35
|
+
parsed = new URL(baseUrl);
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
parsed = null;
|
|
39
|
+
}
|
|
40
|
+
if (!parsed || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:')) {
|
|
41
|
+
throw new TestGenError('INVALID_BASE_URL', `Base URL must be an absolute http(s) URL, got "${baseUrl}"`);
|
|
42
|
+
}
|
|
43
|
+
const outputDir = path.resolve(targetDir, options.outputDir || DEFAULT_TEST_OUTPUT_DIR);
|
|
44
|
+
return { targetDir, outputDir, framework, baseUrl, dryRun: options.dryRun === true };
|
|
45
|
+
}
|
|
46
|
+
function readContractJson(targetDir, file) {
|
|
47
|
+
const filePath = path.join(targetDir, '.ai', file);
|
|
48
|
+
if (!fs.existsSync(filePath))
|
|
49
|
+
return null;
|
|
50
|
+
try {
|
|
51
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
52
|
+
}
|
|
53
|
+
catch (err) {
|
|
54
|
+
throw new TestGenError('INVALID_CONTRACT', `.ai/${file} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export function loadApiContracts(targetDir) {
|
|
58
|
+
const doc = readContractJson(targetDir, 'api_contracts.json');
|
|
59
|
+
if (!doc)
|
|
60
|
+
return null;
|
|
61
|
+
if (!Array.isArray(doc.endpoints)) {
|
|
62
|
+
throw new TestGenError('INVALID_CONTRACT', '.ai/api_contracts.json must contain an "endpoints" array');
|
|
63
|
+
}
|
|
64
|
+
return doc;
|
|
65
|
+
}
|
|
66
|
+
/** .ai/db_schema.json with tables normalized the same way `nativ db diff --target contract` reads them. */
|
|
67
|
+
export function loadDbSchemaContract(targetDir) {
|
|
68
|
+
const doc = readContractJson(targetDir, 'db_schema.json');
|
|
69
|
+
if (!doc)
|
|
70
|
+
return null;
|
|
71
|
+
if (doc.tables !== undefined && !Array.isArray(doc.tables)) {
|
|
72
|
+
throw new TestGenError('INVALID_CONTRACT', '.ai/db_schema.json "tables" must be an array');
|
|
73
|
+
}
|
|
74
|
+
const engine = loadContractEngine(targetDir);
|
|
75
|
+
return {
|
|
76
|
+
version: doc.version,
|
|
77
|
+
projectName: doc.projectName ?? '',
|
|
78
|
+
...(engine ? { engine } : {}),
|
|
79
|
+
tables: loadContractTables(targetDir),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
// ─── Schema helpers ────────────────────────────────────────────────────────
|
|
83
|
+
function isPlainObject(value) {
|
|
84
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
/** Same semantics as the generated shapeErrors helpers: true when `value` conforms to `node`. */
|
|
87
|
+
export function matchesSchema(value, node) {
|
|
88
|
+
switch (node.kind) {
|
|
89
|
+
case 'primitive':
|
|
90
|
+
switch (node.type) {
|
|
91
|
+
case 'string':
|
|
92
|
+
return typeof value === 'string';
|
|
93
|
+
case 'number':
|
|
94
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
95
|
+
case 'integer':
|
|
96
|
+
return Number.isInteger(value);
|
|
97
|
+
case 'boolean':
|
|
98
|
+
return typeof value === 'boolean';
|
|
99
|
+
case 'null':
|
|
100
|
+
return value === null;
|
|
101
|
+
default:
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
case 'enum':
|
|
105
|
+
return node.values.includes(value);
|
|
106
|
+
case 'ref':
|
|
107
|
+
return isPlainObject(value);
|
|
108
|
+
case 'array':
|
|
109
|
+
return Array.isArray(value) && value.every((item) => matchesSchema(item, node.items));
|
|
110
|
+
case 'union':
|
|
111
|
+
return node.variants.some((v) => matchesSchema(value, v));
|
|
112
|
+
case 'object':
|
|
113
|
+
if (!isPlainObject(value))
|
|
114
|
+
return false;
|
|
115
|
+
return Object.entries(node.properties).every(([key, child]) => value[key] === undefined ? child.optional === true : matchesSchema(value[key], child));
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** A plausible valid value for `node` (enums use their first value). */
|
|
119
|
+
function sampleValue(node) {
|
|
120
|
+
switch (node.kind) {
|
|
121
|
+
case 'enum':
|
|
122
|
+
return node.values[0];
|
|
123
|
+
case 'primitive':
|
|
124
|
+
if (node.type === 'number' || node.type === 'integer')
|
|
125
|
+
return 1;
|
|
126
|
+
if (node.type === 'boolean')
|
|
127
|
+
return true;
|
|
128
|
+
if (node.type === 'null')
|
|
129
|
+
return null;
|
|
130
|
+
return 'sample';
|
|
131
|
+
case 'object':
|
|
132
|
+
return Object.fromEntries(Object.entries(node.properties)
|
|
133
|
+
.filter(([, child]) => !child.optional)
|
|
134
|
+
.map(([key, child]) => [key, sampleValue(child)]));
|
|
135
|
+
case 'array':
|
|
136
|
+
return [];
|
|
137
|
+
case 'union': {
|
|
138
|
+
const nonNull = node.variants.find((v) => !(v.kind === 'primitive' && v.type === 'null'));
|
|
139
|
+
return sampleValue(nonNull ?? node.variants[0]);
|
|
140
|
+
}
|
|
141
|
+
case 'ref':
|
|
142
|
+
return {};
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const INVALID_CANDIDATES = [12345, '__nativ_invalid__', true, ['__nativ_invalid__']];
|
|
146
|
+
/** A value `node` rejects, or undefined when it accepts anything. */
|
|
147
|
+
function invalidValue(node) {
|
|
148
|
+
return INVALID_CANDIDATES.find((candidate) => !matchesSchema(candidate, node));
|
|
149
|
+
}
|
|
150
|
+
function toObjectNode(value) {
|
|
151
|
+
if (value === null || value === undefined)
|
|
152
|
+
return null;
|
|
153
|
+
const node = buildSchemaNode(value);
|
|
154
|
+
return node.kind === 'object' && !node.open ? node : null;
|
|
155
|
+
}
|
|
156
|
+
function requiredEntries(node) {
|
|
157
|
+
return node ? Object.entries(node.properties).filter(([, child]) => !child.optional) : [];
|
|
158
|
+
}
|
|
159
|
+
function stringifyQuery(values) {
|
|
160
|
+
return Object.fromEntries(Object.entries(values).map(([k, v]) => [k, String(v)]));
|
|
161
|
+
}
|
|
162
|
+
function quoteList(names) {
|
|
163
|
+
return names.map((n) => `"${n}"`).join(', ');
|
|
164
|
+
}
|
|
165
|
+
// ─── Endpoint suite synthesis ──────────────────────────────────────────────
|
|
166
|
+
const HTTP_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'];
|
|
167
|
+
const SAFE_METHODS = new Set(['GET']);
|
|
168
|
+
const INVALID_INPUT_STATUSES = [400, 422];
|
|
169
|
+
const PATH_PARAM_PATTERN = /:([A-Za-z_]\w*)|\{([A-Za-z_]\w*)\}/g;
|
|
170
|
+
/** Builds a request the contract says must be rejected, or null when no input can be invalidated. */
|
|
171
|
+
function buildInvalidRequest(base, queryNode, bodyNode, safe) {
|
|
172
|
+
const requiredQuery = requiredEntries(queryNode);
|
|
173
|
+
const sampleQuery = stringifyQuery(Object.fromEntries(requiredQuery.map(([k, n]) => [k, sampleValue(n)])));
|
|
174
|
+
const withQuery = Object.keys(sampleQuery).length ? { query: sampleQuery } : {};
|
|
175
|
+
if (!safe && bodyNode) {
|
|
176
|
+
if (bodyNode.kind === 'object' && !bodyNode.open) {
|
|
177
|
+
const required = requiredEntries(bodyNode).map(([k]) => k);
|
|
178
|
+
if (required.length) {
|
|
179
|
+
return {
|
|
180
|
+
request: { ...base, ...withQuery, body: {} },
|
|
181
|
+
reason: `missing required field${required.length > 1 ? 's' : ''} ${quoteList(required)}`,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
for (const [key, child] of Object.entries(bodyNode.properties)) {
|
|
185
|
+
const bad = invalidValue(child);
|
|
186
|
+
if (bad !== undefined)
|
|
187
|
+
return { request: { ...base, ...withQuery, body: { [key]: bad } }, reason: `"${key}" has the wrong type` };
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
const bad = invalidValue(bodyNode);
|
|
192
|
+
if (bad !== undefined)
|
|
193
|
+
return { request: { ...base, ...withQuery, body: bad }, reason: 'body has the wrong type' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
if (requiredQuery.length) {
|
|
197
|
+
const names = requiredQuery.map(([k]) => k);
|
|
198
|
+
return { request: { ...base }, reason: `missing required query parameter${names.length > 1 ? 's' : ''} ${quoteList(names)}` };
|
|
199
|
+
}
|
|
200
|
+
const enumParam = queryNode && Object.entries(queryNode.properties).find(([, n]) => n.kind === 'enum' && n.base === 'string');
|
|
201
|
+
if (enumParam) {
|
|
202
|
+
return { request: { ...base, query: { [enumParam[0]]: '__nativ_invalid__' } }, reason: `"${enumParam[0]}" is outside its allowed values` };
|
|
203
|
+
}
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
function successStatus(endpoint) {
|
|
207
|
+
const codes = Object.keys(endpoint.responses ?? {})
|
|
208
|
+
.map(Number)
|
|
209
|
+
.filter((c) => c >= 200 && c < 300)
|
|
210
|
+
.sort((a, b) => a - b);
|
|
211
|
+
return codes[0] ?? null;
|
|
212
|
+
}
|
|
213
|
+
export function buildEndpointSuite(endpoint) {
|
|
214
|
+
const method = String(endpoint.method ?? '').toUpperCase();
|
|
215
|
+
if (!HTTP_METHODS.includes(method) || typeof endpoint.path !== 'string' || !endpoint.path.startsWith('/')) {
|
|
216
|
+
throw new TestGenError('INVALID_CONTRACT', `Endpoint "${endpoint.id}" needs an HTTP method and a path starting with "/"`);
|
|
217
|
+
}
|
|
218
|
+
const id = endpoint.id || `${method} ${endpoint.path}`;
|
|
219
|
+
const request = endpoint.request ?? {};
|
|
220
|
+
const pathNode = toObjectNode(request.pathParams);
|
|
221
|
+
let hasPathParams = false;
|
|
222
|
+
const filledPath = endpoint.path.replace(PATH_PARAM_PATTERN, (_m, colon, brace) => {
|
|
223
|
+
hasPathParams = true;
|
|
224
|
+
const name = colon ?? brace ?? '';
|
|
225
|
+
const node = pathNode?.properties[name];
|
|
226
|
+
return encodeURIComponent(String(node ? sampleValue(node) : 'sample'));
|
|
227
|
+
});
|
|
228
|
+
// Authorization comes from NATIV_TEST_AUTH_HEADER at run time; Content-Type is set when a body is sent.
|
|
229
|
+
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).filter(([k, v]) => typeof v === 'string' && !/^(authorization|content-type)$/i.test(k)));
|
|
230
|
+
const base = { method, path: filledPath, ...(Object.keys(headers).length ? { headers } : {}) };
|
|
231
|
+
const queryNode = toObjectNode(request.queryParams);
|
|
232
|
+
const bodyNode = request.body === null || request.body === undefined ? null : buildSchemaNode(request.body);
|
|
233
|
+
const safe = SAFE_METHODS.has(method);
|
|
234
|
+
const needsInputs = requiredEntries(queryNode).length > 0 || hasPathParams;
|
|
235
|
+
const invalid = buildInvalidRequest(base, queryNode, bodyNode, safe);
|
|
236
|
+
// Well-formed requests are only sent to safe endpoints that need no caller-supplied values.
|
|
237
|
+
const sampleRequest = safe && !needsInputs ? base : null;
|
|
238
|
+
const probeRequest = sampleRequest ?? invalid?.request ?? null;
|
|
239
|
+
const cases = [];
|
|
240
|
+
if (probeRequest && !hasPathParams) {
|
|
241
|
+
cases.push({ id: `${id}:route`, name: 'route is mounted (not 404/405)', kind: 'route', request: probeRequest, assertions: [] });
|
|
242
|
+
}
|
|
243
|
+
if (endpoint.auth) {
|
|
244
|
+
// Without an invalid request, a mutating endpoint is probed bare: it must reject before acting.
|
|
245
|
+
cases.push({
|
|
246
|
+
id: `${id}:auth-required`,
|
|
247
|
+
name: 'rejects anonymous requests with 401',
|
|
248
|
+
kind: 'auth-required',
|
|
249
|
+
request: probeRequest ?? base,
|
|
250
|
+
assertions: [{ kind: 'status', expected: 401 }],
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
if (invalid) {
|
|
254
|
+
cases.push({
|
|
255
|
+
id: `${id}:invalid-payload`,
|
|
256
|
+
name: `rejects invalid input with 400/422 (${invalid.reason})`,
|
|
257
|
+
kind: 'invalid-payload',
|
|
258
|
+
request: invalid.request,
|
|
259
|
+
assertions: [{ kind: 'status', expected: INVALID_INPUT_STATUSES }],
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
const status = successStatus(endpoint);
|
|
263
|
+
if (sampleRequest && status !== null) {
|
|
264
|
+
const assertions = [{ kind: 'status', expected: status }];
|
|
265
|
+
const body = endpoint.responses[String(status)]?.body;
|
|
266
|
+
if (body !== undefined)
|
|
267
|
+
assertions.push({ kind: 'response-shape', schema: buildSchemaNode(body) });
|
|
268
|
+
cases.push({
|
|
269
|
+
id: `${id}:response-schema`,
|
|
270
|
+
name: `returns ${status} matching the contract response schema`,
|
|
271
|
+
kind: 'response-schema',
|
|
272
|
+
request: sampleRequest,
|
|
273
|
+
assertions,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
return {
|
|
277
|
+
id,
|
|
278
|
+
name: `${method} ${endpoint.path}`,
|
|
279
|
+
suiteType: 'api-contract',
|
|
280
|
+
source: { kind: 'endpoint', endpointId: id, method, path: endpoint.path },
|
|
281
|
+
cases,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
export function buildEndpointSuites(doc) {
|
|
285
|
+
return doc.endpoints.map(buildEndpointSuite).filter((suite) => suite.cases.length > 0);
|
|
286
|
+
}
|
|
287
|
+
// ─── Database integrity suite synthesis ────────────────────────────────────
|
|
288
|
+
/** Engines without unique secondary indexes; unique-constraint checks would always fail there. */
|
|
289
|
+
const NO_UNIQUE_INDEX_ENGINES = new Set(['firestore']);
|
|
290
|
+
/**
|
|
291
|
+
* Integrity checks for one contract table: existence, columns, primary key, nullability and
|
|
292
|
+
* unique constraints. Collections only get required-field checks, because sampled document
|
|
293
|
+
* stores report a field as nullable whenever some sampled document lacks it.
|
|
294
|
+
*/
|
|
295
|
+
export function buildTableSuite(table, engine) {
|
|
296
|
+
const t = table.name;
|
|
297
|
+
const entityType = table.entityType ?? (engine && isNoSqlEngine(engine) ? 'collection' : 'table');
|
|
298
|
+
const isCollection = entityType === 'collection';
|
|
299
|
+
const fieldWord = isCollection ? 'field' : 'column';
|
|
300
|
+
const cases = [
|
|
301
|
+
{ id: `db:${t}:exists`, name: `${entityType} exists`, kind: 'table-exists', assertions: [{ kind: 'table-exists', table: t }] },
|
|
302
|
+
];
|
|
303
|
+
for (const col of table.columns) {
|
|
304
|
+
cases.push({
|
|
305
|
+
id: `db:${t}:column:${col.name}`,
|
|
306
|
+
name: `has ${fieldWord} "${col.name}" (${col.type})`,
|
|
307
|
+
kind: 'column',
|
|
308
|
+
assertions: [{ kind: 'column-exists', table: t, column: col.name, type: col.type }],
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
const pkColumns = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
312
|
+
if (pkColumns.length) {
|
|
313
|
+
cases.push({
|
|
314
|
+
id: `db:${t}:primary-key`,
|
|
315
|
+
name: `primary key is (${pkColumns.join(', ')})`,
|
|
316
|
+
kind: 'primary-key',
|
|
317
|
+
assertions: [{ kind: 'primary-key', table: t, columns: pkColumns }],
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
for (const col of table.columns) {
|
|
321
|
+
if (col.primaryKey || col.isSubcollection || (isCollection && col.nullable))
|
|
322
|
+
continue;
|
|
323
|
+
cases.push({
|
|
324
|
+
id: `db:${t}:nullability:${col.name}`,
|
|
325
|
+
name: col.nullable ? `"${col.name}" is nullable` : isCollection ? `"${col.name}" is always present` : `"${col.name}" is NOT NULL`,
|
|
326
|
+
kind: 'nullability',
|
|
327
|
+
assertions: [{ kind: 'nullability', table: t, column: col.name, nullable: col.nullable }],
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
if (!engine || !NO_UNIQUE_INDEX_ENGINES.has(engine)) {
|
|
331
|
+
for (const idx of table.indexes ?? []) {
|
|
332
|
+
if (!idx.unique || !idx.columns.length)
|
|
333
|
+
continue;
|
|
334
|
+
cases.push({
|
|
335
|
+
id: `db:${t}:unique:${idx.name}`,
|
|
336
|
+
name: `unique constraint ${idx.name} on (${idx.columns.join(', ')})`,
|
|
337
|
+
kind: 'unique-constraint',
|
|
338
|
+
assertions: [{ kind: 'unique-constraint', table: t, index: idx.name, columns: idx.columns }],
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
id: `db:${t}`,
|
|
344
|
+
name: `${entityType} ${t}`,
|
|
345
|
+
suiteType: 'db-integrity',
|
|
346
|
+
source: { kind: 'table', table: t, entityType },
|
|
347
|
+
cases,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
export function buildDbSuites(doc) {
|
|
351
|
+
return doc.tables.map((table) => buildTableSuite(table, doc.engine ?? null));
|
|
352
|
+
}
|
|
353
|
+
// ─── Rendering helpers ─────────────────────────────────────────────────────
|
|
354
|
+
function slug(value) {
|
|
355
|
+
return value.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'suite';
|
|
356
|
+
}
|
|
357
|
+
function snake(value) {
|
|
358
|
+
const s = value.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '') || 'suite';
|
|
359
|
+
return /^\d/.test(s) ? `_${s}` : s;
|
|
360
|
+
}
|
|
361
|
+
function pascal(value) {
|
|
362
|
+
const s = value
|
|
363
|
+
.split(/[^A-Za-z0-9]+/)
|
|
364
|
+
.filter(Boolean)
|
|
365
|
+
.map((part) => part[0].toUpperCase() + part.slice(1))
|
|
366
|
+
.join('');
|
|
367
|
+
return /^\d/.test(s) ? `X${s}` : s || 'Suite';
|
|
368
|
+
}
|
|
369
|
+
/** Indents every line after the first, so multi-line literals nest under the current line. */
|
|
370
|
+
function hang(text, pad) {
|
|
371
|
+
return text.split('\n').join(`\n${pad}`);
|
|
372
|
+
}
|
|
373
|
+
function statusList(expected) {
|
|
374
|
+
return Array.isArray(expected) ? expected : [expected];
|
|
375
|
+
}
|
|
376
|
+
function suiteHeader(comment, suite) {
|
|
377
|
+
const source = suite.source.kind === 'endpoint'
|
|
378
|
+
? `.ai/api_contracts.json (endpoint "${suite.source.endpointId}")`
|
|
379
|
+
: `.ai/db_schema.json (${suite.source.entityType} "${suite.source.table}")`;
|
|
380
|
+
return [`${comment} ${GENERATED_MARKER} from ${source}. DO NOT EDIT.`, `${comment} Re-run \`nativ test gen\` to refresh this file.`];
|
|
381
|
+
}
|
|
382
|
+
function pyLiteral(value, depth = 0) {
|
|
383
|
+
if (value === null || value === undefined)
|
|
384
|
+
return 'None';
|
|
385
|
+
if (value === true)
|
|
386
|
+
return 'True';
|
|
387
|
+
if (value === false)
|
|
388
|
+
return 'False';
|
|
389
|
+
if (typeof value === 'number')
|
|
390
|
+
return Number.isFinite(value) ? String(value) : 'None';
|
|
391
|
+
if (typeof value === 'string')
|
|
392
|
+
return JSON.stringify(value);
|
|
393
|
+
const pad = ' '.repeat(depth + 1);
|
|
394
|
+
const close = ' '.repeat(depth);
|
|
395
|
+
if (Array.isArray(value)) {
|
|
396
|
+
if (!value.length)
|
|
397
|
+
return '[]';
|
|
398
|
+
return `[\n${value.map((v) => pad + pyLiteral(v, depth + 1)).join(',\n')},\n${close}]`;
|
|
399
|
+
}
|
|
400
|
+
const entries = Object.entries(value).filter(([, v]) => v !== undefined);
|
|
401
|
+
if (!entries.length)
|
|
402
|
+
return '{}';
|
|
403
|
+
return `{\n${entries.map(([k, v]) => `${pad}${JSON.stringify(k)}: ${pyLiteral(v, depth + 1)}`).join(',\n')},\n${close}}`;
|
|
404
|
+
}
|
|
405
|
+
function pyTuple(values) {
|
|
406
|
+
return values.length === 1 ? `(${values[0]},)` : `(${values.join(', ')})`;
|
|
407
|
+
}
|
|
408
|
+
/** A JSON document as a Go (and JSON) double-quoted string literal. */
|
|
409
|
+
function goJson(value) {
|
|
410
|
+
return JSON.stringify(JSON.stringify(value));
|
|
411
|
+
}
|
|
412
|
+
function applyBaseUrl(source, baseUrl) {
|
|
413
|
+
return source.replace('__NATIV_BASE_URL__', JSON.stringify(baseUrl));
|
|
414
|
+
}
|
|
415
|
+
// ─── Support file sources ──────────────────────────────────────────────────
|
|
416
|
+
// Written with String.raw and without template placeholders so the sources are emitted verbatim.
|
|
417
|
+
const TS_SUPPORT = String.raw `// Code generated by nativ test gen. DO NOT EDIT.
|
|
418
|
+
// Shared helpers for the generated contract tests. Re-run ${'`'}nativ test gen${'`'} to refresh this file.
|
|
419
|
+
// Environment: NATIV_TEST_BASE_URL overrides the base URL, NATIV_TEST_AUTH_HEADER is sent as Authorization,
|
|
420
|
+
// NATIV_DB_SCHEMA_JSON reads a saved "nativ db inspect --json" file, NATIV_DB_INSPECT_CMD replaces that command.
|
|
421
|
+
|
|
422
|
+
import { execSync } from 'node:child_process';
|
|
423
|
+
import { readFileSync } from 'node:fs';
|
|
424
|
+
|
|
425
|
+
export const BASE_URL = (process.env.NATIV_TEST_BASE_URL || __NATIV_BASE_URL__).replace(/\/+$/, '');
|
|
426
|
+
|
|
427
|
+
/** A parsed contract descriptor (see src/core/test-generator-types.ts in nativ). */
|
|
428
|
+
export type SchemaNode = Record<string, any>;
|
|
429
|
+
|
|
430
|
+
export interface ContractRequest {
|
|
431
|
+
method: string;
|
|
432
|
+
path: string;
|
|
433
|
+
query?: Record<string, string>;
|
|
434
|
+
headers?: Record<string, string>;
|
|
435
|
+
body?: unknown;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export interface ContractResponse {
|
|
439
|
+
status: number;
|
|
440
|
+
body: unknown;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
export async function callEndpoint(request: ContractRequest, options: { anonymous?: boolean } = {}): Promise<ContractResponse> {
|
|
444
|
+
const url = new URL(BASE_URL + request.path);
|
|
445
|
+
for (const [key, value] of Object.entries(request.query ?? {})) url.searchParams.set(key, value);
|
|
446
|
+
const headers: Record<string, string> = { Accept: 'application/json', ...(request.headers ?? {}) };
|
|
447
|
+
const auth = process.env.NATIV_TEST_AUTH_HEADER;
|
|
448
|
+
if (auth && !options.anonymous) headers.Authorization = auth;
|
|
449
|
+
const init: RequestInit = { method: request.method, headers };
|
|
450
|
+
if (request.body !== undefined) {
|
|
451
|
+
headers['Content-Type'] = 'application/json';
|
|
452
|
+
init.body = JSON.stringify(request.body);
|
|
453
|
+
}
|
|
454
|
+
const res = await fetch(url, init);
|
|
455
|
+
const text = await res.text();
|
|
456
|
+
let body: unknown = text;
|
|
457
|
+
try {
|
|
458
|
+
body = text ? JSON.parse(text) : null;
|
|
459
|
+
} catch {
|
|
460
|
+
// Non-JSON bodies are returned as text.
|
|
461
|
+
}
|
|
462
|
+
return { status: res.status, body };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function describeValue(value: unknown): string {
|
|
466
|
+
if (value === null) return 'null';
|
|
467
|
+
return Array.isArray(value) ? 'array' : typeof value;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
471
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function primitiveMatches(value: unknown, type: string): boolean {
|
|
475
|
+
switch (type) {
|
|
476
|
+
case 'string': return typeof value === 'string';
|
|
477
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
478
|
+
case 'integer': return Number.isInteger(value);
|
|
479
|
+
case 'boolean': return typeof value === 'boolean';
|
|
480
|
+
case 'null': return value === null;
|
|
481
|
+
default: return true;
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** Every way value deviates from the contract schema; empty when it conforms. */
|
|
486
|
+
export function shapeErrors(value: unknown, node: SchemaNode, path = '$'): string[] {
|
|
487
|
+
switch (node.kind) {
|
|
488
|
+
case 'primitive':
|
|
489
|
+
return primitiveMatches(value, node.type) ? [] : [path + ': expected ' + node.type + ', got ' + describeValue(value)];
|
|
490
|
+
case 'enum':
|
|
491
|
+
return node.values.includes(value) ? [] : [path + ': expected one of ' + JSON.stringify(node.values) + ', got ' + JSON.stringify(value)];
|
|
492
|
+
case 'ref':
|
|
493
|
+
return isPlainObject(value) ? [] : [path + ': expected ' + node.name + ' object, got ' + describeValue(value)];
|
|
494
|
+
case 'array':
|
|
495
|
+
if (!Array.isArray(value)) return [path + ': expected array, got ' + describeValue(value)];
|
|
496
|
+
return value.flatMap((item, i) => shapeErrors(item, node.items, path + '[' + i + ']'));
|
|
497
|
+
case 'union':
|
|
498
|
+
return node.variants.some((v: SchemaNode) => shapeErrors(value, v, path).length === 0)
|
|
499
|
+
? []
|
|
500
|
+
: [path + ': ' + JSON.stringify(value) + ' matches none of the allowed types'];
|
|
501
|
+
case 'object': {
|
|
502
|
+
if (!isPlainObject(value)) return [path + ': expected object, got ' + describeValue(value)];
|
|
503
|
+
const errors: string[] = [];
|
|
504
|
+
for (const [key, child] of Object.entries(node.properties as Record<string, SchemaNode>)) {
|
|
505
|
+
if (value[key] === undefined) {
|
|
506
|
+
if (!child.optional) errors.push(path + '.' + key + ': missing required field');
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
errors.push(...shapeErrors(value[key], child, path + '.' + key));
|
|
510
|
+
}
|
|
511
|
+
return errors;
|
|
512
|
+
}
|
|
513
|
+
default:
|
|
514
|
+
return [];
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ─── Live database schema (structure only, read through nativ; never rows or credentials) ───
|
|
519
|
+
|
|
520
|
+
export interface LiveColumn {
|
|
521
|
+
name: string;
|
|
522
|
+
primaryKey?: boolean;
|
|
523
|
+
nullable?: boolean;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export interface LiveIndex {
|
|
527
|
+
name: string;
|
|
528
|
+
columns: string[];
|
|
529
|
+
unique: boolean;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
export interface LiveTable {
|
|
533
|
+
name: string;
|
|
534
|
+
columns: LiveColumn[];
|
|
535
|
+
indexes?: LiveIndex[];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export interface LiveSchema {
|
|
539
|
+
connected: boolean;
|
|
540
|
+
error: string | null;
|
|
541
|
+
tables: LiveTable[];
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/** A generated database assertion: { kind, table, column?, columns?, nullable? }. */
|
|
545
|
+
export type DbAssertion = Record<string, any>;
|
|
546
|
+
|
|
547
|
+
let liveSchemaCache: LiveSchema | undefined;
|
|
548
|
+
|
|
549
|
+
function readInspectOutput(): string {
|
|
550
|
+
const file = process.env.NATIV_DB_SCHEMA_JSON;
|
|
551
|
+
if (file) return readFileSync(file, 'utf8');
|
|
552
|
+
const command = process.env.NATIV_DB_INSPECT_CMD || 'nativ db inspect --json';
|
|
553
|
+
try {
|
|
554
|
+
return execSync(command, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000 });
|
|
555
|
+
} catch (err: any) {
|
|
556
|
+
// nativ exits 1 when the database is unreachable but still prints the JSON status.
|
|
557
|
+
return String(err?.stdout ?? '');
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export function loadLiveSchema(): LiveSchema {
|
|
562
|
+
if (liveSchemaCache) return liveSchemaCache;
|
|
563
|
+
let parsed: any = null;
|
|
564
|
+
try {
|
|
565
|
+
const raw = readInspectOutput();
|
|
566
|
+
parsed = JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1));
|
|
567
|
+
} catch {
|
|
568
|
+
parsed = null;
|
|
569
|
+
}
|
|
570
|
+
liveSchemaCache = parsed
|
|
571
|
+
? { connected: parsed.status?.connected === true, error: parsed.status?.error ?? null, tables: Array.isArray(parsed.tables) ? parsed.tables : [] }
|
|
572
|
+
: { connected: false, error: 'could not read "nativ db inspect --json" output (set NATIV_DB_SCHEMA_JSON or NATIV_DB_INSPECT_CMD)', tables: [] };
|
|
573
|
+
return liveSchemaCache;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function sameNames(a: string[], b: string[]): boolean {
|
|
577
|
+
const x = a.map((n) => n.toLowerCase()).sort();
|
|
578
|
+
const y = b.map((n) => n.toLowerCase()).sort();
|
|
579
|
+
return x.length === y.length && x.every((n, i) => n === y[i]);
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** Checks one generated assertion against the live schema; returns a failure message or null. */
|
|
583
|
+
export function dbCheck(live: LiveSchema, a: DbAssertion): string | null {
|
|
584
|
+
const name = String(a.table);
|
|
585
|
+
const table = live.tables.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
586
|
+
if (!table) return '"' + name + '" not found in the live database';
|
|
587
|
+
const qualified = name + '.' + a.column;
|
|
588
|
+
const column = a.column === undefined ? undefined : table.columns.find((c) => c.name.toLowerCase() === String(a.column).toLowerCase());
|
|
589
|
+
const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
590
|
+
switch (a.kind) {
|
|
591
|
+
case 'table-exists':
|
|
592
|
+
return null;
|
|
593
|
+
case 'column-exists':
|
|
594
|
+
return column ? null : '"' + qualified + '" not found in the live database';
|
|
595
|
+
case 'primary-key':
|
|
596
|
+
return sameNames(pk, a.columns) ? null : 'primary key of "' + name + '" is (' + pk.join(', ') + '), contract says (' + a.columns.join(', ') + ')';
|
|
597
|
+
case 'nullability':
|
|
598
|
+
if (!column) return '"' + qualified + '" not found in the live database';
|
|
599
|
+
return Boolean(column.nullable) === a.nullable
|
|
600
|
+
? null
|
|
601
|
+
: '"' + qualified + '" is ' + (column.nullable ? 'nullable' : 'NOT NULL') + ' in the live database, contract says ' + (a.nullable ? 'nullable' : 'NOT NULL');
|
|
602
|
+
case 'unique-constraint': {
|
|
603
|
+
const found = sameNames(pk, a.columns) || (table.indexes ?? []).some((i) => i.unique && sameNames(i.columns, a.columns));
|
|
604
|
+
return found ? null : 'no unique index on "' + name + '" (' + a.columns.join(', ') + ')';
|
|
605
|
+
}
|
|
606
|
+
default:
|
|
607
|
+
return 'unknown assertion kind "' + a.kind + '"';
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
`;
|
|
611
|
+
const JS_SUPPORT = String.raw `// Code generated by nativ test gen. DO NOT EDIT.
|
|
612
|
+
// Shared helpers for the generated contract tests. Re-run ${'`'}nativ test gen${'`'} to refresh this file.
|
|
613
|
+
// Environment: NATIV_TEST_BASE_URL overrides the base URL, NATIV_TEST_AUTH_HEADER is sent as Authorization,
|
|
614
|
+
// NATIV_DB_SCHEMA_JSON reads a saved "nativ db inspect --json" file, NATIV_DB_INSPECT_CMD replaces that command.
|
|
615
|
+
|
|
616
|
+
import { execSync } from 'node:child_process';
|
|
617
|
+
import { readFileSync } from 'node:fs';
|
|
618
|
+
|
|
619
|
+
export const BASE_URL = (process.env.NATIV_TEST_BASE_URL || __NATIV_BASE_URL__).replace(/\/+$/, '');
|
|
620
|
+
|
|
621
|
+
export async function callEndpoint(request, options = {}) {
|
|
622
|
+
const url = new URL(BASE_URL + request.path);
|
|
623
|
+
for (const [key, value] of Object.entries(request.query ?? {})) url.searchParams.set(key, value);
|
|
624
|
+
const headers = { Accept: 'application/json', ...(request.headers ?? {}) };
|
|
625
|
+
const auth = process.env.NATIV_TEST_AUTH_HEADER;
|
|
626
|
+
if (auth && !options.anonymous) headers.Authorization = auth;
|
|
627
|
+
const init = { method: request.method, headers };
|
|
628
|
+
if (request.body !== undefined) {
|
|
629
|
+
headers['Content-Type'] = 'application/json';
|
|
630
|
+
init.body = JSON.stringify(request.body);
|
|
631
|
+
}
|
|
632
|
+
const res = await fetch(url, init);
|
|
633
|
+
const text = await res.text();
|
|
634
|
+
let body = text;
|
|
635
|
+
try {
|
|
636
|
+
body = text ? JSON.parse(text) : null;
|
|
637
|
+
} catch {
|
|
638
|
+
// Non-JSON bodies are returned as text.
|
|
639
|
+
}
|
|
640
|
+
return { status: res.status, body };
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
function describeValue(value) {
|
|
644
|
+
if (value === null) return 'null';
|
|
645
|
+
return Array.isArray(value) ? 'array' : typeof value;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function isPlainObject(value) {
|
|
649
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function primitiveMatches(value, type) {
|
|
653
|
+
switch (type) {
|
|
654
|
+
case 'string': return typeof value === 'string';
|
|
655
|
+
case 'number': return typeof value === 'number' && Number.isFinite(value);
|
|
656
|
+
case 'integer': return Number.isInteger(value);
|
|
657
|
+
case 'boolean': return typeof value === 'boolean';
|
|
658
|
+
case 'null': return value === null;
|
|
659
|
+
default: return true;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/** Every way value deviates from the contract schema; empty when it conforms. */
|
|
664
|
+
export function shapeErrors(value, node, path = '$') {
|
|
665
|
+
switch (node.kind) {
|
|
666
|
+
case 'primitive':
|
|
667
|
+
return primitiveMatches(value, node.type) ? [] : [path + ': expected ' + node.type + ', got ' + describeValue(value)];
|
|
668
|
+
case 'enum':
|
|
669
|
+
return node.values.includes(value) ? [] : [path + ': expected one of ' + JSON.stringify(node.values) + ', got ' + JSON.stringify(value)];
|
|
670
|
+
case 'ref':
|
|
671
|
+
return isPlainObject(value) ? [] : [path + ': expected ' + node.name + ' object, got ' + describeValue(value)];
|
|
672
|
+
case 'array':
|
|
673
|
+
if (!Array.isArray(value)) return [path + ': expected array, got ' + describeValue(value)];
|
|
674
|
+
return value.flatMap((item, i) => shapeErrors(item, node.items, path + '[' + i + ']'));
|
|
675
|
+
case 'union':
|
|
676
|
+
return node.variants.some((v) => shapeErrors(value, v, path).length === 0)
|
|
677
|
+
? []
|
|
678
|
+
: [path + ': ' + JSON.stringify(value) + ' matches none of the allowed types'];
|
|
679
|
+
case 'object': {
|
|
680
|
+
if (!isPlainObject(value)) return [path + ': expected object, got ' + describeValue(value)];
|
|
681
|
+
const errors = [];
|
|
682
|
+
for (const [key, child] of Object.entries(node.properties)) {
|
|
683
|
+
if (value[key] === undefined) {
|
|
684
|
+
if (!child.optional) errors.push(path + '.' + key + ': missing required field');
|
|
685
|
+
continue;
|
|
686
|
+
}
|
|
687
|
+
errors.push(...shapeErrors(value[key], child, path + '.' + key));
|
|
688
|
+
}
|
|
689
|
+
return errors;
|
|
690
|
+
}
|
|
691
|
+
default:
|
|
692
|
+
return [];
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// ─── Live database schema (structure only, read through nativ; never rows or credentials) ───
|
|
697
|
+
|
|
698
|
+
let liveSchemaCache;
|
|
699
|
+
|
|
700
|
+
function readInspectOutput() {
|
|
701
|
+
const file = process.env.NATIV_DB_SCHEMA_JSON;
|
|
702
|
+
if (file) return readFileSync(file, 'utf8');
|
|
703
|
+
const command = process.env.NATIV_DB_INSPECT_CMD || 'nativ db inspect --json';
|
|
704
|
+
try {
|
|
705
|
+
return execSync(command, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 120000 });
|
|
706
|
+
} catch (err) {
|
|
707
|
+
// nativ exits 1 when the database is unreachable but still prints the JSON status.
|
|
708
|
+
return String(err?.stdout ?? '');
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
export function loadLiveSchema() {
|
|
713
|
+
if (liveSchemaCache) return liveSchemaCache;
|
|
714
|
+
let parsed = null;
|
|
715
|
+
try {
|
|
716
|
+
const raw = readInspectOutput();
|
|
717
|
+
parsed = JSON.parse(raw.slice(raw.indexOf('{'), raw.lastIndexOf('}') + 1));
|
|
718
|
+
} catch {
|
|
719
|
+
parsed = null;
|
|
720
|
+
}
|
|
721
|
+
liveSchemaCache = parsed
|
|
722
|
+
? { connected: parsed.status?.connected === true, error: parsed.status?.error ?? null, tables: Array.isArray(parsed.tables) ? parsed.tables : [] }
|
|
723
|
+
: { connected: false, error: 'could not read "nativ db inspect --json" output (set NATIV_DB_SCHEMA_JSON or NATIV_DB_INSPECT_CMD)', tables: [] };
|
|
724
|
+
return liveSchemaCache;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function sameNames(a, b) {
|
|
728
|
+
const x = a.map((n) => n.toLowerCase()).sort();
|
|
729
|
+
const y = b.map((n) => n.toLowerCase()).sort();
|
|
730
|
+
return x.length === y.length && x.every((n, i) => n === y[i]);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** Checks one generated assertion against the live schema; returns a failure message or null. */
|
|
734
|
+
export function dbCheck(live, a) {
|
|
735
|
+
const name = String(a.table);
|
|
736
|
+
const table = live.tables.find((t) => t.name.toLowerCase() === name.toLowerCase());
|
|
737
|
+
if (!table) return '"' + name + '" not found in the live database';
|
|
738
|
+
const qualified = name + '.' + a.column;
|
|
739
|
+
const column = a.column === undefined ? undefined : table.columns.find((c) => c.name.toLowerCase() === String(a.column).toLowerCase());
|
|
740
|
+
const pk = table.columns.filter((c) => c.primaryKey).map((c) => c.name);
|
|
741
|
+
switch (a.kind) {
|
|
742
|
+
case 'table-exists':
|
|
743
|
+
return null;
|
|
744
|
+
case 'column-exists':
|
|
745
|
+
return column ? null : '"' + qualified + '" not found in the live database';
|
|
746
|
+
case 'primary-key':
|
|
747
|
+
return sameNames(pk, a.columns) ? null : 'primary key of "' + name + '" is (' + pk.join(', ') + '), contract says (' + a.columns.join(', ') + ')';
|
|
748
|
+
case 'nullability':
|
|
749
|
+
if (!column) return '"' + qualified + '" not found in the live database';
|
|
750
|
+
return Boolean(column.nullable) === a.nullable
|
|
751
|
+
? null
|
|
752
|
+
: '"' + qualified + '" is ' + (column.nullable ? 'nullable' : 'NOT NULL') + ' in the live database, contract says ' + (a.nullable ? 'nullable' : 'NOT NULL');
|
|
753
|
+
case 'unique-constraint': {
|
|
754
|
+
const found = sameNames(pk, a.columns) || (table.indexes ?? []).some((i) => i.unique && sameNames(i.columns, a.columns));
|
|
755
|
+
return found ? null : 'no unique index on "' + name + '" (' + a.columns.join(', ') + ')';
|
|
756
|
+
}
|
|
757
|
+
default:
|
|
758
|
+
return 'unknown assertion kind "' + a.kind + '"';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
`;
|
|
762
|
+
const PY_SUPPORT = String.raw `# Code generated by nativ test gen. DO NOT EDIT.
|
|
763
|
+
# Shared helpers for the generated contract tests. Re-run "nativ test gen" to refresh this file.
|
|
764
|
+
# Environment: NATIV_TEST_BASE_URL overrides the base URL, NATIV_TEST_AUTH_HEADER is sent as Authorization,
|
|
765
|
+
# NATIV_DB_SCHEMA_JSON reads a saved "nativ db inspect --json" file, NATIV_DB_INSPECT_CMD replaces that command.
|
|
766
|
+
import json
|
|
767
|
+
import os
|
|
768
|
+
import subprocess
|
|
769
|
+
import urllib.error
|
|
770
|
+
import urllib.parse
|
|
771
|
+
import urllib.request
|
|
772
|
+
|
|
773
|
+
BASE_URL = (os.environ.get("NATIV_TEST_BASE_URL") or __NATIV_BASE_URL__).rstrip("/")
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def call_endpoint(request, anonymous=False):
|
|
777
|
+
"""Sends a contract request and returns (status, parsed_body)."""
|
|
778
|
+
url = BASE_URL + request["path"]
|
|
779
|
+
query = request.get("query") or {}
|
|
780
|
+
if query:
|
|
781
|
+
url += "?" + urllib.parse.urlencode(query)
|
|
782
|
+
headers = {"Accept": "application/json"}
|
|
783
|
+
headers.update(request.get("headers") or {})
|
|
784
|
+
auth = os.environ.get("NATIV_TEST_AUTH_HEADER")
|
|
785
|
+
if auth and not anonymous:
|
|
786
|
+
headers["Authorization"] = auth
|
|
787
|
+
data = None
|
|
788
|
+
if "body" in request:
|
|
789
|
+
data = json.dumps(request["body"]).encode("utf-8")
|
|
790
|
+
headers["Content-Type"] = "application/json"
|
|
791
|
+
req = urllib.request.Request(url, data=data, headers=headers, method=request["method"])
|
|
792
|
+
try:
|
|
793
|
+
with urllib.request.urlopen(req, timeout=30) as res:
|
|
794
|
+
status, raw = res.status, res.read()
|
|
795
|
+
except urllib.error.HTTPError as err:
|
|
796
|
+
status, raw = err.code, err.read()
|
|
797
|
+
text = raw.decode("utf-8", "replace")
|
|
798
|
+
try:
|
|
799
|
+
body = json.loads(text) if text else None
|
|
800
|
+
except ValueError:
|
|
801
|
+
body = text
|
|
802
|
+
return status, body
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def _describe(value):
|
|
806
|
+
if value is None:
|
|
807
|
+
return "null"
|
|
808
|
+
if isinstance(value, bool):
|
|
809
|
+
return "boolean"
|
|
810
|
+
if isinstance(value, (int, float)):
|
|
811
|
+
return "number"
|
|
812
|
+
if isinstance(value, str):
|
|
813
|
+
return "string"
|
|
814
|
+
if isinstance(value, list):
|
|
815
|
+
return "array"
|
|
816
|
+
if isinstance(value, dict):
|
|
817
|
+
return "object"
|
|
818
|
+
return type(value).__name__
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _primitive_matches(value, kind):
|
|
822
|
+
if kind == "string":
|
|
823
|
+
return isinstance(value, str)
|
|
824
|
+
if kind == "number":
|
|
825
|
+
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
826
|
+
if kind == "integer":
|
|
827
|
+
if isinstance(value, bool):
|
|
828
|
+
return False
|
|
829
|
+
return isinstance(value, int) or (isinstance(value, float) and value.is_integer())
|
|
830
|
+
if kind == "boolean":
|
|
831
|
+
return isinstance(value, bool)
|
|
832
|
+
if kind == "null":
|
|
833
|
+
return value is None
|
|
834
|
+
return True
|
|
835
|
+
|
|
836
|
+
|
|
837
|
+
def shape_errors(value, node, path="$"):
|
|
838
|
+
"""Every way value deviates from the contract schema; empty when it conforms."""
|
|
839
|
+
kind = node.get("kind")
|
|
840
|
+
if kind == "primitive":
|
|
841
|
+
if _primitive_matches(value, node["type"]):
|
|
842
|
+
return []
|
|
843
|
+
return ["%s: expected %s, got %s" % (path, node["type"], _describe(value))]
|
|
844
|
+
if kind == "enum":
|
|
845
|
+
if any(v == value and isinstance(v, bool) == isinstance(value, bool) for v in node["values"]):
|
|
846
|
+
return []
|
|
847
|
+
return ["%s: expected one of %s, got %r" % (path, node["values"], value)]
|
|
848
|
+
if kind == "ref":
|
|
849
|
+
return [] if isinstance(value, dict) else ["%s: expected %s object, got %s" % (path, node["name"], _describe(value))]
|
|
850
|
+
if kind == "array":
|
|
851
|
+
if not isinstance(value, list):
|
|
852
|
+
return ["%s: expected array, got %s" % (path, _describe(value))]
|
|
853
|
+
errors = []
|
|
854
|
+
for i, item in enumerate(value):
|
|
855
|
+
errors.extend(shape_errors(item, node["items"], "%s[%d]" % (path, i)))
|
|
856
|
+
return errors
|
|
857
|
+
if kind == "union":
|
|
858
|
+
if any(not shape_errors(value, v, path) for v in node["variants"]):
|
|
859
|
+
return []
|
|
860
|
+
return ["%s: %r matches none of the allowed types" % (path, value)]
|
|
861
|
+
if kind == "object":
|
|
862
|
+
if not isinstance(value, dict):
|
|
863
|
+
return ["%s: expected object, got %s" % (path, _describe(value))]
|
|
864
|
+
errors = []
|
|
865
|
+
for key, child in node.get("properties", {}).items():
|
|
866
|
+
if key not in value:
|
|
867
|
+
if not child.get("optional"):
|
|
868
|
+
errors.append("%s.%s: missing required field" % (path, key))
|
|
869
|
+
continue
|
|
870
|
+
errors.extend(shape_errors(value[key], child, "%s.%s" % (path, key)))
|
|
871
|
+
return errors
|
|
872
|
+
return []
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
# --- Live database schema (structure only, read through nativ; never rows or credentials) ---
|
|
876
|
+
|
|
877
|
+
_LIVE_SCHEMA = None
|
|
878
|
+
|
|
879
|
+
|
|
880
|
+
def _read_inspect_output():
|
|
881
|
+
path = os.environ.get("NATIV_DB_SCHEMA_JSON")
|
|
882
|
+
if path:
|
|
883
|
+
with open(path, encoding="utf-8") as handle:
|
|
884
|
+
return handle.read()
|
|
885
|
+
command = os.environ.get("NATIV_DB_INSPECT_CMD") or "nativ db inspect --json"
|
|
886
|
+
# nativ exits 1 when the database is unreachable but still prints the JSON status.
|
|
887
|
+
result = subprocess.run(command, shell=True, capture_output=True, encoding="utf-8", errors="replace", timeout=120)
|
|
888
|
+
return result.stdout or ""
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def load_live_schema():
|
|
892
|
+
"""The live schema as {connected, error, tables}; cached for the test session."""
|
|
893
|
+
global _LIVE_SCHEMA
|
|
894
|
+
if _LIVE_SCHEMA is not None:
|
|
895
|
+
return _LIVE_SCHEMA
|
|
896
|
+
try:
|
|
897
|
+
raw = _read_inspect_output()
|
|
898
|
+
parsed = json.loads(raw[raw.index("{"):raw.rindex("}") + 1])
|
|
899
|
+
except (OSError, ValueError, subprocess.SubprocessError):
|
|
900
|
+
parsed = None
|
|
901
|
+
if isinstance(parsed, dict):
|
|
902
|
+
status = parsed.get("status") or {}
|
|
903
|
+
tables = parsed.get("tables")
|
|
904
|
+
_LIVE_SCHEMA = {
|
|
905
|
+
"connected": status.get("connected") is True,
|
|
906
|
+
"error": status.get("error"),
|
|
907
|
+
"tables": tables if isinstance(tables, list) else [],
|
|
908
|
+
}
|
|
909
|
+
else:
|
|
910
|
+
_LIVE_SCHEMA = {
|
|
911
|
+
"connected": False,
|
|
912
|
+
"error": 'could not read "nativ db inspect --json" output (set NATIV_DB_SCHEMA_JSON or NATIV_DB_INSPECT_CMD)',
|
|
913
|
+
"tables": [],
|
|
914
|
+
}
|
|
915
|
+
return _LIVE_SCHEMA
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def _same_names(a, b):
|
|
919
|
+
return sorted(str(n).lower() for n in a) == sorted(str(n).lower() for n in b)
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _find_by_name(items, name):
|
|
923
|
+
wanted = str(name).lower()
|
|
924
|
+
return next((item for item in items or [] if str(item.get("name", "")).lower() == wanted), None)
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _nullability(nullable):
|
|
928
|
+
return "nullable" if nullable else "NOT NULL"
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def db_check(live, assertion):
|
|
932
|
+
"""Checks one generated assertion against the live schema; returns a failure message or None."""
|
|
933
|
+
name = assertion["table"]
|
|
934
|
+
table = _find_by_name(live["tables"], name)
|
|
935
|
+
if table is None:
|
|
936
|
+
return '"%s" not found in the live database' % name
|
|
937
|
+
columns = table.get("columns") or []
|
|
938
|
+
column = _find_by_name(columns, assertion["column"]) if "column" in assertion else None
|
|
939
|
+
qualified = "%s.%s" % (name, assertion.get("column"))
|
|
940
|
+
pk = [c.get("name", "") for c in columns if c.get("primaryKey")]
|
|
941
|
+
kind = assertion["kind"]
|
|
942
|
+
if kind == "table-exists":
|
|
943
|
+
return None
|
|
944
|
+
if kind == "column-exists":
|
|
945
|
+
return None if column else '"%s" not found in the live database' % qualified
|
|
946
|
+
if kind == "primary-key":
|
|
947
|
+
if _same_names(pk, assertion["columns"]):
|
|
948
|
+
return None
|
|
949
|
+
return 'primary key of "%s" is (%s), contract says (%s)' % (name, ", ".join(pk), ", ".join(assertion["columns"]))
|
|
950
|
+
if kind == "nullability":
|
|
951
|
+
if column is None:
|
|
952
|
+
return '"%s" not found in the live database' % qualified
|
|
953
|
+
actual = bool(column.get("nullable"))
|
|
954
|
+
if actual == assertion["nullable"]:
|
|
955
|
+
return None
|
|
956
|
+
return '"%s" is %s in the live database, contract says %s' % (qualified, _nullability(actual), _nullability(assertion["nullable"]))
|
|
957
|
+
if kind == "unique-constraint":
|
|
958
|
+
indexes = table.get("indexes") or []
|
|
959
|
+
if _same_names(pk, assertion["columns"]) or any(
|
|
960
|
+
i.get("unique") and _same_names(i.get("columns") or [], assertion["columns"]) for i in indexes
|
|
961
|
+
):
|
|
962
|
+
return None
|
|
963
|
+
return 'no unique index on "%s" (%s)' % (name, ", ".join(assertion["columns"]))
|
|
964
|
+
return 'unknown assertion kind "%s"' % kind
|
|
965
|
+
`;
|
|
966
|
+
const GO_SUPPORT = String.raw `// Code generated by nativ test gen. DO NOT EDIT.
|
|
967
|
+
// Shared helpers for the generated contract tests. Re-run "nativ test gen" to refresh this file.
|
|
968
|
+
// Environment: NATIV_TEST_BASE_URL overrides the base URL, NATIV_TEST_AUTH_HEADER is sent as Authorization,
|
|
969
|
+
// NATIV_DB_SCHEMA_JSON reads a saved "nativ db inspect --json" file, NATIV_DB_INSPECT_CMD replaces that command.
|
|
970
|
+
|
|
971
|
+
package contract
|
|
972
|
+
|
|
973
|
+
import (
|
|
974
|
+
"bytes"
|
|
975
|
+
"encoding/json"
|
|
976
|
+
"fmt"
|
|
977
|
+
"io"
|
|
978
|
+
"math"
|
|
979
|
+
"net/http"
|
|
980
|
+
"net/url"
|
|
981
|
+
"os"
|
|
982
|
+
"os/exec"
|
|
983
|
+
"runtime"
|
|
984
|
+
"sort"
|
|
985
|
+
"strings"
|
|
986
|
+
"sync"
|
|
987
|
+
"testing"
|
|
988
|
+
"time"
|
|
989
|
+
)
|
|
990
|
+
|
|
991
|
+
var baseURL = strings.TrimRight(envOr("NATIV_TEST_BASE_URL", __NATIV_BASE_URL__), "/")
|
|
992
|
+
|
|
993
|
+
func envOr(key, fallback string) string {
|
|
994
|
+
if v := os.Getenv(key); v != "" {
|
|
995
|
+
return v
|
|
996
|
+
}
|
|
997
|
+
return fallback
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
type contractResponse struct {
|
|
1001
|
+
Status int
|
|
1002
|
+
Body any
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
// callEndpoint sends a request described as JSON: {method, path, query?, headers?, body?}.
|
|
1006
|
+
func callEndpoint(t *testing.T, requestJSON string, anonymous bool) contractResponse {
|
|
1007
|
+
t.Helper()
|
|
1008
|
+
var spec struct {
|
|
1009
|
+
Method string ` + '`json:"method"`' + String.raw `
|
|
1010
|
+
Path string ` + '`json:"path"`' + String.raw `
|
|
1011
|
+
Query map[string]string ` + '`json:"query"`' + String.raw `
|
|
1012
|
+
Headers map[string]string ` + '`json:"headers"`' + String.raw `
|
|
1013
|
+
Body json.RawMessage ` + '`json:"body"`' + String.raw `
|
|
1014
|
+
}
|
|
1015
|
+
if err := json.Unmarshal([]byte(requestJSON), &spec); err != nil {
|
|
1016
|
+
t.Fatalf("invalid generated request: %v", err)
|
|
1017
|
+
}
|
|
1018
|
+
target, err := url.Parse(baseURL + spec.Path)
|
|
1019
|
+
if err != nil {
|
|
1020
|
+
t.Fatalf("invalid request URL: %v", err)
|
|
1021
|
+
}
|
|
1022
|
+
q := target.Query()
|
|
1023
|
+
for k, v := range spec.Query {
|
|
1024
|
+
q.Set(k, v)
|
|
1025
|
+
}
|
|
1026
|
+
target.RawQuery = q.Encode()
|
|
1027
|
+
var body io.Reader
|
|
1028
|
+
if len(spec.Body) > 0 {
|
|
1029
|
+
body = bytes.NewReader(spec.Body)
|
|
1030
|
+
}
|
|
1031
|
+
req, err := http.NewRequest(spec.Method, target.String(), body)
|
|
1032
|
+
if err != nil {
|
|
1033
|
+
t.Fatalf("cannot build request: %v", err)
|
|
1034
|
+
}
|
|
1035
|
+
req.Header.Set("Accept", "application/json")
|
|
1036
|
+
for k, v := range spec.Headers {
|
|
1037
|
+
req.Header.Set(k, v)
|
|
1038
|
+
}
|
|
1039
|
+
if body != nil {
|
|
1040
|
+
req.Header.Set("Content-Type", "application/json")
|
|
1041
|
+
}
|
|
1042
|
+
if auth := os.Getenv("NATIV_TEST_AUTH_HEADER"); auth != "" && !anonymous {
|
|
1043
|
+
req.Header.Set("Authorization", auth)
|
|
1044
|
+
}
|
|
1045
|
+
client := &http.Client{Timeout: 30 * time.Second}
|
|
1046
|
+
res, err := client.Do(req)
|
|
1047
|
+
if err != nil {
|
|
1048
|
+
t.Fatalf("%s %s failed: %v", spec.Method, target.String(), err)
|
|
1049
|
+
}
|
|
1050
|
+
defer res.Body.Close()
|
|
1051
|
+
raw, _ := io.ReadAll(res.Body)
|
|
1052
|
+
var parsed any
|
|
1053
|
+
if len(raw) > 0 {
|
|
1054
|
+
if json.Unmarshal(raw, &parsed) != nil {
|
|
1055
|
+
parsed = string(raw)
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return contractResponse{Status: res.StatusCode, Body: parsed}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
func expectRouteMounted(t *testing.T, res contractResponse) {
|
|
1062
|
+
t.Helper()
|
|
1063
|
+
if res.Status == 404 || res.Status == 405 {
|
|
1064
|
+
t.Fatalf("route not mounted: HTTP %d", res.Status)
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
func expectStatus(t *testing.T, res contractResponse, codes ...int) {
|
|
1069
|
+
t.Helper()
|
|
1070
|
+
for _, c := range codes {
|
|
1071
|
+
if res.Status == c {
|
|
1072
|
+
return
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
t.Fatalf("expected HTTP status in %v, got %d (body: %v)", codes, res.Status, res.Body)
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
func expectShape(t *testing.T, res contractResponse, schemaJSON string) {
|
|
1079
|
+
t.Helper()
|
|
1080
|
+
var node map[string]any
|
|
1081
|
+
if err := json.Unmarshal([]byte(schemaJSON), &node); err != nil {
|
|
1082
|
+
t.Fatalf("invalid generated schema: %v", err)
|
|
1083
|
+
}
|
|
1084
|
+
if errs := shapeErrors(res.Body, node, "$"); len(errs) > 0 {
|
|
1085
|
+
t.Fatalf("response does not match the contract:\n %s", strings.Join(errs, "\n "))
|
|
1086
|
+
}
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
func describeValue(value any) string {
|
|
1090
|
+
switch value.(type) {
|
|
1091
|
+
case nil:
|
|
1092
|
+
return "null"
|
|
1093
|
+
case string:
|
|
1094
|
+
return "string"
|
|
1095
|
+
case float64:
|
|
1096
|
+
return "number"
|
|
1097
|
+
case bool:
|
|
1098
|
+
return "boolean"
|
|
1099
|
+
case []any:
|
|
1100
|
+
return "array"
|
|
1101
|
+
case map[string]any:
|
|
1102
|
+
return "object"
|
|
1103
|
+
}
|
|
1104
|
+
return fmt.Sprintf("%T", value)
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
func primitiveMatches(value any, kind string) bool {
|
|
1108
|
+
switch kind {
|
|
1109
|
+
case "string":
|
|
1110
|
+
_, ok := value.(string)
|
|
1111
|
+
return ok
|
|
1112
|
+
case "number":
|
|
1113
|
+
_, ok := value.(float64)
|
|
1114
|
+
return ok
|
|
1115
|
+
case "integer":
|
|
1116
|
+
f, ok := value.(float64)
|
|
1117
|
+
return ok && f == math.Trunc(f)
|
|
1118
|
+
case "boolean":
|
|
1119
|
+
_, ok := value.(bool)
|
|
1120
|
+
return ok
|
|
1121
|
+
case "null":
|
|
1122
|
+
return value == nil
|
|
1123
|
+
}
|
|
1124
|
+
return true
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
func enumMatches(value any, values []any) bool {
|
|
1128
|
+
for _, v := range values {
|
|
1129
|
+
switch value.(type) {
|
|
1130
|
+
case string, float64, bool:
|
|
1131
|
+
if v == value {
|
|
1132
|
+
return true
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return false
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
// shapeErrors lists every way value deviates from the contract schema; empty when it conforms.
|
|
1140
|
+
func shapeErrors(value any, node map[string]any, path string) []string {
|
|
1141
|
+
switch node["kind"] {
|
|
1142
|
+
case "primitive":
|
|
1143
|
+
kind, _ := node["type"].(string)
|
|
1144
|
+
if primitiveMatches(value, kind) {
|
|
1145
|
+
return nil
|
|
1146
|
+
}
|
|
1147
|
+
return []string{fmt.Sprintf("%s: expected %s, got %s", path, kind, describeValue(value))}
|
|
1148
|
+
case "enum":
|
|
1149
|
+
values, _ := node["values"].([]any)
|
|
1150
|
+
if enumMatches(value, values) {
|
|
1151
|
+
return nil
|
|
1152
|
+
}
|
|
1153
|
+
return []string{fmt.Sprintf("%s: expected one of %v, got %v", path, values, value)}
|
|
1154
|
+
case "ref":
|
|
1155
|
+
if _, ok := value.(map[string]any); ok {
|
|
1156
|
+
return nil
|
|
1157
|
+
}
|
|
1158
|
+
return []string{fmt.Sprintf("%s: expected %v object, got %s", path, node["name"], describeValue(value))}
|
|
1159
|
+
case "array":
|
|
1160
|
+
items, ok := value.([]any)
|
|
1161
|
+
if !ok {
|
|
1162
|
+
return []string{fmt.Sprintf("%s: expected array, got %s", path, describeValue(value))}
|
|
1163
|
+
}
|
|
1164
|
+
child, _ := node["items"].(map[string]any)
|
|
1165
|
+
var errs []string
|
|
1166
|
+
for i, item := range items {
|
|
1167
|
+
errs = append(errs, shapeErrors(item, child, fmt.Sprintf("%s[%d]", path, i))...)
|
|
1168
|
+
}
|
|
1169
|
+
return errs
|
|
1170
|
+
case "union":
|
|
1171
|
+
variants, _ := node["variants"].([]any)
|
|
1172
|
+
for _, v := range variants {
|
|
1173
|
+
if m, ok := v.(map[string]any); ok && len(shapeErrors(value, m, path)) == 0 {
|
|
1174
|
+
return nil
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
return []string{fmt.Sprintf("%s: %v matches none of the allowed types", path, value)}
|
|
1178
|
+
case "object":
|
|
1179
|
+
obj, ok := value.(map[string]any)
|
|
1180
|
+
if !ok {
|
|
1181
|
+
return []string{fmt.Sprintf("%s: expected object, got %s", path, describeValue(value))}
|
|
1182
|
+
}
|
|
1183
|
+
props, _ := node["properties"].(map[string]any)
|
|
1184
|
+
keys := make([]string, 0, len(props))
|
|
1185
|
+
for k := range props {
|
|
1186
|
+
keys = append(keys, k)
|
|
1187
|
+
}
|
|
1188
|
+
sort.Strings(keys)
|
|
1189
|
+
var errs []string
|
|
1190
|
+
for _, key := range keys {
|
|
1191
|
+
child, _ := props[key].(map[string]any)
|
|
1192
|
+
v, present := obj[key]
|
|
1193
|
+
if !present {
|
|
1194
|
+
if optional, _ := child["optional"].(bool); !optional {
|
|
1195
|
+
errs = append(errs, fmt.Sprintf("%s.%s: missing required field", path, key))
|
|
1196
|
+
}
|
|
1197
|
+
continue
|
|
1198
|
+
}
|
|
1199
|
+
errs = append(errs, shapeErrors(v, child, path+"."+key)...)
|
|
1200
|
+
}
|
|
1201
|
+
return errs
|
|
1202
|
+
}
|
|
1203
|
+
return nil
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
// ─── Live database schema (structure only, read through nativ; never rows or credentials) ───
|
|
1207
|
+
|
|
1208
|
+
var (
|
|
1209
|
+
liveOnce sync.Once
|
|
1210
|
+
liveSchema map[string]any
|
|
1211
|
+
liveError string
|
|
1212
|
+
)
|
|
1213
|
+
|
|
1214
|
+
func readInspectOutput() []byte {
|
|
1215
|
+
if file := os.Getenv("NATIV_DB_SCHEMA_JSON"); file != "" {
|
|
1216
|
+
raw, _ := os.ReadFile(file)
|
|
1217
|
+
return raw
|
|
1218
|
+
}
|
|
1219
|
+
command := envOr("NATIV_DB_INSPECT_CMD", "nativ db inspect --json")
|
|
1220
|
+
var cmd *exec.Cmd
|
|
1221
|
+
if runtime.GOOS == "windows" {
|
|
1222
|
+
cmd = exec.Command("cmd", "/C", command)
|
|
1223
|
+
} else {
|
|
1224
|
+
cmd = exec.Command("sh", "-c", command)
|
|
1225
|
+
}
|
|
1226
|
+
// nativ exits 1 when the database is unreachable but still prints the JSON status.
|
|
1227
|
+
raw, _ := cmd.Output()
|
|
1228
|
+
return raw
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
// requireLiveSchema returns the live schema, skipping the test when the database is unreachable.
|
|
1232
|
+
func requireLiveSchema(t *testing.T) map[string]any {
|
|
1233
|
+
t.Helper()
|
|
1234
|
+
liveOnce.Do(func() {
|
|
1235
|
+
raw := readInspectOutput()
|
|
1236
|
+
start, end := bytes.IndexByte(raw, '{'), bytes.LastIndexByte(raw, '}')
|
|
1237
|
+
var parsed map[string]any
|
|
1238
|
+
if start < 0 || end < start || json.Unmarshal(raw[start:end+1], &parsed) != nil {
|
|
1239
|
+
liveError = "could not read \"nativ db inspect --json\" output (set NATIV_DB_SCHEMA_JSON or NATIV_DB_INSPECT_CMD)"
|
|
1240
|
+
return
|
|
1241
|
+
}
|
|
1242
|
+
status, _ := parsed["status"].(map[string]any)
|
|
1243
|
+
if connected, _ := status["connected"].(bool); !connected {
|
|
1244
|
+
liveError = fmt.Sprintf("live database unavailable: %v", status["error"])
|
|
1245
|
+
return
|
|
1246
|
+
}
|
|
1247
|
+
liveSchema = parsed
|
|
1248
|
+
})
|
|
1249
|
+
if liveSchema == nil {
|
|
1250
|
+
t.Skip(liveError)
|
|
1251
|
+
}
|
|
1252
|
+
return liveSchema
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
func stringsOf(value any) []string {
|
|
1256
|
+
items, _ := value.([]any)
|
|
1257
|
+
out := make([]string, 0, len(items))
|
|
1258
|
+
for _, v := range items {
|
|
1259
|
+
if s, ok := v.(string); ok {
|
|
1260
|
+
out = append(out, s)
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
return out
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
func lowerSorted(values []string) []string {
|
|
1267
|
+
out := make([]string, len(values))
|
|
1268
|
+
for i, v := range values {
|
|
1269
|
+
out[i] = strings.ToLower(v)
|
|
1270
|
+
}
|
|
1271
|
+
sort.Strings(out)
|
|
1272
|
+
return out
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
func sameNames(a, b []string) bool {
|
|
1276
|
+
if len(a) != len(b) {
|
|
1277
|
+
return false
|
|
1278
|
+
}
|
|
1279
|
+
x, y := lowerSorted(a), lowerSorted(b)
|
|
1280
|
+
for i := range x {
|
|
1281
|
+
if x[i] != y[i] {
|
|
1282
|
+
return false
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
return true
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
func findByName(items any, name string) map[string]any {
|
|
1289
|
+
list, _ := items.([]any)
|
|
1290
|
+
for _, item := range list {
|
|
1291
|
+
m, ok := item.(map[string]any)
|
|
1292
|
+
if !ok {
|
|
1293
|
+
continue
|
|
1294
|
+
}
|
|
1295
|
+
if n, _ := m["name"].(string); strings.EqualFold(n, name) {
|
|
1296
|
+
return m
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
return nil
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
func nullability(nullable bool) string {
|
|
1303
|
+
if nullable {
|
|
1304
|
+
return "nullable"
|
|
1305
|
+
}
|
|
1306
|
+
return "NOT NULL"
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
// expectDB checks one generated assertion (JSON) against the live schema.
|
|
1310
|
+
func expectDB(t *testing.T, live map[string]any, assertionJSON string) {
|
|
1311
|
+
t.Helper()
|
|
1312
|
+
var a map[string]any
|
|
1313
|
+
if err := json.Unmarshal([]byte(assertionJSON), &a); err != nil {
|
|
1314
|
+
t.Fatalf("invalid generated assertion: %v", err)
|
|
1315
|
+
}
|
|
1316
|
+
if msg := dbCheck(live, a); msg != "" {
|
|
1317
|
+
t.Fatal(msg)
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// dbCheck returns a failure message, or "" when the live schema satisfies the assertion.
|
|
1322
|
+
func dbCheck(live map[string]any, a map[string]any) string {
|
|
1323
|
+
name, _ := a["table"].(string)
|
|
1324
|
+
table := findByName(live["tables"], name)
|
|
1325
|
+
if table == nil {
|
|
1326
|
+
return fmt.Sprintf("%q not found in the live database", name)
|
|
1327
|
+
}
|
|
1328
|
+
columnName, hasColumn := a["column"].(string)
|
|
1329
|
+
var column map[string]any
|
|
1330
|
+
if hasColumn {
|
|
1331
|
+
column = findByName(table["columns"], columnName)
|
|
1332
|
+
}
|
|
1333
|
+
qualified := name + "." + columnName
|
|
1334
|
+
var pk []string
|
|
1335
|
+
cols, _ := table["columns"].([]any)
|
|
1336
|
+
for _, c := range cols {
|
|
1337
|
+
if m, ok := c.(map[string]any); ok {
|
|
1338
|
+
if isPK, _ := m["primaryKey"].(bool); isPK {
|
|
1339
|
+
n, _ := m["name"].(string)
|
|
1340
|
+
pk = append(pk, n)
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
expected := stringsOf(a["columns"])
|
|
1345
|
+
switch a["kind"] {
|
|
1346
|
+
case "table-exists":
|
|
1347
|
+
return ""
|
|
1348
|
+
case "column-exists":
|
|
1349
|
+
if column != nil {
|
|
1350
|
+
return ""
|
|
1351
|
+
}
|
|
1352
|
+
return fmt.Sprintf("%q not found in the live database", qualified)
|
|
1353
|
+
case "primary-key":
|
|
1354
|
+
if sameNames(pk, expected) {
|
|
1355
|
+
return ""
|
|
1356
|
+
}
|
|
1357
|
+
return fmt.Sprintf("primary key of %q is (%s), contract says (%s)", name, strings.Join(pk, ", "), strings.Join(expected, ", "))
|
|
1358
|
+
case "nullability":
|
|
1359
|
+
if column == nil {
|
|
1360
|
+
return fmt.Sprintf("%q not found in the live database", qualified)
|
|
1361
|
+
}
|
|
1362
|
+
actual, _ := column["nullable"].(bool)
|
|
1363
|
+
want, _ := a["nullable"].(bool)
|
|
1364
|
+
if actual == want {
|
|
1365
|
+
return ""
|
|
1366
|
+
}
|
|
1367
|
+
return fmt.Sprintf("%q is %s in the live database, contract says %s", qualified, nullability(actual), nullability(want))
|
|
1368
|
+
case "unique-constraint":
|
|
1369
|
+
if sameNames(pk, expected) {
|
|
1370
|
+
return ""
|
|
1371
|
+
}
|
|
1372
|
+
indexes, _ := table["indexes"].([]any)
|
|
1373
|
+
for _, i := range indexes {
|
|
1374
|
+
m, _ := i.(map[string]any)
|
|
1375
|
+
if unique, _ := m["unique"].(bool); unique && sameNames(stringsOf(m["columns"]), expected) {
|
|
1376
|
+
return ""
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
return fmt.Sprintf("no unique index on %q (%s)", name, strings.Join(expected, ", "))
|
|
1380
|
+
}
|
|
1381
|
+
return fmt.Sprintf("unknown assertion kind %v", a["kind"])
|
|
1382
|
+
}
|
|
1383
|
+
`;
|
|
1384
|
+
const JEST_LIKE = {
|
|
1385
|
+
expectRouteMounted: 'expect([404, 405]).not.toContain(res.status);',
|
|
1386
|
+
expectStatus: (codes) => codes.length === 1 ? `expect(res.status).toBe(${codes[0]});` : `expect(${JSON.stringify(codes)}).toContain(res.status);`,
|
|
1387
|
+
expectNoShapeErrors: 'expect(shapeErrors(res.body, schema)).toEqual([]);',
|
|
1388
|
+
expectNull: (expr) => `expect(${expr}).toBeNull();`,
|
|
1389
|
+
};
|
|
1390
|
+
const VITEST_DIALECT = {
|
|
1391
|
+
framework: 'vitest',
|
|
1392
|
+
typed: true,
|
|
1393
|
+
fileExtension: '.contract.test.ts',
|
|
1394
|
+
supportFile: 'nativ-contract-support.ts',
|
|
1395
|
+
supportImport: '../nativ-contract-support',
|
|
1396
|
+
runnerImports: ["import { describe, it, expect } from 'vitest';"],
|
|
1397
|
+
...JEST_LIKE,
|
|
1398
|
+
};
|
|
1399
|
+
const JEST_DIALECT = {
|
|
1400
|
+
framework: 'jest',
|
|
1401
|
+
typed: true,
|
|
1402
|
+
fileExtension: '.contract.test.ts',
|
|
1403
|
+
supportFile: 'nativ-contract-support.ts',
|
|
1404
|
+
supportImport: '../nativ-contract-support',
|
|
1405
|
+
runnerImports: ["import { describe, it, expect } from '@jest/globals';"],
|
|
1406
|
+
...JEST_LIKE,
|
|
1407
|
+
};
|
|
1408
|
+
const NODE_TEST_DIALECT = {
|
|
1409
|
+
framework: 'node:test',
|
|
1410
|
+
typed: false,
|
|
1411
|
+
fileExtension: '.contract.test.mjs',
|
|
1412
|
+
supportFile: 'nativ-contract-support.mjs',
|
|
1413
|
+
supportImport: '../nativ-contract-support.mjs',
|
|
1414
|
+
runnerImports: ["import { describe, it } from 'node:test';", "import assert from 'node:assert/strict';"],
|
|
1415
|
+
expectRouteMounted: "assert.ok(![404, 405].includes(res.status), 'route not mounted: HTTP ' + res.status);",
|
|
1416
|
+
expectStatus: (codes) => `assert.ok(${JSON.stringify(codes)}.includes(res.status), 'expected HTTP ${codes.join('/')}, got ' + res.status);`,
|
|
1417
|
+
expectNoShapeErrors: 'assert.deepEqual(shapeErrors(res.body, schema), []);',
|
|
1418
|
+
expectNull: (expr) => `assert.equal(${expr}, null);`,
|
|
1419
|
+
};
|
|
1420
|
+
function renderJsApiCase(d, c) {
|
|
1421
|
+
const pad = ' ';
|
|
1422
|
+
const lines = [` it(${JSON.stringify(c.name)}, async () => {`];
|
|
1423
|
+
const requestType = d.typed ? ': ContractRequest' : '';
|
|
1424
|
+
lines.push(`${pad}const request${requestType} = ${hang(JSON.stringify(c.request, null, 2), pad)};`);
|
|
1425
|
+
lines.push(`${pad}const res = await callEndpoint(request${c.kind === 'auth-required' ? ', { anonymous: true }' : ''});`);
|
|
1426
|
+
if (c.kind === 'route')
|
|
1427
|
+
lines.push(pad + d.expectRouteMounted);
|
|
1428
|
+
for (const a of c.assertions) {
|
|
1429
|
+
if (a.kind === 'status')
|
|
1430
|
+
lines.push(pad + d.expectStatus(statusList(a.expected)));
|
|
1431
|
+
else if (a.kind === 'response-shape') {
|
|
1432
|
+
const schemaType = d.typed ? ': SchemaNode' : '';
|
|
1433
|
+
lines.push(`${pad}const schema${schemaType} = ${hang(JSON.stringify(a.schema, null, 2), pad)};`);
|
|
1434
|
+
lines.push(pad + d.expectNoShapeErrors);
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
lines.push(' });');
|
|
1438
|
+
return lines;
|
|
1439
|
+
}
|
|
1440
|
+
function renderJsApiSuite(d, suite) {
|
|
1441
|
+
const usesShape = suite.cases.some((c) => c.assertions.some((a) => a.kind === 'response-shape'));
|
|
1442
|
+
const names = ['callEndpoint', ...(usesShape ? ['shapeErrors'] : [])];
|
|
1443
|
+
if (d.typed)
|
|
1444
|
+
names.push('type ContractRequest', ...(usesShape ? ['type SchemaNode'] : []));
|
|
1445
|
+
const lines = [
|
|
1446
|
+
...suiteHeader('//', suite),
|
|
1447
|
+
...d.runnerImports,
|
|
1448
|
+
`import { ${names.join(', ')} } from '${d.supportImport}';`,
|
|
1449
|
+
'',
|
|
1450
|
+
`describe(${JSON.stringify(suite.name)}, () => {`,
|
|
1451
|
+
];
|
|
1452
|
+
suite.cases.forEach((c, i) => {
|
|
1453
|
+
if (i > 0)
|
|
1454
|
+
lines.push('');
|
|
1455
|
+
lines.push(...renderJsApiCase(d, c));
|
|
1456
|
+
});
|
|
1457
|
+
lines.push('});', '');
|
|
1458
|
+
return lines.join('\n');
|
|
1459
|
+
}
|
|
1460
|
+
function tableOf(suite) {
|
|
1461
|
+
return suite.source.kind === 'table' ? suite.source.table : suite.id;
|
|
1462
|
+
}
|
|
1463
|
+
function renderJsDbSuite(d, suite) {
|
|
1464
|
+
const names = ['dbCheck', 'loadLiveSchema', ...(d.typed ? ['type DbAssertion'] : [])];
|
|
1465
|
+
const lines = [
|
|
1466
|
+
...suiteHeader('//', suite),
|
|
1467
|
+
...d.runnerImports,
|
|
1468
|
+
`import { ${names.join(', ')} } from '${d.supportImport}';`,
|
|
1469
|
+
'',
|
|
1470
|
+
'const live = loadLiveSchema();',
|
|
1471
|
+
];
|
|
1472
|
+
if (d.framework === 'node:test') {
|
|
1473
|
+
lines.push('', `describe(${JSON.stringify(suite.name)}, { skip: live.connected ? false : 'live database unavailable: ' + live.error }, () => {`);
|
|
1474
|
+
}
|
|
1475
|
+
else {
|
|
1476
|
+
lines.push("if (!live.connected) console.warn('[nativ] skipping database contract tests: ' + live.error);", 'const describeLive = live.connected ? describe : describe.skip;', '', `describeLive(${JSON.stringify(suite.name)}, () => {`);
|
|
1477
|
+
}
|
|
1478
|
+
const pad = ' ';
|
|
1479
|
+
suite.cases.forEach((c, i) => {
|
|
1480
|
+
if (i > 0)
|
|
1481
|
+
lines.push('');
|
|
1482
|
+
lines.push(` it(${JSON.stringify(c.name)}, () => {`);
|
|
1483
|
+
for (const a of c.assertions) {
|
|
1484
|
+
const cast = d.typed ? ' as DbAssertion' : '';
|
|
1485
|
+
lines.push(pad + d.expectNull(`dbCheck(live, ${hang(JSON.stringify(a, null, 2), pad)}${cast})`));
|
|
1486
|
+
}
|
|
1487
|
+
lines.push(' });');
|
|
1488
|
+
});
|
|
1489
|
+
lines.push('});', '');
|
|
1490
|
+
return lines.join('\n');
|
|
1491
|
+
}
|
|
1492
|
+
function createJsAdapter(d) {
|
|
1493
|
+
return {
|
|
1494
|
+
framework: d.framework,
|
|
1495
|
+
language: d.typed ? 'typescript' : 'javascript',
|
|
1496
|
+
fileExtension: d.fileExtension,
|
|
1497
|
+
suiteFileName: (suite) => suite.suiteType === 'db-integrity' ? `db/${slug(tableOf(suite))}${d.fileExtension}` : `api/${slug(suite.id)}${d.fileExtension}`,
|
|
1498
|
+
renderSuite: (suite) => (suite.suiteType === 'db-integrity' ? renderJsDbSuite(d, suite) : renderJsApiSuite(d, suite)),
|
|
1499
|
+
renderSupportFiles: (ctx) => [
|
|
1500
|
+
{ relativePath: d.supportFile, content: applyBaseUrl(d.typed ? TS_SUPPORT : JS_SUPPORT, ctx.baseUrl), suiteType: 'support' },
|
|
1501
|
+
],
|
|
1502
|
+
};
|
|
1503
|
+
}
|
|
1504
|
+
// ─── pytest adapter ────────────────────────────────────────────────────────
|
|
1505
|
+
function renderPyApiCase(suite, c) {
|
|
1506
|
+
const pad = ' ';
|
|
1507
|
+
const lines = [`def test_${snake(suite.id)}_${snake(c.kind)}():`, `${pad}${JSON.stringify(`${suite.name}: ${c.name}`)}`];
|
|
1508
|
+
lines.push(`${pad}request = ${pyLiteral(c.request, 1)}`);
|
|
1509
|
+
lines.push(`${pad}status, body = call_endpoint(request${c.kind === 'auth-required' ? ', anonymous=True' : ''})`);
|
|
1510
|
+
if (c.kind === 'route')
|
|
1511
|
+
lines.push(`${pad}assert status not in (404, 405), "route not mounted: HTTP %d" % status`);
|
|
1512
|
+
for (const a of c.assertions) {
|
|
1513
|
+
if (a.kind === 'status') {
|
|
1514
|
+
const codes = statusList(a.expected);
|
|
1515
|
+
lines.push(`${pad}assert status in ${pyTuple(codes)}, "expected HTTP ${codes.join('/')}, got %d: %r" % (status, body)`);
|
|
1516
|
+
}
|
|
1517
|
+
else if (a.kind === 'response-shape') {
|
|
1518
|
+
lines.push(`${pad}schema = ${pyLiteral(a.schema, 1)}`);
|
|
1519
|
+
lines.push(`${pad}errors = shape_errors(body, schema)`);
|
|
1520
|
+
lines.push(`${pad}assert errors == [], "\\n".join(errors)`);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
return lines;
|
|
1524
|
+
}
|
|
1525
|
+
function renderPyApiSuite(suite) {
|
|
1526
|
+
const usesShape = suite.cases.some((c) => c.assertions.some((a) => a.kind === 'response-shape'));
|
|
1527
|
+
const lines = [
|
|
1528
|
+
...suiteHeader('#', suite),
|
|
1529
|
+
`from nativ_contract_support import call_endpoint${usesShape ? ', shape_errors' : ''}`,
|
|
1530
|
+
];
|
|
1531
|
+
for (const c of suite.cases)
|
|
1532
|
+
lines.push('', '', ...renderPyApiCase(suite, c));
|
|
1533
|
+
lines.push('');
|
|
1534
|
+
return lines.join('\n');
|
|
1535
|
+
}
|
|
1536
|
+
function renderPyDbSuite(suite) {
|
|
1537
|
+
const lines = [
|
|
1538
|
+
...suiteHeader('#', suite),
|
|
1539
|
+
'import pytest',
|
|
1540
|
+
'',
|
|
1541
|
+
'from nativ_contract_support import db_check, load_live_schema',
|
|
1542
|
+
'',
|
|
1543
|
+
'LIVE = load_live_schema()',
|
|
1544
|
+
'pytestmark = pytest.mark.skipif(not LIVE["connected"], reason="live database unavailable: %s" % LIVE["error"])',
|
|
1545
|
+
];
|
|
1546
|
+
for (const c of suite.cases) {
|
|
1547
|
+
lines.push('', '', `def test_${snake(c.id)}():`, ` ${JSON.stringify(`${suite.name}: ${c.name}`)}`);
|
|
1548
|
+
for (const a of c.assertions) {
|
|
1549
|
+
lines.push(` error = db_check(LIVE, ${pyLiteral(a, 1)})`, ' assert error is None, error');
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
lines.push('');
|
|
1553
|
+
return lines.join('\n');
|
|
1554
|
+
}
|
|
1555
|
+
const PYTEST_ADAPTER = {
|
|
1556
|
+
framework: 'pytest',
|
|
1557
|
+
language: 'python',
|
|
1558
|
+
fileExtension: '.py',
|
|
1559
|
+
suiteFileName: (suite) => suite.suiteType === 'db-integrity' ? `test_db_${snake(tableOf(suite))}.py` : `test_api_${snake(suite.id)}.py`,
|
|
1560
|
+
renderSuite: (suite) => (suite.suiteType === 'db-integrity' ? renderPyDbSuite(suite) : renderPyApiSuite(suite)),
|
|
1561
|
+
renderSupportFiles: (ctx) => [
|
|
1562
|
+
{ relativePath: 'nativ_contract_support.py', content: applyBaseUrl(PY_SUPPORT, ctx.baseUrl), suiteType: 'support' },
|
|
1563
|
+
],
|
|
1564
|
+
};
|
|
1565
|
+
// ─── Go adapter ────────────────────────────────────────────────────────────
|
|
1566
|
+
function renderGoApiCase(c) {
|
|
1567
|
+
const pad = '\t\t';
|
|
1568
|
+
const lines = [`\tt.Run(${JSON.stringify(c.name)}, func(t *testing.T) {`];
|
|
1569
|
+
lines.push(`${pad}res := callEndpoint(t, ${goJson(c.request)}, ${c.kind === 'auth-required'})`);
|
|
1570
|
+
if (c.kind === 'route')
|
|
1571
|
+
lines.push(`${pad}expectRouteMounted(t, res)`);
|
|
1572
|
+
for (const a of c.assertions) {
|
|
1573
|
+
if (a.kind === 'status')
|
|
1574
|
+
lines.push(`${pad}expectStatus(t, res, ${statusList(a.expected).join(', ')})`);
|
|
1575
|
+
else if (a.kind === 'response-shape')
|
|
1576
|
+
lines.push(`${pad}expectShape(t, res, ${goJson(a.schema)})`);
|
|
1577
|
+
}
|
|
1578
|
+
lines.push('\t})');
|
|
1579
|
+
return lines;
|
|
1580
|
+
}
|
|
1581
|
+
function renderGoApiSuite(suite) {
|
|
1582
|
+
const lines = [...suiteHeader('//', suite), '', 'package contract', '', 'import "testing"', '', `func TestAPI${pascal(suite.id)}(t *testing.T) {`];
|
|
1583
|
+
suite.cases.forEach((c, i) => {
|
|
1584
|
+
if (i > 0)
|
|
1585
|
+
lines.push('');
|
|
1586
|
+
lines.push(...renderGoApiCase(c));
|
|
1587
|
+
});
|
|
1588
|
+
lines.push('}', '');
|
|
1589
|
+
return lines.join('\n');
|
|
1590
|
+
}
|
|
1591
|
+
function renderGoDbSuite(suite) {
|
|
1592
|
+
const lines = [
|
|
1593
|
+
...suiteHeader('//', suite),
|
|
1594
|
+
'',
|
|
1595
|
+
'package contract',
|
|
1596
|
+
'',
|
|
1597
|
+
'import "testing"',
|
|
1598
|
+
'',
|
|
1599
|
+
`func TestDB${pascal(tableOf(suite))}(t *testing.T) {`,
|
|
1600
|
+
'\tlive := requireLiveSchema(t)',
|
|
1601
|
+
];
|
|
1602
|
+
for (const c of suite.cases) {
|
|
1603
|
+
lines.push('', `\tt.Run(${JSON.stringify(c.name)}, func(t *testing.T) {`);
|
|
1604
|
+
for (const a of c.assertions)
|
|
1605
|
+
lines.push(`\t\texpectDB(t, live, ${goJson(a)})`);
|
|
1606
|
+
lines.push('\t})');
|
|
1607
|
+
}
|
|
1608
|
+
lines.push('}', '');
|
|
1609
|
+
return lines.join('\n');
|
|
1610
|
+
}
|
|
1611
|
+
const GO_ADAPTER = {
|
|
1612
|
+
framework: 'go',
|
|
1613
|
+
language: 'go',
|
|
1614
|
+
fileExtension: '_test.go',
|
|
1615
|
+
suiteFileName: (suite) => suite.suiteType === 'db-integrity' ? `db_${snake(tableOf(suite))}_test.go` : `api_${snake(suite.id)}_test.go`,
|
|
1616
|
+
renderSuite: (suite) => (suite.suiteType === 'db-integrity' ? renderGoDbSuite(suite) : renderGoApiSuite(suite)),
|
|
1617
|
+
renderSupportFiles: (ctx) => [
|
|
1618
|
+
{ relativePath: 'nativ_support_test.go', content: applyBaseUrl(GO_SUPPORT, ctx.baseUrl), suiteType: 'support' },
|
|
1619
|
+
],
|
|
1620
|
+
};
|
|
1621
|
+
export const TEST_FRAMEWORK_ADAPTERS = {
|
|
1622
|
+
vitest: createJsAdapter(VITEST_DIALECT),
|
|
1623
|
+
jest: createJsAdapter(JEST_DIALECT),
|
|
1624
|
+
'node:test': createJsAdapter(NODE_TEST_DIALECT),
|
|
1625
|
+
pytest: PYTEST_ADAPTER,
|
|
1626
|
+
go: GO_ADAPTER,
|
|
1627
|
+
};
|
|
1628
|
+
// ─── Orchestration ─────────────────────────────────────────────────────────
|
|
1629
|
+
function toDisplayPath(targetDir, filePath) {
|
|
1630
|
+
const rel = path.relative(targetDir, filePath);
|
|
1631
|
+
const inside = rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
1632
|
+
return (inside ? rel || '.' : filePath).split(path.sep).join('/');
|
|
1633
|
+
}
|
|
1634
|
+
/** Writes generated files, refusing to overwrite any file that was not generated by nativ. */
|
|
1635
|
+
function writeGeneratedFiles(outputDir, files) {
|
|
1636
|
+
const resolved = files.map((file) => {
|
|
1637
|
+
const filePath = path.resolve(outputDir, file.relativePath);
|
|
1638
|
+
const rel = path.relative(outputDir, filePath);
|
|
1639
|
+
if (rel.startsWith('..') || path.isAbsolute(rel)) {
|
|
1640
|
+
throw new TestGenError('INVALID_OUTPUT_PATH', `Generated path escapes the output directory: ${file.relativePath}`);
|
|
1641
|
+
}
|
|
1642
|
+
return { filePath, file };
|
|
1643
|
+
});
|
|
1644
|
+
for (const { filePath, file } of resolved) {
|
|
1645
|
+
if (!fs.existsSync(filePath))
|
|
1646
|
+
continue;
|
|
1647
|
+
const head = fs.readFileSync(filePath, 'utf8').slice(0, 512);
|
|
1648
|
+
if (!GENERATED_MARKER_PATTERN.test(head)) {
|
|
1649
|
+
throw new TestGenError('OUTPUT_CONFLICT', `Refusing to overwrite ${file.relativePath}: it was not generated by nativ test gen`);
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
for (const { filePath, file } of resolved) {
|
|
1653
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
1654
|
+
fs.writeFileSync(filePath, file.content, 'utf8');
|
|
1655
|
+
}
|
|
1656
|
+
}
|
|
1657
|
+
/** Compiles .ai contracts into test files for the chosen framework (written unless dryRun). */
|
|
1658
|
+
export function generateContractTests(options) {
|
|
1659
|
+
const started = Date.now();
|
|
1660
|
+
const opts = resolveTestGenOptions(options);
|
|
1661
|
+
const api = loadApiContracts(opts.targetDir);
|
|
1662
|
+
const db = loadDbSchemaContract(opts.targetDir);
|
|
1663
|
+
if (!api && !db) {
|
|
1664
|
+
throw new TestGenError('NO_CONTRACTS', `No .ai/api_contracts.json or .ai/db_schema.json found in ${opts.targetDir}`);
|
|
1665
|
+
}
|
|
1666
|
+
const endpointSuites = api ? buildEndpointSuites(api) : [];
|
|
1667
|
+
const tableSuites = db ? buildDbSuites(db) : [];
|
|
1668
|
+
const suites = [...endpointSuites, ...tableSuites];
|
|
1669
|
+
const adapter = TEST_FRAMEWORK_ADAPTERS[opts.framework];
|
|
1670
|
+
const ctx = {
|
|
1671
|
+
projectName: api?.projectName || db?.projectName || path.basename(opts.targetDir),
|
|
1672
|
+
baseUrl: opts.baseUrl,
|
|
1673
|
+
engine: db?.engine ?? null,
|
|
1674
|
+
};
|
|
1675
|
+
const files = suites.length ? [...(adapter.renderSupportFiles?.(ctx) ?? [])] : [];
|
|
1676
|
+
for (const suite of suites) {
|
|
1677
|
+
files.push({ relativePath: adapter.suiteFileName(suite), content: adapter.renderSuite(suite, ctx), suiteType: suite.suiteType });
|
|
1678
|
+
}
|
|
1679
|
+
if (!opts.dryRun)
|
|
1680
|
+
writeGeneratedFiles(opts.outputDir, files);
|
|
1681
|
+
return {
|
|
1682
|
+
projectName: ctx.projectName,
|
|
1683
|
+
framework: opts.framework,
|
|
1684
|
+
outputDir: toDisplayPath(opts.targetDir, opts.outputDir),
|
|
1685
|
+
files,
|
|
1686
|
+
endpointsCovered: endpointSuites.length,
|
|
1687
|
+
tablesCovered: tableSuites.length,
|
|
1688
|
+
durationMs: Date.now() - started,
|
|
1689
|
+
};
|
|
1690
|
+
}
|