@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/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/hatch/index.ts +1 -0
- package/src/hatch/runscope.ts +40 -0
- 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/hatch-auth.test.ts +53 -0
- package/test/hatch-runscope.test.ts +28 -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/hatch/index.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Runtime IO only — provably outside the deterministic core (bind.ts / vocabulary.ts), enforced by hatch-wall.test.ts.
|
|
15
15
|
*/
|
|
16
|
+
export { runScope, type RunScope, type RunScopeOptions } from "./runscope";
|
|
16
17
|
export { resolveBackend, localD1, remoteD1, type BackendSpec } from "./backends";
|
|
17
18
|
export { stateHatch, type StateHatchOptions } from "./state";
|
|
18
19
|
export { signInAs, type SignInAsOptions, type SignedInSession, type CleanupTarget } from "./auth";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* runScope (C039) — a per-process RUN SCOPE so parallel git worktrees / CI shards / agents can run the SAME BDD suite
|
|
3
|
+
* without clashing. The only things that clash are SHARED mutable state with a FIXED name; this makes both unique:
|
|
4
|
+
*
|
|
5
|
+
* - `d1Path` is a unique file in the OS temp dir (NOT in the repo), so git/worktrees/.wrangler never see or contend
|
|
6
|
+
* on it — each run opens its own local sqlite.
|
|
7
|
+
* - `scopeId`/`email` are unique, so even against a SHARED backend (prod-as-test-users) the hatch's test-user scoping
|
|
8
|
+
* means a run only ever seeds/cleans ITS OWN rows.
|
|
9
|
+
*
|
|
10
|
+
* `runId = <pid>_<uuid8>` is globally unique across processes and worktrees. Pair it with a hatch `scope: { value:
|
|
11
|
+
* scopeId }` (state hatch) and the `email` (auth hatch) for a fully isolated, parallel-safe run.
|
|
12
|
+
*/
|
|
13
|
+
import { tmpdir } from "node:os";
|
|
14
|
+
import { join } from "node:path";
|
|
15
|
+
|
|
16
|
+
export interface RunScope {
|
|
17
|
+
runId: string;
|
|
18
|
+
/** the test-user id — the state hatch forces every seeded row's owner to this; cleanup deletes only these rows. */
|
|
19
|
+
scopeId: string;
|
|
20
|
+
email: string;
|
|
21
|
+
/** an isolated local sqlite path in the OS temp dir (outside any worktree). */
|
|
22
|
+
d1Path: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface RunScopeOptions {
|
|
26
|
+
/** filename prefix for the temp DB (default "suluk-bdd"). */
|
|
27
|
+
prefix?: string;
|
|
28
|
+
/** email domain for the synthetic test user (default "example.test"). */
|
|
29
|
+
emailDomain?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function runScope(opts: RunScopeOptions = {}): RunScope {
|
|
33
|
+
const runId = `${process.pid}_${crypto.randomUUID().slice(0, 8)}`;
|
|
34
|
+
return {
|
|
35
|
+
runId,
|
|
36
|
+
scopeId: `testuser_${runId}`,
|
|
37
|
+
email: `bdd+${runId}@${opts.emailDomain ?? "example.test"}`,
|
|
38
|
+
d1Path: join(tmpdir(), `${opts.prefix ?? "suluk-bdd"}-${runId}.sqlite`),
|
|
39
|
+
};
|
|
40
|
+
}
|
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
|
+
}
|