@suluk/journeys 0.3.1 → 0.4.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/bin/journeys.ts +197 -0
- package/package.json +7 -2
- package/src/cli.ts +186 -0
- package/src/coverage.ts +24 -0
- package/src/demos.ts +239 -0
- package/src/emit.ts +83 -15
- package/src/examples.ts +6 -0
- package/src/gherkin.ts +43 -2
- package/src/index.ts +71 -0
- package/src/outline.ts +102 -0
- package/src/promote.ts +226 -0
- package/test/audit.test.ts +90 -0
- package/test/cli.test.ts +78 -0
- package/test/demos.test.ts +126 -0
- package/test/emit-outline.test.ts +95 -0
- package/test/examples-wall.test.ts +31 -0
- package/test/outline.test.ts +105 -0
- package/test/promote-cli.test.ts +85 -0
- package/test/promote.test.ts +129 -0
package/src/emit.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The RUNNABLE emitter (C038): a bound feature set → a self-contained `bun:test` suite that drives each
|
|
3
|
-
* through the consumer's GENERATED @suluk/sdk client (+ its reactive stores), against a LIVE deployment.
|
|
2
|
+
* The RUNNABLE emitter (C038 + C040-P1): a bound feature set → a self-contained `bun:test` suite that drives each
|
|
3
|
+
* scenario through the consumer's GENERATED @suluk/sdk client (+ its reactive stores), against a LIVE deployment.
|
|
4
4
|
*
|
|
5
5
|
* Why the SDK client and not raw HTTP (the distinction from @suluk/testgen, which calls a raw `fetch(BASE+path)`
|
|
6
6
|
* harness): the SDK client is the SAME one a Suluk frontend ships on, so a green scenario exercises the real frontend
|
|
@@ -8,11 +8,16 @@
|
|
|
8
8
|
* invalidation/refetch. HONEST BOUNDARY (emitted as a literal header in the suite): it tests
|
|
9
9
|
* client + contract + wire + the store data layer, NOT rendered UI / layout / visual — there is no DOM in a bun:test.
|
|
10
10
|
*
|
|
11
|
+
* SCENARIO OUTLINES (C040-P1): a scenario with an `Examples:` table is unrolled to ONE test PER ROW; the bound When op's
|
|
12
|
+
* request body is built FROM THE ROW (an Examples column = a request field of that op, by name). An `input` cell is used
|
|
13
|
+
* as a literal (coerced to the field's type); a `sourced` cell `<op.select>` is RESOLVED from a prior step's captured
|
|
14
|
+
* response via the inlined `pick` (carried-data across a journey — `resolveSourced`'s shape). Each bound When's result
|
|
15
|
+
* is captured under its `op.name`, so a later sourced field chains from it. Where a value isn't supplied (no Examples
|
|
16
|
+
* table, or an op with path/query args not yet outline-wired) the emitter writes a "provide input" placeholder — a real
|
|
17
|
+
* gap, never an invented value (the never-launder-thin discipline).
|
|
18
|
+
*
|
|
11
19
|
* The call site is lowered with @suluk/sdk's OWN `resolveOps` + `clientAccessor` — the single source of accessor
|
|
12
|
-
* identity — so the emitted `client.<ns>.<member>(…)` can NEVER drift from the method `generateSdk`
|
|
13
|
-
* Journeys keeps `op.name@path` as the stable handle for IDENTITY; the SDK accessor (which `resolveOps` mutates) is
|
|
14
|
-
* used ONLY here, at the late call-site lowering. Where an operation needs a request value the emitter writes a
|
|
15
|
-
* "provide input" placeholder (a real NEEDS-DEV-GLUE), never an invented value (the never-launder-thin discipline).
|
|
20
|
+
* identity — so the emitted `client.<ns>.<member>(…)` can NEVER drift from the method `generateSdk` emits.
|
|
16
21
|
*/
|
|
17
22
|
import type { OpenAPIv4Document } from "@suluk/core";
|
|
18
23
|
import { resolveOps, clientAccessor, type OpInfo } from "@suluk/sdk";
|
|
@@ -31,6 +36,35 @@ export interface EmitOptions extends BindOptions {
|
|
|
31
36
|
tokenEnv?: string;
|
|
32
37
|
}
|
|
33
38
|
|
|
39
|
+
const JS_ID = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
40
|
+
const jsKey = (k: string) => (JS_ID.test(k) ? k : JSON.stringify(k));
|
|
41
|
+
/** A `sourced` Examples cell: the wiring token `<op.select>` (e.g. `<createSubscription.id>`). */
|
|
42
|
+
const SOURCED_CELL = /^<([A-Za-z_][\w]*)\.(.+)>$/;
|
|
43
|
+
|
|
44
|
+
/** An Examples cell → a TS literal: a `<op.select>` token → `pick(captured,…)`; else coerced by the field's type. */
|
|
45
|
+
function cellLiteral(cell: string, fieldSchema: unknown): string {
|
|
46
|
+
const m = SOURCED_CELL.exec(cell.trim());
|
|
47
|
+
if (m) return `pick(captured, ${JSON.stringify(m[1])}, ${JSON.stringify(m[2])})`;
|
|
48
|
+
const t = fieldSchema && typeof fieldSchema === "object" ? (fieldSchema as { type?: unknown }).type : undefined;
|
|
49
|
+
const type = Array.isArray(t) ? t[0] : t;
|
|
50
|
+
if ((type === "integer" || type === "number") && cell.trim() !== "" && Number.isFinite(Number(cell))) return String(Number(cell));
|
|
51
|
+
if (type === "boolean" && (cell === "true" || cell === "false")) return cell;
|
|
52
|
+
return JSON.stringify(cell);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The call args for an outline row, or null if the op needs path/query args (not yet outline-wired → placeholder). */
|
|
56
|
+
function buildCallArgs(op: OpInfo, cells: Record<string, string>): string | null {
|
|
57
|
+
if (op.pathParams.length || op.queryRaw != null) return null;
|
|
58
|
+
if (op.bodyRaw == null) return "";
|
|
59
|
+
const props = (op.bodyRaw && typeof op.bodyRaw === "object" ? (op.bodyRaw as { properties?: Record<string, unknown> }).properties : undefined) ?? {};
|
|
60
|
+
const entries: string[] = [];
|
|
61
|
+
for (const [h, cell] of Object.entries(cells)) {
|
|
62
|
+
if (!(h in props)) continue; // a column belonging to ANOTHER When op in a multi-step journey
|
|
63
|
+
entries.push(`${jsKey(h)}: ${cellLiteral(cell, (props as Record<string, unknown>)[h])}`);
|
|
64
|
+
}
|
|
65
|
+
return `{ ${entries.join(", ")} }`;
|
|
66
|
+
}
|
|
67
|
+
|
|
34
68
|
/** Emit a runnable bun:test suite (a string) from a parsed, bound feature set, lowered to the real SDK client. */
|
|
35
69
|
export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, features: Feature[], opts: EmitOptions = {}): string {
|
|
36
70
|
const clientModule = opts.clientModule ?? "./sdk";
|
|
@@ -39,6 +73,9 @@ export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, fea
|
|
|
39
73
|
const tokenEnv = opts.tokenEnv ?? "SULUK_USER_TOKEN";
|
|
40
74
|
|
|
41
75
|
const report = bindFeatures(vocab, features, opts);
|
|
76
|
+
// report.scenarios is pushed in feature→scenario order, so it aligns 1:1 with the flattened source scenarios; the
|
|
77
|
+
// source carries the Examples table the binder does not.
|
|
78
|
+
const sourceScenarios = features.flatMap((f) => f.scenarios);
|
|
42
79
|
// accessor identity comes from @suluk/sdk itself, keyed by the STABLE handle (name@uri — neither is mutated by resolveOps).
|
|
43
80
|
const { ops } = resolveOps(doc);
|
|
44
81
|
const opByHandle = new Map<string, OpInfo>(ops.map((op) => [`${op.name}@${op.uri}`, op]));
|
|
@@ -49,6 +86,9 @@ export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, fea
|
|
|
49
86
|
`import { test, expect } from "bun:test";`,
|
|
50
87
|
`import { ${clientFactory} } from "${clientModule}";`,
|
|
51
88
|
``,
|
|
89
|
+
`// resolve a \`sourced\` Examples cell <op.select> from a prior step's captured response (carried-data across a journey).`,
|
|
90
|
+
`const pick = (captured: Record<string, any>, op: string, sel: string) => sel.split(".").reduce((v: any, k) => (v == null ? undefined : v[k]), captured[op]);`,
|
|
91
|
+
``,
|
|
52
92
|
`const client = ${clientFactory}({`,
|
|
53
93
|
` baseURL: process.env.${baseUrlEnv},`,
|
|
54
94
|
` token: () => process.env.${tokenEnv} ?? null, // bearer; cookie auth also works via credentials:"include"`,
|
|
@@ -57,14 +97,13 @@ export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, fea
|
|
|
57
97
|
];
|
|
58
98
|
|
|
59
99
|
const body: string[] = [];
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
body.push(`test(${JSON.stringify(sc.scenario)}, async () => {`);
|
|
100
|
+
|
|
101
|
+
/** Emit the bound When calls (+ outcome assertions) for one scenario run. `cells` non-null ⇒ an outline row (build
|
|
102
|
+
* the body + capture for chaining); null ⇒ the plain path (a "provide input" placeholder). */
|
|
103
|
+
const emitCalls = (results: typeof report.scenarios[number]["results"], cells: Record<string, string> | null) => {
|
|
65
104
|
let n = 0;
|
|
66
105
|
let current: OpInfo | undefined;
|
|
67
|
-
for (const r of
|
|
106
|
+
for (const r of results) {
|
|
68
107
|
if (r.state !== "BOUND") continue;
|
|
69
108
|
if (r.step.kind === "when") {
|
|
70
109
|
current = opByHandle.get(r.handle);
|
|
@@ -72,15 +111,44 @@ export function emitRunnableSuite(doc: OpenAPIv4Document, vocab: Vocabulary, fea
|
|
|
72
111
|
body.push(` // skipped ${r.handle} — not an SDK-surfaced operation (e.g. a webhook); bind via raw HTTP.`);
|
|
73
112
|
continue;
|
|
74
113
|
}
|
|
75
|
-
const acc = clientAccessor(current);
|
|
114
|
+
const acc = clientAccessor(current);
|
|
76
115
|
body.push(` // ${current.method.toUpperCase()} ${current.uri}${current.requires !== "anyone" ? ` (requires ${current.requires}: set ${tokenEnv})` : ""}`);
|
|
77
|
-
|
|
116
|
+
n++;
|
|
117
|
+
if (cells) {
|
|
118
|
+
const args = buildCallArgs(current, cells);
|
|
119
|
+
body.push(args === null
|
|
120
|
+
? ` const result${n} = await client.${acc}(/* provide input — path/query args not yet outline-wired */);`
|
|
121
|
+
: ` const result${n} = await client.${acc}(${args});`);
|
|
122
|
+
body.push(` captured[${JSON.stringify(current.name)}] = result${n};`);
|
|
123
|
+
} else {
|
|
124
|
+
body.push(` const result${n} = await client.${acc}(/* provide input */);`);
|
|
125
|
+
}
|
|
78
126
|
} else if (r.step.kind === "then" && current) {
|
|
79
127
|
if (/succeed/i.test(r.step.text) && n) body.push(` expect(result${n}).toBeDefined();`);
|
|
80
128
|
if (/refresh/i.test(r.step.text)) for (const key of current.store?.invalidates ?? []) body.push(` // store: expect $${key} to have refreshed after this mutation`);
|
|
81
129
|
}
|
|
82
130
|
}
|
|
83
|
-
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
for (let i = 0; i < report.scenarios.length; i++) {
|
|
134
|
+
const sc = report.scenarios[i];
|
|
135
|
+
if (!sc.results.some((r) => r.state === "BOUND" && r.step.kind === "when")) continue;
|
|
136
|
+
const ex = sourceScenarios[i]?.examples;
|
|
137
|
+
|
|
138
|
+
if (ex && ex.headers.length && ex.rows.length) {
|
|
139
|
+
// OUTLINE: one test per Examples row, body built from the row + sourced cells chained via `captured`.
|
|
140
|
+
ex.rows.forEach((row, ri) => {
|
|
141
|
+
const cells = Object.fromEntries(ex.headers.map((h, ci) => [h, row[ci] ?? ""]));
|
|
142
|
+
body.push(`test(${JSON.stringify(`${sc.scenario} — example ${ri + 1}`)}, async () => {`);
|
|
143
|
+
body.push(` const captured: Record<string, any> = {};`);
|
|
144
|
+
emitCalls(sc.results, cells);
|
|
145
|
+
body.push(`});`, ``);
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
body.push(`test(${JSON.stringify(sc.scenario)}, async () => {`);
|
|
149
|
+
emitCalls(sc.results, null);
|
|
150
|
+
body.push(`});`, ``);
|
|
151
|
+
}
|
|
84
152
|
}
|
|
85
153
|
|
|
86
154
|
if (!body.length) body.push("// No scenario bound a When-operation yet — author steps from the generated phrasebook.");
|
package/src/examples.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Example precedence + origin-aware synthesis. The implementation moved to the shared zero-dep leaf `@suluk/examples`
|
|
3
|
+
* (so `@suluk/sdk` can read it too — journeys depends on sdk, so the reader had to sit below both). journeys keeps this
|
|
4
|
+
* re-export so its public API and the projector-core wall (which forbids importing this VALUE layer) are unchanged.
|
|
5
|
+
*/
|
|
6
|
+
export * from "@suluk/examples";
|
package/src/gherkin.ts
CHANGED
|
@@ -25,6 +25,11 @@ export interface Scenario {
|
|
|
25
25
|
rule?: string;
|
|
26
26
|
steps: FeatureStep[];
|
|
27
27
|
line: number;
|
|
28
|
+
/** tags on this scenario (the leading `@` stripped), e.g. ["public"]. */
|
|
29
|
+
tags?: string[];
|
|
30
|
+
/** the captured `Examples:` table of a Scenario Outline (C040-P1); absent for a plain Scenario. `tags` are from a
|
|
31
|
+
* `@public`-style line directly above the `Examples:` keyword (C040-P4 promote selection). */
|
|
32
|
+
examples?: { headers: string[]; rows: string[][]; tags?: string[] };
|
|
28
33
|
}
|
|
29
34
|
|
|
30
35
|
export interface Feature {
|
|
@@ -32,19 +37,41 @@ export interface Feature {
|
|
|
32
37
|
scenarios: Scenario[];
|
|
33
38
|
}
|
|
34
39
|
|
|
35
|
-
|
|
40
|
+
// NB: `Scenario Outline` MUST precede `Scenario` — alternation is ordered, else "Scenario Outline: x" matches the
|
|
41
|
+
// shorter `Scenario` and mis-parses the name as "Outline: x".
|
|
42
|
+
const KW = /^(Feature|Background|Rule|Scenario Outline|Scenario|Given|When|Then|And|But|Examples)\b:?\s*(.*)$/i;
|
|
36
43
|
|
|
37
44
|
export function parseFeature(src: string): Feature {
|
|
38
45
|
let feature = "";
|
|
39
46
|
let rule: string | undefined;
|
|
40
47
|
let cur: Scenario | null = null;
|
|
41
48
|
let last: StepKind = "given";
|
|
49
|
+
let collectingExamples = false;
|
|
50
|
+
let pendingTags: string[] = [];
|
|
42
51
|
const scenarios: Scenario[] = [];
|
|
43
52
|
const lines = src.split("\n");
|
|
44
53
|
|
|
45
54
|
lines.forEach((raw, i) => {
|
|
46
55
|
const t = raw.trim();
|
|
47
56
|
if (!t || t.startsWith("#")) return;
|
|
57
|
+
|
|
58
|
+
// a tag line (`@public @smoke …`) — attaches to the NEXT Scenario or Examples block.
|
|
59
|
+
if (t.startsWith("@")) {
|
|
60
|
+
pendingTags.push(...t.split(/\s+/).filter((x) => x.startsWith("@")).map((x) => x.slice(1)));
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// a `|` table row — captured ONLY while inside an Examples block (a Scenario Outline's table); otherwise ignored.
|
|
65
|
+
if (t.startsWith("|")) {
|
|
66
|
+
const c = cur;
|
|
67
|
+
if (collectingExamples && c?.examples) {
|
|
68
|
+
const cells = t.split("|").slice(1, -1).map((s) => s.trim());
|
|
69
|
+
if (c.examples.headers.length === 0) c.examples.headers = cells;
|
|
70
|
+
else c.examples.rows.push(cells);
|
|
71
|
+
}
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
48
75
|
const m = KW.exec(t);
|
|
49
76
|
if (!m) return;
|
|
50
77
|
const kw = m[1].toLowerCase();
|
|
@@ -52,17 +79,31 @@ export function parseFeature(src: string): Feature {
|
|
|
52
79
|
switch (kw) {
|
|
53
80
|
case "feature":
|
|
54
81
|
feature = rest;
|
|
82
|
+
collectingExamples = false;
|
|
83
|
+
pendingTags = [];
|
|
55
84
|
return;
|
|
56
85
|
case "rule":
|
|
57
86
|
rule = rest;
|
|
87
|
+
collectingExamples = false;
|
|
88
|
+
pendingTags = [];
|
|
58
89
|
return;
|
|
59
90
|
case "background":
|
|
91
|
+
collectingExamples = false;
|
|
92
|
+
pendingTags = [];
|
|
93
|
+
return;
|
|
60
94
|
case "examples":
|
|
95
|
+
if (cur) {
|
|
96
|
+
cur.examples = { headers: [], rows: [], ...(pendingTags.length ? { tags: pendingTags } : {}) };
|
|
97
|
+
collectingExamples = true;
|
|
98
|
+
}
|
|
99
|
+
pendingTags = [];
|
|
61
100
|
return;
|
|
62
101
|
case "scenario":
|
|
63
102
|
case "scenario outline":
|
|
64
|
-
|
|
103
|
+
collectingExamples = false;
|
|
104
|
+
cur = { name: rest, rule, steps: [], line: i + 1, ...(pendingTags.length ? { tags: pendingTags } : {}) };
|
|
65
105
|
scenarios.push(cur);
|
|
106
|
+
pendingTags = [];
|
|
66
107
|
return;
|
|
67
108
|
}
|
|
68
109
|
// a step keyword
|
package/src/index.ts
CHANGED
|
@@ -22,6 +22,25 @@ export {
|
|
|
22
22
|
|
|
23
23
|
export { parseFeature, type Feature, type Scenario, type FeatureStep } from "./gherkin";
|
|
24
24
|
|
|
25
|
+
export {
|
|
26
|
+
buildScenarioOutlines,
|
|
27
|
+
renderScenarioOutlines,
|
|
28
|
+
type ScenarioOutline,
|
|
29
|
+
type OutlineColumn,
|
|
30
|
+
type OutlineRenderOptions,
|
|
31
|
+
} from "./outline";
|
|
32
|
+
|
|
33
|
+
export {
|
|
34
|
+
extractPublicRows,
|
|
35
|
+
buildExampleObject,
|
|
36
|
+
promoteExampleIntoZod,
|
|
37
|
+
promoteFeatureExamples,
|
|
38
|
+
type PublicExampleRow,
|
|
39
|
+
type PromoteResult,
|
|
40
|
+
type PromoteTarget,
|
|
41
|
+
type PromoteFeatureResult,
|
|
42
|
+
} from "./promote";
|
|
43
|
+
|
|
25
44
|
export {
|
|
26
45
|
bindFeatures,
|
|
27
46
|
detectUndefined,
|
|
@@ -38,3 +57,55 @@ export {
|
|
|
38
57
|
} from "./bind";
|
|
39
58
|
|
|
40
59
|
export { emitRunnableSuite, type EmitOptions } from "./emit";
|
|
60
|
+
|
|
61
|
+
export {
|
|
62
|
+
compileDemos,
|
|
63
|
+
renderPostman,
|
|
64
|
+
renderBruno,
|
|
65
|
+
type DemoScenario,
|
|
66
|
+
type DemoRequest,
|
|
67
|
+
type DemoValue,
|
|
68
|
+
type DemoCapture,
|
|
69
|
+
type CompileDemoOptions,
|
|
70
|
+
type RenderOptions,
|
|
71
|
+
} from "./demos";
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
buildDemoFiles,
|
|
75
|
+
planPromotions,
|
|
76
|
+
parseTargetSpec,
|
|
77
|
+
miniDiff,
|
|
78
|
+
buildAudit,
|
|
79
|
+
type DemoFormat,
|
|
80
|
+
type BuildDemoFilesOptions,
|
|
81
|
+
type DemoFilesResult,
|
|
82
|
+
type PromoteTargetSpec,
|
|
83
|
+
type PromotionPlan,
|
|
84
|
+
type PromotionRow,
|
|
85
|
+
type PromotionFileResult,
|
|
86
|
+
type AuditResult,
|
|
87
|
+
type DimensionAudit,
|
|
88
|
+
} from "./cli";
|
|
89
|
+
|
|
90
|
+
export { coverageGrade, type CoverageGrade } from "./coverage";
|
|
91
|
+
|
|
92
|
+
export {
|
|
93
|
+
resolveExample,
|
|
94
|
+
synthesize,
|
|
95
|
+
fieldOrigin,
|
|
96
|
+
describeInputs,
|
|
97
|
+
asSourceRef,
|
|
98
|
+
resolveSourced,
|
|
99
|
+
ORIGIN_KEYWORD,
|
|
100
|
+
FROM_KEYWORD,
|
|
101
|
+
type JsonSchema,
|
|
102
|
+
type ExampleTier,
|
|
103
|
+
type ExampleSources,
|
|
104
|
+
type ResolvedExample,
|
|
105
|
+
type FieldOrigin,
|
|
106
|
+
type SourceRef,
|
|
107
|
+
type FieldSource,
|
|
108
|
+
type FieldDescriptor,
|
|
109
|
+
type SynthDirection,
|
|
110
|
+
type SynthOptions,
|
|
111
|
+
} from "./examples";
|
package/src/outline.ts
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scenario Outline generator (C040, P1). Per operation, project a Gherkin `Scenario Outline:` whose `Examples:` columns
|
|
3
|
+
* are the request's CLIENT-FACING input fields (origin `input` or `sourced`; `computed`/server-set fields are dropped —
|
|
4
|
+
* a client never sends them), and whose first seed row comes from the C041-origin-aware resolver:
|
|
5
|
+
* • an `input` cell → a deterministic synthesized value the tester edits;
|
|
6
|
+
* • a `sourced` cell → the WIREABLE TOKEN `<op.select>` (e.g. `<createSubscription.id>`), not a value — so the tester
|
|
7
|
+
* sees it is wired from a prior step, and the runnable emitter resolves it via `resolveSourced` (the follow-on).
|
|
8
|
+
*
|
|
9
|
+
* The tester EXPANDS the table (adds rows / edits cells); the binder + emitter then run each row. This is an authoring
|
|
10
|
+
* surface, so it lives on the VALUE side (it imports the synth) — never read by the matcher.
|
|
11
|
+
*/
|
|
12
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
13
|
+
import { describeInputs, resolveExample, type FieldOrigin, type JsonSchema } from "@suluk/examples";
|
|
14
|
+
import { generateVocabulary, opHandle } from "./vocabulary";
|
|
15
|
+
|
|
16
|
+
export interface OutlineColumn {
|
|
17
|
+
name: string;
|
|
18
|
+
origin: FieldOrigin;
|
|
19
|
+
/** the seed cell for the first Examples row: a synthesized value (`input`) or a `<op.select>` wiring token (`sourced`). */
|
|
20
|
+
seed: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ScenarioOutline {
|
|
24
|
+
/** the operation's v4 by-name handle. */
|
|
25
|
+
op: string;
|
|
26
|
+
method: string;
|
|
27
|
+
uri: string;
|
|
28
|
+
/** the `When` step text (placeholders reference the Examples columns). */
|
|
29
|
+
whenPhrase: string;
|
|
30
|
+
/** client-facing input columns (computed fields dropped). Empty ⇒ a plain Scenario, no Examples table. */
|
|
31
|
+
columns: OutlineColumn[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface RawReq {
|
|
35
|
+
method: string;
|
|
36
|
+
contentSchema?: unknown;
|
|
37
|
+
parameterSchema?: { body?: unknown };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Escape a synthesized value for a Gherkin table cell. */
|
|
41
|
+
function cell(v: unknown): string {
|
|
42
|
+
const s = typeof v === "string" ? v : typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(v ?? null);
|
|
43
|
+
return s.replace(/\\/g, "\\\\").replace(/\|/g, "\\|").replace(/\r?\n/g, " ");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Build the structured outlines for every operation that has a client-facing request body. */
|
|
47
|
+
export function buildScenarioOutlines(doc: OpenAPIv4Document): ScenarioOutline[] {
|
|
48
|
+
// the When uses the contract's OWN generated phrase (so the outline BINDS); the Examples columns carry the request
|
|
49
|
+
// body — a Suluk convention: for a Scenario Outline, an Examples column = a request field of the bound When op.
|
|
50
|
+
const whenByHandle = new Map(generateVocabulary(doc).steps.filter((s) => s.kind === "when").map((s) => [s.handle, s.phrase.replace(/^When\s+/i, "")]));
|
|
51
|
+
const outlines: ScenarioOutline[] = [];
|
|
52
|
+
for (const [uri, piRaw] of Object.entries(doc.paths ?? {})) {
|
|
53
|
+
const pi = piRaw as { requests?: Record<string, RawReq> };
|
|
54
|
+
for (const [name, req] of Object.entries(pi.requests ?? {})) {
|
|
55
|
+
const body = (req.contentSchema ?? req.parameterSchema?.body) as JsonSchema | undefined;
|
|
56
|
+
const props = (body?.properties ?? {}) as Record<string, JsonSchema>;
|
|
57
|
+
const columns: OutlineColumn[] = [];
|
|
58
|
+
for (const d of describeInputs(body)) {
|
|
59
|
+
if (d.origin === "computed") continue; // a client never sends a server-computed field
|
|
60
|
+
const seed =
|
|
61
|
+
d.origin === "sourced"
|
|
62
|
+
? `<${d.source ? `${d.source.op}.${d.source.select ?? "id"}` : d.name}>` // wired, not a value
|
|
63
|
+
: cell(resolveExample(props[d.name], {}, d.name, { direction: "request" }).value);
|
|
64
|
+
columns.push({ name: d.name, origin: d.origin, seed });
|
|
65
|
+
}
|
|
66
|
+
const whenPhrase = whenByHandle.get(opHandle(name, uri)) ?? `I ${name}`;
|
|
67
|
+
outlines.push({ op: name, method: req.method.toLowerCase(), uri, whenPhrase, columns });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return outlines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Render a left-aligned, padded Gherkin table (6-space indent, matching the step indent + 2). */
|
|
74
|
+
function renderTable(headers: string[], rows: string[][]): string {
|
|
75
|
+
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length)));
|
|
76
|
+
const line = (cells: string[]) => " | " + cells.map((c, i) => (c ?? "").padEnd(widths[i])).join(" | ") + " |";
|
|
77
|
+
return [line(headers), ...rows.map(line)].join("\n");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface OutlineRenderOptions {
|
|
81
|
+
/** only render these operations (by name); default all. */
|
|
82
|
+
only?: string[];
|
|
83
|
+
feature?: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Render the generated outlines as a `.feature` SIDECAR a tester expands. A column-bearing op becomes a `Scenario
|
|
88
|
+
* Outline:` + a one-row `Examples:` table; a body-less op becomes a plain `Scenario:`.
|
|
89
|
+
*/
|
|
90
|
+
export function renderScenarioOutlines(doc: OpenAPIv4Document, opts: OutlineRenderOptions = {}): string {
|
|
91
|
+
const title = opts.feature ?? `${doc.info?.title ?? "API"} — generated scenario outlines (expand the Examples rows)`;
|
|
92
|
+
const want = opts.only ? new Set(opts.only) : null;
|
|
93
|
+
const blocks = buildScenarioOutlines(doc)
|
|
94
|
+
.filter((o) => !want || want.has(o.op))
|
|
95
|
+
.map((o) => {
|
|
96
|
+
const head = ` Scenario${o.columns.length ? " Outline" : ""}: ${o.op}\n When ${o.whenPhrase}\n Then it succeeds`;
|
|
97
|
+
if (!o.columns.length) return head;
|
|
98
|
+
const table = renderTable(o.columns.map((c) => c.name), [o.columns.map((c) => c.seed)]);
|
|
99
|
+
return `${head}\n\n Examples:\n${table}`;
|
|
100
|
+
});
|
|
101
|
+
return `Feature: ${title}\n\n${blocks.join("\n\n")}\n`;
|
|
102
|
+
}
|
package/src/promote.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Promote-into-Zod (C040, P4). A tester marks an `Examples:` block `@public`; this lifts the block's first row into the
|
|
3
|
+
* matching Zod schema's source as `.meta({ examples: [ … ] })`, provenance-stamped — so Zod stays the LITERAL home for
|
|
4
|
+
* every example (the operator's fork), and the promoted value flows into the rendered docs as the request `.example`.
|
|
5
|
+
*
|
|
6
|
+
* SOURCE-WRITE SAFETY (this edits the maintainer's source file): the edit is MARKED (`@suluk-public`), IDEMPOTENT
|
|
7
|
+
* (re-promoting replaces the marked block, never double-appends), and NEVER CLOBBERS a hand-authored example (if the
|
|
8
|
+
* schema already carries an unmarked top-level `.meta({ examples })`, it REFUSES and asks the maintainer to resolve).
|
|
9
|
+
* The functions here are PURE (string in → string out); the consumer's bin runs `mizan_check_action_safety` before
|
|
10
|
+
* writing and the maintainer reviews the git diff. Reuses @suluk/examples' coercion shape; never invents a value.
|
|
11
|
+
*/
|
|
12
|
+
import type { Feature } from "./gherkin";
|
|
13
|
+
import type { JsonSchema } from "@suluk/examples";
|
|
14
|
+
|
|
15
|
+
const PUBLIC_MARK = "@suluk-public";
|
|
16
|
+
|
|
17
|
+
export interface PublicExampleRow {
|
|
18
|
+
scenario: string;
|
|
19
|
+
headers: string[];
|
|
20
|
+
/** the FIRST row of the `@public`-tagged Examples block — the canonical public example. */
|
|
21
|
+
row: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Every `@public`-tagged Examples block's first row (the tester's curated public example). Pure. */
|
|
25
|
+
export function extractPublicRows(features: Feature[]): PublicExampleRow[] {
|
|
26
|
+
const out: PublicExampleRow[] = [];
|
|
27
|
+
for (const f of features) {
|
|
28
|
+
for (const sc of f.scenarios) {
|
|
29
|
+
const ex = sc.examples;
|
|
30
|
+
const isPublic = !!ex && (ex.tags?.includes("public") || sc.tags?.includes("public"));
|
|
31
|
+
if (ex && isPublic && ex.headers.length && ex.rows.length) {
|
|
32
|
+
out.push({ scenario: sc.name, headers: ex.headers, row: ex.rows[0] });
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Coerce a table cell to a concrete value by the field's declared type; with no schema, infer from the cell content. */
|
|
40
|
+
function coerce(cell: string, fieldSchema?: JsonSchema): unknown {
|
|
41
|
+
const t = fieldSchema && typeof fieldSchema === "object" ? fieldSchema.type : undefined;
|
|
42
|
+
const type = Array.isArray(t) ? t[0] : t;
|
|
43
|
+
if ((type === "integer" || type === "number") && cell.trim() !== "" && Number.isFinite(Number(cell))) return Number(cell);
|
|
44
|
+
if (type === "boolean" && (cell === "true" || cell === "false")) return cell === "true";
|
|
45
|
+
if (type) return cell; // a declared string/other type stays a string verbatim
|
|
46
|
+
// no schema: infer a number/boolean from the content, else keep the string.
|
|
47
|
+
if (/^-?\d+(\.\d+)?$/.test(cell.trim())) return Number(cell);
|
|
48
|
+
if (cell === "true" || cell === "false") return cell === "true";
|
|
49
|
+
return cell;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Build a concrete public example object from a row, coercing by the body schema's field types. A WIRING TOKEN cell
|
|
54
|
+
* (`<op.select>`) is skipped — a public docs example holds concrete values, not a chaining instruction.
|
|
55
|
+
*/
|
|
56
|
+
export function buildExampleObject(headers: string[], row: string[], bodySchema?: JsonSchema): Record<string, unknown> {
|
|
57
|
+
const props = (bodySchema?.properties ?? {}) as Record<string, JsonSchema>;
|
|
58
|
+
const out: Record<string, unknown> = {};
|
|
59
|
+
headers.forEach((h, i) => {
|
|
60
|
+
const cell = (row[i] ?? "").trim();
|
|
61
|
+
if (/^<[^>]+>$/.test(cell)) return; // a sourced wiring token is not a concrete public value
|
|
62
|
+
out[h] = coerce(cell, props[h]);
|
|
63
|
+
});
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ---- the source-editing core (string-aware paren/bracket scanning; never regex-balances JSON) ----
|
|
68
|
+
|
|
69
|
+
const escapeRe = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
70
|
+
|
|
71
|
+
/** From `(` at `open`, the index of the matching `)`, skipping string literals. -1 if unbalanced. */
|
|
72
|
+
function matchParen(text: string, open: number): number {
|
|
73
|
+
let depth = 0;
|
|
74
|
+
let str: string | null = null;
|
|
75
|
+
for (let i = open; i < text.length; i++) {
|
|
76
|
+
const ch = text[i];
|
|
77
|
+
if (str) {
|
|
78
|
+
if (ch === "\\") i++;
|
|
79
|
+
else if (ch === str) str = null;
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
if (ch === '"' || ch === "'" || ch === "`") str = ch;
|
|
83
|
+
else if (ch === "(") depth++;
|
|
84
|
+
else if (ch === ")" && --depth === 0) return i;
|
|
85
|
+
}
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** The index of the top-level `;` ending the expression that starts at `from`, skipping strings + nested brackets. */
|
|
90
|
+
function statementEnd(text: string, from: number): number {
|
|
91
|
+
let depth = 0;
|
|
92
|
+
let str: string | null = null;
|
|
93
|
+
for (let i = from; i < text.length; i++) {
|
|
94
|
+
const ch = text[i];
|
|
95
|
+
if (str) {
|
|
96
|
+
if (ch === "\\") i++;
|
|
97
|
+
else if (ch === str) str = null;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (ch === '"' || ch === "'" || ch === "`") str = ch;
|
|
101
|
+
else if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
102
|
+
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
103
|
+
else if (ch === ";" && depth === 0) return i;
|
|
104
|
+
}
|
|
105
|
+
return -1;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The LAST top-level `.meta(…)` call in `expr` (depth 0 — not a property's `.meta`), or null. */
|
|
109
|
+
function topLevelMeta(expr: string): { start: number; end: number; marked: boolean; hasExamples: boolean } | null {
|
|
110
|
+
let depth = 0;
|
|
111
|
+
let str: string | null = null;
|
|
112
|
+
let last: { start: number; end: number; marked: boolean; hasExamples: boolean } | null = null;
|
|
113
|
+
for (let i = 0; i < expr.length; i++) {
|
|
114
|
+
const ch = expr[i];
|
|
115
|
+
if (str) {
|
|
116
|
+
if (ch === "\\") i++;
|
|
117
|
+
else if (ch === str) str = null;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
if (ch === '"' || ch === "'" || ch === "`") {
|
|
121
|
+
str = ch;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === "." && depth === 0 && expr.startsWith(".meta(", i)) {
|
|
125
|
+
const end = matchParen(expr, i + 5);
|
|
126
|
+
if (end > 0) {
|
|
127
|
+
const content = expr.slice(i, end + 1);
|
|
128
|
+
last = { start: i, end, marked: content.includes(PUBLIC_MARK), hasExamples: /\bexamples\b/.test(content) };
|
|
129
|
+
i = end;
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (ch === "(" || ch === "{" || ch === "[") depth++;
|
|
134
|
+
else if (ch === ")" || ch === "}" || ch === "]") depth--;
|
|
135
|
+
}
|
|
136
|
+
return last;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const renderMeta = (example: unknown, provenance: string) => `.meta(/* ${PUBLIC_MARK}: ${provenance} */ { examples: [${JSON.stringify(example)}] })`;
|
|
140
|
+
|
|
141
|
+
export interface PromoteResult {
|
|
142
|
+
source: string;
|
|
143
|
+
changed: boolean;
|
|
144
|
+
reason: string;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Promote `example` into the source of the Zod schema bound to `const <schemaVar> = …`. Idempotent (re-promote replaces
|
|
149
|
+
* the marked block), marked, and refuses to clobber a hand-authored top-level `.meta({ examples })`.
|
|
150
|
+
*/
|
|
151
|
+
export function promoteExampleIntoZod(source: string, schemaVar: string, example: unknown, provenance: string): PromoteResult {
|
|
152
|
+
const decl = new RegExp(`(^|[\\n;{])\\s*(export\\s+)?const\\s+${escapeRe(schemaVar)}\\s*=`);
|
|
153
|
+
const m = decl.exec(source);
|
|
154
|
+
if (!m) return { source, changed: false, reason: `schema \`${schemaVar}\` not found` };
|
|
155
|
+
const eqIdx = m.index + m[0].length - 1; // the "="
|
|
156
|
+
const exprStart = eqIdx + 1;
|
|
157
|
+
const semi = statementEnd(source, exprStart);
|
|
158
|
+
if (semi < 0) return { source, changed: false, reason: `could not find the end of \`${schemaVar}\`'s declaration` };
|
|
159
|
+
|
|
160
|
+
const expr = source.slice(exprStart, semi);
|
|
161
|
+
const meta = renderMeta(example, provenance);
|
|
162
|
+
const top = topLevelMeta(expr);
|
|
163
|
+
|
|
164
|
+
let newExpr: string;
|
|
165
|
+
if (top?.marked) {
|
|
166
|
+
newExpr = expr.slice(0, top.start) + meta + expr.slice(top.end + 1); // idempotent replace of the promoted block
|
|
167
|
+
} else if (top?.hasExamples) {
|
|
168
|
+
return { source, changed: false, reason: `\`${schemaVar}\` already has a hand-authored .meta({ examples }) — not clobbering; merge the public example manually` };
|
|
169
|
+
} else if (top) {
|
|
170
|
+
newExpr = expr.slice(0, top.end + 1) + " " + meta + expr.slice(top.end + 1); // append after a non-example .meta (merges safely)
|
|
171
|
+
} else {
|
|
172
|
+
const trimmed = expr.replace(/\s+$/, "").length;
|
|
173
|
+
newExpr = expr.slice(0, trimmed) + meta + expr.slice(trimmed);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
if (newExpr === expr) return { source, changed: false, reason: "no change" };
|
|
177
|
+
return {
|
|
178
|
+
source: source.slice(0, exprStart) + newExpr + source.slice(semi),
|
|
179
|
+
changed: true,
|
|
180
|
+
reason: top?.marked ? `updated the promoted example on \`${schemaVar}\`` : `promoted a public example into \`${schemaVar}\``,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export interface PromoteTarget {
|
|
185
|
+
/** the Zod `const` name to edit. */
|
|
186
|
+
schemaVar: string;
|
|
187
|
+
/** the op's request body schema (for typed cell coercion); optional. */
|
|
188
|
+
bodySchema?: JsonSchema;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface PromoteFeatureResult {
|
|
192
|
+
source: string;
|
|
193
|
+
applied: { scenario: string; schemaVar: string; reason: string }[];
|
|
194
|
+
skipped: { scenario: string; reason: string }[];
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Orchestrate promotion for a whole feature set: for each `@public` Examples row, resolve its target (the consumer maps
|
|
199
|
+
* scenario → schemaVar + body schema — the app knows that wiring), build the example, and apply it. Adapter-seam shaped.
|
|
200
|
+
*/
|
|
201
|
+
export function promoteFeatureExamples(
|
|
202
|
+
source: string,
|
|
203
|
+
features: Feature[],
|
|
204
|
+
resolveTarget: (scenario: string) => PromoteTarget | null,
|
|
205
|
+
provenancePrefix = "promoted from",
|
|
206
|
+
): PromoteFeatureResult {
|
|
207
|
+
let src = source;
|
|
208
|
+
const applied: PromoteFeatureResult["applied"] = [];
|
|
209
|
+
const skipped: PromoteFeatureResult["skipped"] = [];
|
|
210
|
+
for (const pub of extractPublicRows(features)) {
|
|
211
|
+
const target = resolveTarget(pub.scenario);
|
|
212
|
+
if (!target) {
|
|
213
|
+
skipped.push({ scenario: pub.scenario, reason: "no target schema resolved for this scenario" });
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const example = buildExampleObject(pub.headers, pub.row, target.bodySchema);
|
|
217
|
+
const r = promoteExampleIntoZod(src, target.schemaVar, example, `${provenancePrefix} ${pub.scenario}`);
|
|
218
|
+
if (r.changed) {
|
|
219
|
+
src = r.source;
|
|
220
|
+
applied.push({ scenario: pub.scenario, schemaVar: target.schemaVar, reason: r.reason });
|
|
221
|
+
} else {
|
|
222
|
+
skipped.push({ scenario: pub.scenario, reason: r.reason });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { source: src, applied, skipped };
|
|
226
|
+
}
|