@sungen/driver-api 3.1.2-beta.123 → 3.1.2-beta.125

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sungen/driver-api",
3
- "version": "3.1.2-beta.123",
3
+ "version": "3.1.2-beta.125",
4
4
  "description": "Sungen API capability — the @api annotation, API catalog, OpenAPI import + `sungen api import`, and the specs/api.ts runtime template. Plugs into @sun-asterisk/sungen via the capability SPI.",
5
5
  "main": "src/index.ts",
6
6
  "types": "src/index.ts",
@@ -13,7 +13,7 @@
13
13
  "author": "eqe team (engineer & quality) — Sun Asterisk",
14
14
  "license": "MIT",
15
15
  "dependencies": {
16
- "@sun-asterisk/sungen": "3.1.2-beta.123",
16
+ "@sun-asterisk/sungen": "3.1.2-beta.125",
17
17
  "commander": "^14.0.2",
18
18
  "yaml": "^2.8.2"
19
19
  },
@@ -27,6 +27,7 @@ export interface ApiEntry {
27
27
  method: HttpMethod;
28
28
  path: string;
29
29
  body?: unknown;
30
+ encoding?: 'json' | 'form' | 'multipart'; // request-body wire format (default json); form = application/x-www-form-urlencoded
30
31
  headers?: Record<string, string>; // per-endpoint header templates; `:param` tokens bind at runtime (flow auth, e.g. authorization: "Bearer :token")
31
32
  params: string[];
32
33
  expect?: { status?: number | string };
@@ -89,6 +90,7 @@ function parseEntries(file: string, scope: 'screen' | 'shared'): Map<string, Api
89
90
  method: String(v.method).toUpperCase() as HttpMethod,
90
91
  path: v.path,
91
92
  body: v.body,
93
+ encoding: v.encoding === 'form' || v.encoding === 'multipart' ? v.encoding : undefined, // default (undefined) = json
92
94
  headers: v.headers && typeof v.headers === 'object' ? Object.fromEntries(Object.entries(v.headers).map(([k, hv]) => [k, String(hv)])) : undefined,
93
95
  params: Array.isArray(v.params) ? v.params.map((p: any) => String(p)) : [],
94
96
  expect: v.expect && typeof v.expect === 'object' ? { status: v.expect.status } : undefined,
@@ -22,6 +22,7 @@ interface ApiScenario {
22
22
  queryRefs?: string[];
23
23
  casesDataset?: string;
24
24
  stepsText?: string; // lowercase steps text
25
+ manual?: boolean; // @manual — not automated, so excluded from automation businessDepth
25
26
  }
26
27
 
27
28
  interface ApiGateInput {
@@ -112,7 +113,10 @@ export function apiGateProvider(input: ApiGateInput): { gate: GateResult; depth:
112
113
  // An error scenario (a @cases matrix, or one asserting a 4xx/5xx) proves the failure CONTRACT —
113
114
  // the status IS its correct assertion (the API analog of UI NAV), so it is not depth-required.
114
115
  const errorLike = (s: ApiScenario) => !!s.casesDataset || /\bis\s+["']?[45]\d\d\b/.test(s.stepsText ?? '');
115
- const depthRequired = apiScenarios.filter((s) => (s.apiRefs ?? []).some(isMutating) && !errorLike(s));
116
+ // businessDepth measures AUTOMATED proof a @manual scenario isn't automated, so it can't count
117
+ // toward depth (else deferring an automatable mutation to @manual inflates the score). The
118
+ // api-viewpoints sensor separately flags @manual scenarios whose endpoint resolves (automatable).
119
+ const depthRequired = apiScenarios.filter((s) => !s.manual && (s.apiRefs ?? []).some(isMutating) && !errorLike(s));
116
120
  const isDeep = (s: ApiScenario): boolean => {
117
121
  const t = s.stepsText ?? '';
118
122
  return /\b[a-z_][a-z0-9_]*\.body\b/.test(t) // asserts a response body field
package/src/api-repair.ts CHANGED
@@ -21,6 +21,8 @@ export const apiRepair: RepairProvider = {
21
21
  fix: 'A mutating success scenario only asserts the status — make it PROVE THE EFFECT: assert a response body field, a `@query` DB side-effect, or a `@concurrent` `ok_count` invariant.' },
22
22
  { id: 'api-verification', match: /VERIFICATION-FAIL|does not resolve|not found in/i,
23
23
  fix: 'An `@api:<name>` does not resolve in the catalog — fix the name, or add/import the endpoint into `api/apis.yaml` (`sungen api import`).' },
24
+ { id: 'api-manual-automatable', match: /VIEWPOINT-API-MANUAL-AUTOMATABLE/i,
25
+ fix: 'A @manual scenario is automatable (its endpoint resolves) — drop @manual and use @api (+ @cases for error rows / @concurrent for idempotency). Reserve @manual for genuine judgment cases.' },
24
26
 
25
27
  // ── Runtime failures (from the Playwright test-result) ────────────────────────────────────
26
28
  { id: 'api-auth', match: /\b40[13]\b|unauthor|forbidden/i,
package/src/index.ts CHANGED
@@ -85,7 +85,9 @@ function apiPreconditionCodegen(input: { tags: string[]; screenName: string; cwd
85
85
  // their `:param` placeholders bind at runtime from the same params — this is the flow auth/token
86
86
  // mechanism (the dynamic value comes via an override like `token={{login.body.token}}`).
87
87
  const hdr = entry.headers && Object.keys(entry.headers).length ? `, headers: ${JSON.stringify(entry.headers)}` : '';
88
- const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${hdr}, datasource: ${ds} }`;
88
+ // request-body wire format (default json); `form` x-www-form-urlencoded, `multipart` form-data (#345).
89
+ const enc = entry.encoding ? `, encoding: ${JSON.stringify(entry.encoding)}` : '';
90
+ const req = `{ method: ${JSON.stringify(entry.method)}, path: ${JSON.stringify(entry.path)}${entry.body !== undefined ? `, body: ${JSON.stringify(entry.body)}` : ''}${enc}${hdr}, datasource: ${ds} }`;
89
91
  // @concurrent:N → callN (N parallel calls, aggregate result); else a single call. @hybrid adds
90
92
  // the storageState opts so the request reuses the UI auth session.
91
93
  const callExpr = concurrent
@@ -157,6 +159,16 @@ const apiViewpointSensor: Sensor<GateInput> = {
157
159
  }
158
160
  }
159
161
 
162
+ // api-manual-automatable — a @manual scenario whose @api endpoint RESOLVES is automatable; flag it
163
+ // so automatable work isn't deferred to manual (a common run-test reflex when the call failed).
164
+ for (const s of apiScenarios) {
165
+ if (!s.manual) continue;
166
+ const automatable = (s.apiRefs ?? []).filter((r) => { try { resolveApi(r, screenName, cwd); return true; } catch { return false; } });
167
+ if (automatable.length) {
168
+ add('warn', `VIEWPOINT-API-MANUAL-AUTOMATABLE: "${s.name}" is @manual but its endpoint(s) ${automatable.join(', ')} resolve in the catalog — automate with @api (+ @cases for error rows); don't defer an automatable API check to manual.`);
169
+ }
170
+ }
171
+
160
172
  // Method-aware: error + idempotency only apply when a referenced endpoint MUTATES.
161
173
  const mutating: string[] = [];
162
174
  for (const name of refs) {
@@ -14,6 +14,7 @@ export interface ImportedApiEntry {
14
14
  path: string;
15
15
  params?: string[];
16
16
  body?: Record<string, string>;
17
+ encoding?: 'form' | 'multipart'; // set when the requestBody is form/multipart (json omitted = default)
17
18
  expect?: { status: number };
18
19
  }
19
20
 
@@ -52,16 +53,24 @@ function expectedStatus(responses: Record<string, unknown> | undefined): number
52
53
  return success ?? codes[0];
53
54
  }
54
55
 
55
- /** JSON-body property names → a `{ prop: ":prop" }` template (best-effort from requestBody schema). */
56
- function bodyTemplate(op: any): { body?: Record<string, string>; props: string[] } {
57
- const schema = op?.requestBody?.content?.['application/json']?.schema;
56
+ /** Body property names → a `{ prop: ":prop" }` template, + the wire encoding (json | form | multipart),
57
+ * best-effort from the requestBody content-type (#345). JSON is the default (encoding omitted). */
58
+ function bodyTemplate(op: any): { body?: Record<string, string>; props: string[]; encoding?: 'form' | 'multipart' } {
59
+ const content = op?.requestBody?.content ?? {};
60
+ // Prefer JSON; else fall back to form-urlencoded / multipart (those set encoding).
61
+ const pick = content['application/json'] ? { ct: 'application/json' as const, encoding: undefined }
62
+ : content['application/x-www-form-urlencoded'] ? { ct: 'application/x-www-form-urlencoded' as const, encoding: 'form' as const }
63
+ : content['multipart/form-data'] ? { ct: 'multipart/form-data' as const, encoding: 'multipart' as const }
64
+ : undefined;
65
+ if (!pick) return { props: [] };
66
+ const schema = content[pick.ct]?.schema;
58
67
  const props = schema && typeof schema === 'object' && schema.properties && typeof schema.properties === 'object'
59
68
  ? Object.keys(schema.properties)
60
69
  : [];
61
- if (!props.length) return { props: [] };
70
+ if (!props.length) return { props: [], encoding: pick.encoding };
62
71
  const body: Record<string, string> = {};
63
72
  for (const p of props) body[p] = `:${p}`;
64
- return { body, props };
73
+ return { body, props, encoding: pick.encoding };
65
74
  }
66
75
 
67
76
  export interface ImportResult {
@@ -89,7 +98,7 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
89
98
 
90
99
  const catalogPath = toCatalogPath(rawPath);
91
100
  const pathParams = (catalogPath.match(/:([A-Za-z_][A-Za-z0-9_]*)/g) || []).map((s) => s.slice(1));
92
- const { body, props } = bodyTemplate(op);
101
+ const { body, props, encoding } = bodyTemplate(op);
93
102
  const params = [...new Set([...pathParams, ...props])];
94
103
 
95
104
  let name = typeof op.operationId === 'string' && /^[A-Za-z_][A-Za-z0-9_]*$/.test(op.operationId)
@@ -107,6 +116,7 @@ export function importOpenApi(doc: any, datasource = 'app_api'): ImportResult {
107
116
  path: catalogPath,
108
117
  ...(params.length ? { params } : {}),
109
118
  ...(body ? { body } : {}),
119
+ ...(encoding ? { encoding } : {}),
110
120
  ...(status != null ? { expect: { status } } : {}),
111
121
  };
112
122
  }