@suluk/journeys 0.3.0 → 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/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
+ }
@@ -0,0 +1,90 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildAudit } from "../src/cli";
3
+ import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * C043 — the unified `journeys audit`: harden SECURITY + harden READINESS + journeys COVERAGE, folded by letter via
9
+ * harden's combineGrades. Coverage is journeys-owned (needs the features); harden never depends on journeys.
10
+ */
11
+ const docJson = JSON.stringify({
12
+ openapi: "4.0.0-candidate",
13
+ info: { title: "Billing" },
14
+ paths: {
15
+ "/charge": {
16
+ requests: {
17
+ charge: {
18
+ method: "post",
19
+ contentSchema: {
20
+ type: "object",
21
+ additionalProperties: false,
22
+ required: ["amountCents", "balance"],
23
+ properties: {
24
+ amountCents: { type: "integer" }, // no maximum → a SECURITY finding
25
+ balance: { type: "integer", "x-suluk-origin": "computed" }, // required+computed → a READINESS finding
26
+ },
27
+ },
28
+ },
29
+ },
30
+ },
31
+ "/health": { requests: { health: { method: "get", responses: { "200": { status: 200 } } } } },
32
+ },
33
+ });
34
+ const feature = "Feature: f\n Scenario: charge it\n When I charge\n Then it succeeds\n";
35
+
36
+ describe("buildAudit", () => {
37
+ test("doc-only: security + readiness dimensions, no coverage, combined of 2", () => {
38
+ const a = buildAudit(docJson);
39
+ expect(a.combined.grades).toHaveLength(2);
40
+ expect(a.coverage).toBeUndefined();
41
+ expect(a.readiness.findings.some((f) => f.rule === "computed-required")).toBe(true);
42
+ expect(a.security.findings.some((f) => f.rule === "number-maximum")).toBe(true);
43
+ });
44
+
45
+ test("with features: coverage dimension included (combined of 3), uncovered ops surfaced", () => {
46
+ const a = buildAudit(docJson, [feature]);
47
+ expect(a.combined.grades).toHaveLength(3);
48
+ expect(a.coverage).toBeDefined();
49
+ expect(a.coverage!.covered).toBe(1); // charge covered
50
+ expect(a.coverage!.uncovered).toContain("health"); // health is a gap → generate an outline
51
+ });
52
+
53
+ test("the combined worst is the lowest dimension (the safe gate value)", () => {
54
+ const a = buildAudit(docJson, [feature]);
55
+ const ORDER = ["F", "D", "C", "B", "A"];
56
+ const min = a.combined.grades.reduce((w, g) => (ORDER.indexOf(g) < ORDER.indexOf(w) ? g : w), "A");
57
+ expect(a.combined.worst).toBe(min);
58
+ });
59
+ });
60
+
61
+ describe("bin: `journeys audit` end-to-end", () => {
62
+ const bin = join(import.meta.dir, "..", "bin", "journeys.ts");
63
+
64
+ test("prints all three dimensions + a combined grade; --min gates", () => {
65
+ const dir = mkdtempSync(join(tmpdir(), "journeys-audit-"));
66
+ try {
67
+ const docPath = join(dir, "openapi.json");
68
+ const featDir = join(dir, "features");
69
+ mkdirSync(featDir);
70
+ writeFileSync(docPath, docJson);
71
+ writeFileSync(join(featDir, "billing.feature"), feature);
72
+
73
+ const run = Bun.spawnSync(["bun", bin, "audit", "--doc", docPath, "--features", featDir]);
74
+ expect(run.exitCode).toBe(0);
75
+ const out = run.stdout.toString();
76
+ expect(out).toContain("security");
77
+ expect(out).toContain("readiness");
78
+ expect(out).toContain("coverage");
79
+ expect(out).toContain("combined");
80
+ expect(out).toContain("uncovered");
81
+
82
+ // gate on an impossible-to-meet minimum → non-zero exit
83
+ const gated = Bun.spawnSync(["bun", bin, "audit", "--doc", docPath, "--features", featDir, "--min", "A"]);
84
+ expect(gated.exitCode).toBe(1);
85
+ expect(gated.stderr.toString()).toContain("below the required A");
86
+ } finally {
87
+ rmSync(dir, { recursive: true, force: true });
88
+ }
89
+ });
90
+ });
@@ -0,0 +1,78 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { buildDemoFiles } from "../src/cli";
3
+ import { mkdtempSync, rmSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+
7
+ /**
8
+ * C042 CLI — `journeys demos` compiles `.feature` files into a Bruno/Postman demo collection on disk. buildDemoFiles is
9
+ * the pure core (no fs); the bin is thin IO around it. Both are exercised here (unit + an end-to-end spawn).
10
+ */
11
+ const docJson = JSON.stringify({
12
+ openapi: "4.0.0-candidate",
13
+ info: { title: "Billing" },
14
+ paths: {
15
+ "/subs": { requests: { createSubscription: { method: "post", contentSchema: { type: "object", required: ["plan"], properties: { plan: { type: "string" } } }, responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" } } } },
16
+ "/charge": { requests: { charge: { method: "post", contentSchema: { type: "object", required: ["amountCents", "subscriptionId"], properties: { amountCents: { type: "integer", minimum: 100 }, subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } } } }, responses: { "200": { status: 200 } }, "x-suluk-access": { requires: "authenticated" } } } },
17
+ },
18
+ });
19
+ const featureText = "Feature: billing demo\n Scenario: subscribe then charge\n When I create subscription\n And I charge\n";
20
+
21
+ describe("buildDemoFiles", () => {
22
+ test("format bruno → a Bruno file map (chaining preserved)", () => {
23
+ const r = buildDemoFiles(docJson, [featureText], { format: "bruno", baseUrl: "https://api.example.com" });
24
+ expect([r.scenarios, r.requests]).toEqual([1, 2]);
25
+ expect(r.files["bruno.json"]).toBeDefined();
26
+ expect(r.files["environments/prod.bru"]).toContain("https://api.example.com");
27
+ expect(r.files["subscribe-then-charge/2-charge.bru"]).toContain('"subscriptionId": "{{createSubscription_id}}"');
28
+ });
29
+
30
+ test("format postman → a single v2.1 collection json", () => {
31
+ const r = buildDemoFiles(docJson, [featureText], { format: "postman", name: "Billing" });
32
+ expect(Object.keys(r.files)).toEqual(["billing.postman_collection.json"]);
33
+ expect(JSON.parse(r.files["billing.postman_collection.json"]).info.schema).toContain("v2.1.0");
34
+ });
35
+
36
+ test("format both → bruno/ and postman/ prefixed, no collision", () => {
37
+ const r = buildDemoFiles(docJson, [featureText], { format: "both", name: "Billing" });
38
+ expect(r.files["bruno/bruno.json"]).toBeDefined();
39
+ expect(r.files["postman/billing.postman_collection.json"]).toBeDefined();
40
+ });
41
+
42
+ test("name defaults to the contract's info.title", () => {
43
+ const r = buildDemoFiles(docJson, [featureText], { format: "postman" });
44
+ expect(r.files["billing.postman_collection.json"]).toBeDefined();
45
+ });
46
+ });
47
+
48
+ describe("bin: `journeys demos` end-to-end on disk", () => {
49
+ test("writes the collection files to --out (real spawn)", () => {
50
+ const dir = mkdtempSync(join(tmpdir(), "journeys-cli-"));
51
+ try {
52
+ const docPath = join(dir, "openapi.json");
53
+ const featDir = join(dir, "features");
54
+ const outDir = join(dir, "out");
55
+ mkdirSync(featDir);
56
+ writeFileSync(docPath, docJson);
57
+ writeFileSync(join(featDir, "billing.feature"), featureText);
58
+
59
+ const bin = join(import.meta.dir, "..", "bin", "journeys.ts");
60
+ const proc = Bun.spawnSync(["bun", bin, "demos", "--doc", docPath, "--features", featDir, "--out", outDir, "--format", "bruno", "--base-url", "https://api.example.com"]);
61
+
62
+ expect(proc.exitCode).toBe(0);
63
+ expect(proc.stdout.toString()).toContain("1 scenario(s), 2 request(s)");
64
+ expect(existsSync(join(outDir, "bruno.json"))).toBe(true);
65
+ const charge = join(outDir, "subscribe-then-charge", "2-charge.bru");
66
+ expect(existsSync(charge)).toBe(true);
67
+ } finally {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ }
70
+ });
71
+
72
+ test("missing required flags → non-zero exit + usage", () => {
73
+ const bin = join(import.meta.dir, "..", "bin", "journeys.ts");
74
+ const proc = Bun.spawnSync(["bun", bin, "demos", "--doc", "x.json"]);
75
+ expect(proc.exitCode).toBe(1);
76
+ expect(proc.stderr.toString()).toContain("required");
77
+ });
78
+ });
@@ -0,0 +1,126 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { generateVocabulary } from "../src/vocabulary";
3
+ import { parseFeature } from "../src/gherkin";
4
+ import { compileDemos, renderPostman, renderBruno } from "../src/demos";
5
+ import type { OpenAPIv4Document } from "@suluk/core";
6
+
7
+ /**
8
+ * C042 — compile a bound feature into a Bruno/Postman DEMO collection: ordered requests, body from the Examples row (or
9
+ * synthesized), sourced fields wired to request CHAINING (capture → {{var}}), auth + a {{baseUrl}} that a developer
10
+ * points at localhost first and the presenter switches to prod for the live call.
11
+ */
12
+ const doc = {
13
+ openapi: "4.0.0-candidate",
14
+ info: { title: "Billing" },
15
+ paths: {
16
+ "/subs": {
17
+ requests: {
18
+ createSubscription: {
19
+ method: "post",
20
+ contentSchema: { type: "object", required: ["plan"], properties: { plan: { type: "string" } } },
21
+ responses: { ok: { status: 200 } },
22
+ "x-suluk-access": { requires: "authenticated" },
23
+ },
24
+ },
25
+ },
26
+ "/charge": {
27
+ requests: {
28
+ charge: {
29
+ method: "post",
30
+ contentSchema: {
31
+ type: "object",
32
+ required: ["amountCents", "subscriptionId"],
33
+ properties: {
34
+ amountCents: { type: "integer", minimum: 100 },
35
+ subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } },
36
+ },
37
+ },
38
+ responses: { ok: { status: 200 } },
39
+ "x-suluk-access": { requires: "authenticated" },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ } as unknown as OpenAPIv4Document;
45
+
46
+ const feature = parseFeature(
47
+ ["Feature: billing demo", " Scenario: subscribe then charge", " When I create subscription", " And I charge"].join("\n"),
48
+ );
49
+ const demos = compileDemos(doc, generateVocabulary(doc), [feature]);
50
+
51
+ describe("compileDemos — the IR", () => {
52
+ test("ordered requests per scenario, method + path from the contract", () => {
53
+ expect(demos).toHaveLength(1);
54
+ expect(demos[0].requests.map((r) => [r.method, r.path])).toEqual([
55
+ ["POST", "/subs"],
56
+ ["POST", "/charge"],
57
+ ]);
58
+ });
59
+
60
+ test("the body is synthesized when there's no Examples table; computed dropped", () => {
61
+ expect(demos[0].requests[0].body).toEqual({ plan: { kind: "literal", value: "plan" } });
62
+ });
63
+
64
+ test("a sourced field becomes a {{var}} reference, and the SOURCE request captures it", () => {
65
+ const charge = demos[0].requests[1];
66
+ expect(charge.body!.subscriptionId).toEqual({ kind: "var", name: "createSubscription_id" });
67
+ const create = demos[0].requests[0];
68
+ expect(create.captures).toEqual([{ var: "createSubscription_id", from: "id" }]);
69
+ });
70
+
71
+ test("auth is flagged from x-suluk-access", () => {
72
+ expect(demos[0].requests.every((r) => r.needsAuth)).toBe(true);
73
+ });
74
+ });
75
+
76
+ describe("renderPostman", () => {
77
+ const json = renderPostman(demos, { name: "Billing demo", baseUrl: "https://api.example.com" });
78
+ const collection = JSON.parse(json);
79
+
80
+ test("a v2.1 collection with baseUrl (local-first) + prodBaseUrl + token variables", () => {
81
+ expect(collection.info.schema).toContain("v2.1.0");
82
+ const vars = Object.fromEntries(collection.variable.map((v: any) => [v.key, v.value]));
83
+ expect(vars.baseUrl).toBe("http://localhost:8787"); // dev-first
84
+ expect(vars.prodBaseUrl).toBe("https://api.example.com");
85
+ expect(vars).toHaveProperty("token");
86
+ });
87
+
88
+ test("the charge request body references the chained var, and create captures it via a test script", () => {
89
+ const folder = collection.item[0];
90
+ const create = folder.item[0];
91
+ const charge = folder.item[1];
92
+ expect(charge.request.body.raw).toContain('"subscriptionId": "{{createSubscription_id}}"');
93
+ const createScript = create.event.find((e: any) => e.listen === "test").script.exec.join("\n");
94
+ expect(createScript).toContain('pm.collectionVariables.set("createSubscription_id", pm.response.json().id)');
95
+ });
96
+
97
+ test("auth'd requests carry a bearer header + a 2xx test", () => {
98
+ const create = collection.item[0].item[0];
99
+ expect(create.request.header).toContainEqual({ key: "Authorization", value: "Bearer {{token}}" });
100
+ expect(create.event[0].script.exec.join("\n")).toContain("below(300)");
101
+ });
102
+ });
103
+
104
+ describe("renderBruno", () => {
105
+ const files = renderBruno(demos, { name: "Billing demo", baseUrl: "https://api.example.com" });
106
+
107
+ test("emits a collection manifest + BOTH local and prod environments", () => {
108
+ expect(files["bruno.json"]).toContain('"name": "Billing demo"');
109
+ expect(files["environments/local.bru"]).toContain("baseUrl: http://localhost:8787");
110
+ expect(files["environments/prod.bru"]).toContain("baseUrl: https://api.example.com");
111
+ });
112
+
113
+ test("a .bru file per request, sequenced, with method/url/body", () => {
114
+ const create = files["subscribe-then-charge/1-createsubscription.bru"];
115
+ const charge = files["subscribe-then-charge/2-charge.bru"];
116
+ expect(create).toContain("post {\n url: {{baseUrl}}/subs");
117
+ expect(create).toContain("auth: bearer");
118
+ expect(charge).toContain('"subscriptionId": "{{createSubscription_id}}"');
119
+ });
120
+
121
+ test("the source request captures the chained var via a post-response script", () => {
122
+ const create = files["subscribe-then-charge/1-createsubscription.bru"];
123
+ expect(create).toContain('bru.setVar("createSubscription_id", res.body.id);');
124
+ expect(create).toContain("res.status: lt 300");
125
+ });
126
+ });
@@ -0,0 +1,95 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { generateVocabulary } from "../src/vocabulary";
3
+ import { parseFeature } from "../src/gherkin";
4
+ import { emitRunnableSuite } from "../src/emit";
5
+ import type { OpenAPIv4Document } from "@suluk/core";
6
+
7
+ /**
8
+ * C040-P1 (runnable half) — a Scenario Outline runs PER Examples row through the generated SDK client; an `input` cell
9
+ * is a coerced literal, a `sourced` cell <op.select> is RESOLVED from a prior step's captured response (chaining across
10
+ * a multi-step journey). Each bound When's result is captured under its op.name so a later sourced field reads it.
11
+ */
12
+ const doc = {
13
+ openapi: "4.0.0-candidate",
14
+ info: { title: "Billing" },
15
+ paths: {
16
+ "/subs": {
17
+ requests: {
18
+ createSubscription: {
19
+ method: "post",
20
+ contentSchema: { type: "object", required: ["plan"], properties: { plan: { type: "string" } } },
21
+ responses: { ok: { status: 200 } },
22
+ },
23
+ },
24
+ },
25
+ "/charge": {
26
+ requests: {
27
+ charge: {
28
+ method: "post",
29
+ contentSchema: {
30
+ type: "object",
31
+ required: ["amountCents", "subscriptionId"],
32
+ properties: {
33
+ amountCents: { type: "integer", minimum: 100 },
34
+ subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } },
35
+ },
36
+ },
37
+ responses: { ok: { status: 200 } },
38
+ },
39
+ },
40
+ },
41
+ },
42
+ } as unknown as OpenAPIv4Document;
43
+
44
+ const feature = parseFeature(
45
+ [
46
+ "Feature: billing journeys",
47
+ " Scenario Outline: subscribe then charge",
48
+ " When I create subscription",
49
+ " And I charge",
50
+ " Examples:",
51
+ " | plan | amountCents | subscriptionId |",
52
+ " | pro | 100 | <createSubscription.id> |",
53
+ " | team | 250 | <createSubscription.id> |",
54
+ ].join("\n"),
55
+ );
56
+
57
+ describe("emitRunnableSuite — outline rows + sourced chaining", () => {
58
+ const suite = emitRunnableSuite(doc, generateVocabulary(doc), [feature]);
59
+
60
+ test("unrolls one test per Examples row", () => {
61
+ expect(suite).toContain('test("subscribe then charge — example 1"');
62
+ expect(suite).toContain('test("subscribe then charge — example 2"');
63
+ });
64
+
65
+ test("inlines the pick() helper + a per-row captured bag", () => {
66
+ expect(suite).toContain("const pick =");
67
+ expect(suite).toContain("const captured: Record<string, any> = {}");
68
+ });
69
+
70
+ test("builds each When op's body from the row, mapping columns to that op's fields by name", () => {
71
+ expect(suite).toContain('{ plan: "pro" }'); // createSubscription gets only its own field
72
+ expect(suite).toContain("amountCents: 100"); // input cell coerced to a number literal (its type)
73
+ });
74
+
75
+ test("a sourced cell resolves from the prior captured response (chaining), not a literal", () => {
76
+ expect(suite).toContain('subscriptionId: pick(captured, "createSubscription", "id")');
77
+ });
78
+
79
+ test("captures each bound When's result under its op.name for downstream chaining", () => {
80
+ expect(suite).toContain('captured["createSubscription"] = result1');
81
+ expect(suite).toContain('captured["charge"] = result2');
82
+ });
83
+
84
+ test("row 2 uses its own input value", () => {
85
+ expect(suite).toContain("amountCents: 250");
86
+ });
87
+
88
+ test("a plain (non-outline) scenario still emits a single test with a provide-input placeholder", () => {
89
+ const plain = parseFeature("Feature: f\n\n Scenario: just charge\n When I charge\n Then it succeeds\n");
90
+ const out = emitRunnableSuite(doc, generateVocabulary(doc), [plain]);
91
+ expect(out).toContain('test("just charge"');
92
+ expect(out).toContain("/* provide input */");
93
+ expect(out).not.toContain("const captured");
94
+ });
95
+ });
@@ -0,0 +1,31 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { readFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ /**
6
+ * THE VALUE WALL (C040) — the mirror of the C039 hatch wall, one layer over. Examples are request/response VALUES, and
7
+ * `examples.ts` SYNTHESIZES them. The deterministic PROJECTOR CORE (the vocabulary projection + the binder + the parser
8
+ * + normalization) names only contract FACTS and must never reach the value-synthesis layer — otherwise a value could
9
+ * leak into a place the D1 wall keeps fact-only. Subpath/module naming is not compiler-enforced, so this asserts it over
10
+ * the source. It also pins `examples.ts` as SELF-CONTAINED (no journeys-internal, no external import) so it stays a
11
+ * one-file extraction if @suluk/reference / @suluk/sdk later want the resolver.
12
+ */
13
+ const src = (rel: string) => readFileSync(join(import.meta.dir, "..", "src", rel), "utf8");
14
+
15
+ // The pure projector core — produces contract-derived FACTS and the bind decision. It must not import the value layer.
16
+ const PROJECTOR_CORE = ["vocabulary.ts", "bind.ts", "gherkin.ts", "normalize.ts"];
17
+ const NO_EXAMPLES = /from\s+["']\.{1,2}\/examples["']/;
18
+
19
+ describe("C040 value wall — the projector core never imports the example/value synthesis layer", () => {
20
+ for (const file of PROJECTOR_CORE) {
21
+ test(`src/${file}: does not import ./examples`, () => {
22
+ expect(NO_EXAMPLES.test(src(file))).toBe(false);
23
+ });
24
+ }
25
+
26
+ test("src/examples.ts is a pure re-export of the shared @suluk/examples leaf (no relative value-layer import)", () => {
27
+ const body = src("examples.ts");
28
+ expect(/export\s+\*\s+from\s+["']@suluk\/examples["']/.test(body)).toBe(true);
29
+ expect(/from\s+["']\.{1,2}\//.test(body)).toBe(false); // the impl lives in the leaf, not a journeys-relative file
30
+ });
31
+ });