@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.
@@ -0,0 +1,53 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { localD1 } from "../src/hatch/backends";
3
+ import { stateHatch } from "../src/hatch/state";
4
+ import { signInAs } from "../src/hatch/auth";
5
+ import type { TestUser } from "../src/hatch/types";
6
+
7
+ /**
8
+ * Durable package witness for the AUTH HATCH contract (C039) — the orchestration + the FAIL-CLOSED guarantee, proven
9
+ * without any real Better Auth (the consumer supplies mintSession/verify; here they are stubs). The end-to-end mint
10
+ * against toolfactory's real Better Auth is witnessed separately by toolfactory's local self-test
11
+ * (scripts/journeys-hatch-selftest.ts). This pins the property that matters most: signInAs NEVER returns a session the
12
+ * app rejected — it can never manufacture a false green — and teardown is test-user-scoped.
13
+ */
14
+ async function fixture() {
15
+ const d1 = await localD1(":memory:");
16
+ await d1.run("CREATE TABLE user (id TEXT PRIMARY KEY, email TEXT)");
17
+ await d1.run("CREATE TABLE session (id TEXT PRIMARY KEY, userId TEXT, token TEXT)");
18
+ const state = stateHatch(d1, { write: true, scope: { value: "testuser_1" } });
19
+ return { d1, state };
20
+ }
21
+ const ensureUser = async (state: Awaited<ReturnType<typeof fixture>>["state"], user: TestUser) => {
22
+ await state.d1.seed("user", "id", [{ email: user.email }], { kind: "auth", because: "OAuth-only; no API seeds a verified user", userPathChecked: true });
23
+ return "testuser_1"; // seed forced user.id = the scope value
24
+ };
25
+ const mintSession = async (userId: string) => ({ cookie: `better-auth.session_token=${userId}.sig` });
26
+
27
+ describe("auth hatch — signInAs (fail-closed, never a false green)", () => {
28
+ test("returns the session + seeds the user when the app ACCEPTS the minted cookie", async () => {
29
+ const { state } = await fixture();
30
+ const s = await signInAs({ state, user: { email: "bdd@example.test" }, ensureUser, mintSession, verify: async () => true });
31
+ expect(s.userId).toBe("testuser_1");
32
+ expect(s.cookie).toBe("better-auth.session_token=testuser_1.sig");
33
+ expect(await state.d1.get("SELECT id FROM user WHERE id = ?", ["testuser_1"])).toEqual({ id: "testuser_1" });
34
+ });
35
+
36
+ test("THROWS when the app REJECTS the minted session — fails closed, never returns an unverified credential", async () => {
37
+ const { state } = await fixture();
38
+ await expect(
39
+ signInAs({ state, user: { email: "bdd@example.test" }, ensureUser, mintSession, verify: async () => false }),
40
+ ).rejects.toThrow(/REJECTED|fails closed/);
41
+ });
42
+
43
+ test("teardown scoped-deletes only the test user's user + session rows", async () => {
44
+ const { d1, state } = await fixture();
45
+ await d1.run("INSERT INTO session (id, userId, token) VALUES (?,?,?)", ["s1", "testuser_1", "t"]);
46
+ await d1.run("INSERT INTO user (id, email) VALUES (?,?)", ["real_1", "real@customer.com"]); // a co-resident real user
47
+ const s = await signInAs({ state, user: { email: "bdd@example.test" }, ensureUser, mintSession, verify: async () => true });
48
+ await s.teardown(); // default targets: session.userId + user.id
49
+ expect(await state.d1.select("SELECT id FROM user WHERE id = ?", ["testuser_1"])).toEqual([]);
50
+ expect(await state.d1.select("SELECT id FROM session WHERE userId = ?", ["testuser_1"])).toEqual([]);
51
+ expect(await state.d1.select("SELECT id FROM user WHERE id = ?", ["real_1"])).toEqual([{ id: "real_1" }]); // untouched
52
+ });
53
+ });
@@ -0,0 +1,28 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { tmpdir } from "node:os";
3
+ import { runScope } from "../src/hatch/runscope";
4
+
5
+ /**
6
+ * runScope is the parallel-safety primitive (C039): every call yields a UNIQUE test-user identity + an isolated temp DB
7
+ * path, so parallel worktrees / CI shards running the same BDD suite never clash.
8
+ */
9
+ describe("runScope — unique-per-call, isolated, parallel-safe", () => {
10
+ test("each call is globally unique (id, scopeId, email, d1Path)", () => {
11
+ const a = runScope();
12
+ const b = runScope();
13
+ expect(a.runId).not.toBe(b.runId);
14
+ expect(a.scopeId).not.toBe(b.scopeId);
15
+ expect(a.email).not.toBe(b.email);
16
+ expect(a.d1Path).not.toBe(b.d1Path);
17
+ // 50 calls → 50 distinct ids
18
+ expect(new Set(Array.from({ length: 50 }, () => runScope().runId)).size).toBe(50);
19
+ });
20
+
21
+ test("the DB path is in the OS temp dir (outside any worktree/repo) and the scope ties everything to one test user", () => {
22
+ const s = runScope({ prefix: "tf-bdd" });
23
+ expect(s.d1Path.startsWith(tmpdir())).toBe(true);
24
+ expect(s.d1Path).toContain("tf-bdd-");
25
+ expect(s.scopeId).toBe(`testuser_${s.runId}`);
26
+ expect(s.email).toContain(s.runId);
27
+ });
28
+ });
@@ -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
+ });
@@ -0,0 +1,129 @@
1
+ import { test, expect, describe } from "bun:test";
2
+ import { parseFeature } from "../src/gherkin";
3
+ import {
4
+ extractPublicRows,
5
+ buildExampleObject,
6
+ promoteExampleIntoZod,
7
+ promoteFeatureExamples,
8
+ } from "../src/promote";
9
+ import type { JsonSchema } from "@suluk/examples";
10
+
11
+ /**
12
+ * C040-P4 — promote a tester's @public Examples row into the Zod source as .meta({ examples }). The source-write is
13
+ * MARKED, IDEMPOTENT, and NEVER CLOBBERS a hand-authored example.
14
+ */
15
+
16
+ describe("extractPublicRows — a @public Examples block's first row", () => {
17
+ const feature = parseFeature(
18
+ [
19
+ "Feature: f",
20
+ " Scenario Outline: charge",
21
+ " When I charge",
22
+ " @public",
23
+ " Examples:",
24
+ " | amountCents | currency |",
25
+ " | 100 | usd |",
26
+ " | 250 | eur |",
27
+ " Scenario Outline: private",
28
+ " When I charge",
29
+ " Examples:",
30
+ " | amountCents |",
31
+ " | 999 |",
32
+ ].join("\n"),
33
+ );
34
+
35
+ test("only the @public-tagged block is returned, first row only", () => {
36
+ const rows = extractPublicRows([feature]);
37
+ expect(rows).toHaveLength(1);
38
+ expect(rows[0].scenario).toBe("charge");
39
+ expect(rows[0].headers).toEqual(["amountCents", "currency"]);
40
+ expect(rows[0].row).toEqual(["100", "usd"]);
41
+ });
42
+ });
43
+
44
+ describe("buildExampleObject — typed coercion, wiring tokens skipped", () => {
45
+ const body: JsonSchema = {
46
+ type: "object",
47
+ properties: { amountCents: { type: "integer" }, currency: { type: "string" }, live: { type: "boolean" } },
48
+ };
49
+ test("coerces cells by the field type", () => {
50
+ expect(buildExampleObject(["amountCents", "currency", "live"], ["100", "usd", "true"], body)).toEqual({
51
+ amountCents: 100,
52
+ currency: "usd",
53
+ live: true,
54
+ });
55
+ });
56
+ test("a sourced wiring token <op.select> is not a concrete public value — skipped", () => {
57
+ expect(buildExampleObject(["amountCents", "subId"], ["100", "<createSubscription.id>"], body)).toEqual({ amountCents: 100 });
58
+ });
59
+ });
60
+
61
+ describe("promoteExampleIntoZod — marked, idempotent, never-clobber", () => {
62
+ const base = `import { z } from "zod";\nexport const ChargeBody = z.object({ amountCents: z.number().int() });\n`;
63
+
64
+ test("appends a marked .meta({ examples }) to a schema with no meta", () => {
65
+ const r = promoteExampleIntoZod(base, "ChargeBody", { amountCents: 100 }, "charge");
66
+ expect(r.changed).toBe(true);
67
+ expect(r.source).toContain("@suluk-public: charge");
68
+ expect(r.source).toContain('examples: [{"amountCents":100}]');
69
+ expect(r.source).toContain("z.object({ amountCents: z.number().int() }).meta(");
70
+ });
71
+
72
+ test("is idempotent — re-promoting REPLACES the marked block (single .meta), no double-append", () => {
73
+ const once = promoteExampleIntoZod(base, "ChargeBody", { amountCents: 100 }, "charge").source;
74
+ const twice = promoteExampleIntoZod(once, "ChargeBody", { amountCents: 250 }, "charge").source;
75
+ expect(twice.match(/@suluk-public/g)).toHaveLength(1);
76
+ expect(twice).toContain('examples: [{"amountCents":250}]');
77
+ expect(twice).not.toContain('examples: [{"amountCents":100}]');
78
+ });
79
+
80
+ test("REFUSES to clobber a hand-authored top-level .meta({ examples })", () => {
81
+ const hand = `export const ChargeBody = z.object({ amountCents: z.number() }).meta({ examples: [{ amountCents: 1 }] });\n`;
82
+ const r = promoteExampleIntoZod(hand, "ChargeBody", { amountCents: 100 }, "charge");
83
+ expect(r.changed).toBe(false);
84
+ expect(r.reason).toMatch(/not clobbering/i);
85
+ expect(r.source).toBe(hand);
86
+ });
87
+
88
+ test("appends safely after a non-example .meta (description) — preserved", () => {
89
+ const desc = `export const ChargeBody = z.object({ amountCents: z.number() }).meta({ description: "a charge" });\n`;
90
+ const r = promoteExampleIntoZod(desc, "ChargeBody", { amountCents: 100 }, "charge");
91
+ expect(r.changed).toBe(true);
92
+ expect(r.source).toContain('description: "a charge"');
93
+ expect(r.source).toContain("@suluk-public: charge");
94
+ });
95
+
96
+ test("a PROPERTY-level .meta is not mistaken for the top-level example meta", () => {
97
+ const prop = `export const ChargeBody = z.object({ amountCents: z.number().meta({ description: "cents" }) });\n`;
98
+ const r = promoteExampleIntoZod(prop, "ChargeBody", { amountCents: 100 }, "charge");
99
+ expect(r.changed).toBe(true);
100
+ // the property meta is untouched; a NEW top-level meta is appended
101
+ expect(r.source).toContain('z.number().meta({ description: "cents" })');
102
+ expect(r.source).toContain(") }).meta(/* @suluk-public");
103
+ });
104
+
105
+ test("a missing schema var is reported, not edited", () => {
106
+ const r = promoteExampleIntoZod(base, "NopeBody", { x: 1 }, "x");
107
+ expect(r.changed).toBe(false);
108
+ expect(r.reason).toMatch(/not found/);
109
+ });
110
+ });
111
+
112
+ describe("promoteFeatureExamples — orchestrated over a feature, target injected", () => {
113
+ const source = `export const ChargeBody = z.object({ amountCents: z.number().int() });\n`;
114
+ const feature = parseFeature(
115
+ ["Feature: f", " Scenario Outline: charge", " When I charge", " @public", " Examples:", " | amountCents |", " | 100 |"].join("\n"),
116
+ );
117
+
118
+ test("applies the public row to the resolved schema var", () => {
119
+ const r = promoteFeatureExamples(source, [feature], (sc) => (sc === "charge" ? { schemaVar: "ChargeBody", bodySchema: { type: "object", properties: { amountCents: { type: "integer" } } } } : null));
120
+ expect(r.applied).toEqual([{ scenario: "charge", schemaVar: "ChargeBody", reason: expect.stringContaining("promoted") }]);
121
+ expect(r.source).toContain('examples: [{"amountCents":100}]');
122
+ });
123
+
124
+ test("an unresolved scenario is skipped, not applied", () => {
125
+ const r = promoteFeatureExamples(source, [feature], () => null);
126
+ expect(r.applied).toHaveLength(0);
127
+ expect(r.skipped[0].reason).toMatch(/no target/);
128
+ });
129
+ });