@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
|
@@ -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
|
+
});
|
package/test/cli.test.ts
ADDED
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { buildScenarioOutlines, renderScenarioOutlines } from "../src/outline";
|
|
3
|
+
import { parseFeature } from "../src/gherkin";
|
|
4
|
+
import type { OpenAPIv4Document } from "@suluk/core";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* C040-P1 — generate a Scenario Outline per op: columns = client-facing input fields (computed dropped), seed row from
|
|
8
|
+
* the C041 origin-aware resolver; a `sourced` column seeds as the wiring token `<op.select>`, not a value. Plus the
|
|
9
|
+
* gherkin parser now CAPTURES the Examples table (it used to drop it), so render→parse round-trips.
|
|
10
|
+
*/
|
|
11
|
+
const doc = {
|
|
12
|
+
openapi: "4.0.0-candidate",
|
|
13
|
+
info: { title: "Billing" },
|
|
14
|
+
paths: {
|
|
15
|
+
"billing/charge": {
|
|
16
|
+
requests: {
|
|
17
|
+
charge: {
|
|
18
|
+
method: "post",
|
|
19
|
+
contentSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
required: ["amountCents", "subscriptionId"],
|
|
22
|
+
properties: {
|
|
23
|
+
amountCents: { type: "integer", minimum: 100 },
|
|
24
|
+
subscriptionId: { type: "string", "x-suluk-origin": "sourced", "x-suluk-from": { op: "createSubscription", select: "id" } },
|
|
25
|
+
total: { type: "number", "x-suluk-origin": "computed" },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
responses: { ok: { status: 200 } },
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
health: { requests: { health: { method: "get", responses: { ok: { status: 200 } } } } },
|
|
33
|
+
},
|
|
34
|
+
} as unknown as OpenAPIv4Document;
|
|
35
|
+
|
|
36
|
+
describe("buildScenarioOutlines", () => {
|
|
37
|
+
const outlines = Object.fromEntries(buildScenarioOutlines(doc).map((o) => [o.op, o]));
|
|
38
|
+
|
|
39
|
+
test("columns are the client-facing inputs; a computed field is dropped", () => {
|
|
40
|
+
expect(outlines.charge.columns.map((c) => c.name)).toEqual(["amountCents", "subscriptionId"]);
|
|
41
|
+
expect(outlines.charge.columns.map((c) => c.origin)).toEqual(["input", "sourced"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("an input cell seeds a synthesized value; a sourced cell seeds the <op.select> wiring token", () => {
|
|
45
|
+
const byName = Object.fromEntries(outlines.charge.columns.map((c) => [c.name, c.seed]));
|
|
46
|
+
expect(byName.amountCents).toBe("100"); // synthesized, honors minimum
|
|
47
|
+
expect(byName.subscriptionId).toBe("<createSubscription.id>"); // wired, not a value
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the When phrase is the contract's bindable vocabulary phrase (columns carry the body, so the outline binds)", () => {
|
|
51
|
+
expect(outlines.charge.whenPhrase).toBe("I charge");
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("a body-less op has no columns (renders as a plain Scenario, not an Outline)", () => {
|
|
55
|
+
expect(outlines.health.columns).toEqual([]);
|
|
56
|
+
expect(outlines.health.whenPhrase).toBe("I health");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("renderScenarioOutlines → a .feature sidecar", () => {
|
|
61
|
+
const feature = renderScenarioOutlines(doc);
|
|
62
|
+
|
|
63
|
+
test("a column-bearing op becomes a Scenario Outline with an Examples table + the wiring token", () => {
|
|
64
|
+
expect(feature).toContain("Scenario Outline: charge");
|
|
65
|
+
expect(feature).toContain("Examples:");
|
|
66
|
+
expect(feature).toContain("amountCents");
|
|
67
|
+
expect(feature).toContain("<createSubscription.id>");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("a body-less op becomes a plain Scenario (no Outline, no Examples)", () => {
|
|
71
|
+
expect(feature).toContain("Scenario: health");
|
|
72
|
+
expect(feature).not.toContain("Scenario Outline: health");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("parseFeature captures the Examples table (round-trip)", () => {
|
|
77
|
+
test("render → parse recovers the headers + the seed row", () => {
|
|
78
|
+
const feature = renderScenarioOutlines(doc, { only: ["charge"] });
|
|
79
|
+
const parsed = parseFeature(feature);
|
|
80
|
+
const charge = parsed.scenarios.find((s) => s.name === "charge")!;
|
|
81
|
+
expect(charge.examples?.headers).toEqual(["amountCents", "subscriptionId"]);
|
|
82
|
+
expect(charge.examples?.rows).toEqual([["100", "<createSubscription.id>"]]);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("a plain Scenario has no examples block", () => {
|
|
86
|
+
const parsed = parseFeature("Feature: f\n\n Scenario: s\n When I do\n Then it works\n");
|
|
87
|
+
expect(parsed.scenarios[0].examples).toBeUndefined();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("a hand-authored Outline with multiple rows parses every row", () => {
|
|
91
|
+
const src = [
|
|
92
|
+
"Feature: f",
|
|
93
|
+
" Scenario Outline: charge",
|
|
94
|
+
" When I charge with amountCents=<amountCents>",
|
|
95
|
+
" Then it succeeds",
|
|
96
|
+
" Examples:",
|
|
97
|
+
" | amountCents |",
|
|
98
|
+
" | 100 |",
|
|
99
|
+
" | 250 |",
|
|
100
|
+
].join("\n");
|
|
101
|
+
const charge = parseFeature(src).scenarios[0];
|
|
102
|
+
expect(charge.examples?.headers).toEqual(["amountCents"]);
|
|
103
|
+
expect(charge.examples?.rows).toEqual([["100"], ["250"]]);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { test, expect, describe } from "bun:test";
|
|
2
|
+
import { planPromotions, parseTargetSpec, miniDiff, type PromoteTargetSpec } from "../src/cli";
|
|
3
|
+
import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* C040-P4 CLI — `journeys promote` lifts a tester's @public Examples row into the Zod source. planPromotions is the pure
|
|
9
|
+
* core (no fs); the bin is dry-run-by-default + --write (the reviewable safety model; a substrate operator runs
|
|
10
|
+
* mizan_check_action_safety before --write).
|
|
11
|
+
*/
|
|
12
|
+
const featureText = ["Feature: f", " Scenario Outline: charge", " When I charge", " @public", " Examples:", " | amountCents | currency |", " | 100 | usd |"].join("\n");
|
|
13
|
+
const source = `import { z } from "zod";\nexport const ChargeBody = z.object({ amountCents: z.number().int(), currency: z.string() });\n`;
|
|
14
|
+
|
|
15
|
+
describe("parseTargetSpec", () => {
|
|
16
|
+
test('parses "<scenario>=<file>#<schemaVar>" (scenario may have spaces)', () => {
|
|
17
|
+
expect(parseTargetSpec("subscribe then charge=src/validation.ts#ChargeBody")).toEqual({ scenario: "subscribe then charge", file: "src/validation.ts", schemaVar: "ChargeBody" });
|
|
18
|
+
});
|
|
19
|
+
test("rejects a malformed spec", () => {
|
|
20
|
+
expect(parseTargetSpec("no-hash=file.ts")).toBeNull();
|
|
21
|
+
expect(parseTargetSpec("nodelimiters")).toBeNull();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("planPromotions", () => {
|
|
26
|
+
const targets = new Map<string, PromoteTargetSpec>([["charge", { file: "validation.ts", schemaVar: "ChargeBody" }]]);
|
|
27
|
+
|
|
28
|
+
test("applies the @public row to the target source (content-typed coercion)", () => {
|
|
29
|
+
const plan = planPromotions([featureText], targets, { "validation.ts": source });
|
|
30
|
+
expect(plan.rows[0]).toMatchObject({ scenario: "charge", schemaVar: "ChargeBody", status: "applied" });
|
|
31
|
+
const file = plan.files.find((f) => f.file === "validation.ts")!;
|
|
32
|
+
expect(file.changed).toBe(true);
|
|
33
|
+
expect(file.updated).toContain('examples: [{"amountCents":100,"currency":"usd"}]'); // 100 → number, usd → string
|
|
34
|
+
expect(file.updated).toContain("@suluk-public");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("an unmapped scenario is reported as skipped, source unchanged", () => {
|
|
38
|
+
const plan = planPromotions([featureText], new Map(), { "validation.ts": source });
|
|
39
|
+
expect(plan.rows[0].status).toBe("skipped");
|
|
40
|
+
expect(plan.files.every((f) => !f.changed)).toBe(true);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("never-clobber surfaces as a skipped row (hand-authored examples present)", () => {
|
|
44
|
+
const hand = `export const ChargeBody = z.object({ amountCents: z.number() }).meta({ examples: [{ amountCents: 1 }] });\n`;
|
|
45
|
+
const plan = planPromotions([featureText], targets, { "validation.ts": hand });
|
|
46
|
+
expect(plan.rows[0].status).toBe("skipped");
|
|
47
|
+
expect(plan.rows[0].reason).toMatch(/not clobbering/i);
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("miniDiff", () => {
|
|
52
|
+
test("marks removed/added lines with context", () => {
|
|
53
|
+
const d = miniDiff("a\nb\nc\n", "a\nB\nc\n");
|
|
54
|
+
expect(d).toContain("- b");
|
|
55
|
+
expect(d).toContain("+ B");
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
describe("bin: `journeys promote` end-to-end on disk", () => {
|
|
60
|
+
test("dry run prints a diff but does NOT write; --write applies", () => {
|
|
61
|
+
const dir = mkdtempSync(join(tmpdir(), "journeys-promote-"));
|
|
62
|
+
try {
|
|
63
|
+
const featDir = join(dir, "features");
|
|
64
|
+
const src = join(dir, "validation.ts");
|
|
65
|
+
mkdirSync(featDir);
|
|
66
|
+
writeFileSync(join(featDir, "billing.feature"), featureText);
|
|
67
|
+
writeFileSync(src, source);
|
|
68
|
+
const bin = join(import.meta.dir, "..", "bin", "journeys.ts");
|
|
69
|
+
const target = `charge=${src}#ChargeBody`;
|
|
70
|
+
|
|
71
|
+
const dry = Bun.spawnSync(["bun", bin, "promote", "--features", featDir, "--target", target]);
|
|
72
|
+
expect(dry.exitCode).toBe(0);
|
|
73
|
+
expect(dry.stdout.toString()).toContain("@suluk-public");
|
|
74
|
+
expect(dry.stdout.toString()).toContain("dry run");
|
|
75
|
+
expect(readFileSync(src, "utf8")).toBe(source); // UNCHANGED on a dry run
|
|
76
|
+
|
|
77
|
+
const wrote = Bun.spawnSync(["bun", bin, "promote", "--features", featDir, "--target", target, "--write"]);
|
|
78
|
+
expect(wrote.exitCode).toBe(0);
|
|
79
|
+
expect(wrote.stdout.toString()).toContain("wrote 1 file");
|
|
80
|
+
expect(readFileSync(src, "utf8")).toContain("@suluk-public"); // applied on --write
|
|
81
|
+
} finally {
|
|
82
|
+
rmSync(dir, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
});
|