@pikku/core 0.12.69 → 0.12.70

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.
Files changed (84) hide show
  1. package/CHANGELOG.md +334 -0
  2. package/README.md +34 -2
  3. package/dist/function/functions.types.d.ts +27 -0
  4. package/dist/index.d.ts +1 -1
  5. package/dist/internal.d.ts +1 -1
  6. package/dist/internal.js +1 -1
  7. package/dist/pikku-state.js +1 -0
  8. package/dist/services/http-scenario-actors.d.ts +12 -4
  9. package/dist/services/http-scenario-actors.js +47 -45
  10. package/dist/services/index.d.ts +2 -1
  11. package/dist/services/index.js +1 -0
  12. package/dist/services/meta-service.d.ts +5 -1
  13. package/dist/services/meta-service.js +44 -18
  14. package/dist/services/scenario-actors-service.d.ts +108 -2
  15. package/dist/services/scenario-actors-service.js +40 -1
  16. package/dist/types/core.types.d.ts +21 -3
  17. package/dist/types/state.types.d.ts +3 -1
  18. package/dist/wirings/actor-flow/actor-flow.types.d.ts +1 -1
  19. package/dist/wirings/actor-flow/index.d.ts +1 -1
  20. package/dist/wirings/actor-flow/run-conversation.d.ts +10 -10
  21. package/dist/wirings/actor-flow/run-conversation.js +27 -27
  22. package/dist/wirings/cli/command-parser.js +11 -1
  23. package/dist/wirings/rpc/rpc-runner.js +1 -1
  24. package/dist/wirings/workflow/dsl/workflow-dsl.types.d.ts +52 -3
  25. package/dist/wirings/workflow/feature.d.ts +28 -0
  26. package/dist/wirings/workflow/feature.js +57 -0
  27. package/dist/wirings/workflow/index.d.ts +13 -2
  28. package/dist/wirings/workflow/index.js +15 -0
  29. package/dist/wirings/workflow/pikku-scenario-service.d.ts +121 -0
  30. package/dist/wirings/workflow/pikku-scenario-service.js +419 -0
  31. package/dist/wirings/workflow/pikku-workflow-service.d.ts +103 -10
  32. package/dist/wirings/workflow/pikku-workflow-service.js +80 -135
  33. package/dist/wirings/workflow/scenario-cookie-jar.d.ts +29 -0
  34. package/dist/wirings/workflow/scenario-cookie-jar.js +51 -0
  35. package/dist/wirings/workflow/scenario-poll.d.ts +20 -0
  36. package/dist/wirings/workflow/scenario-poll.js +25 -0
  37. package/dist/wirings/workflow/scenario-prose.d.ts +38 -0
  38. package/dist/wirings/workflow/scenario-prose.js +45 -0
  39. package/dist/wirings/workflow/scenario-step-guards.d.ts +16 -0
  40. package/dist/wirings/workflow/scenario-step-guards.js +29 -0
  41. package/dist/wirings/workflow/scenario-step.types.d.ts +148 -0
  42. package/dist/wirings/workflow/scenario-step.types.js +1 -0
  43. package/dist/wirings/workflow/workflow.types.d.ts +81 -2
  44. package/package.json +3 -1
  45. package/src/function/functions.types.ts +32 -0
  46. package/src/index.ts +1 -0
  47. package/src/internal.ts +5 -1
  48. package/src/pikku-state.ts +1 -0
  49. package/src/services/http-scenario-actors.test.ts +85 -1
  50. package/src/services/http-scenario-actors.ts +65 -51
  51. package/src/services/index.ts +5 -0
  52. package/src/services/meta-service.test.ts +79 -0
  53. package/src/services/meta-service.ts +61 -26
  54. package/src/services/scenario-actors-service.ts +157 -2
  55. package/src/types/core.types.ts +27 -2
  56. package/src/types/state.types.ts +3 -0
  57. package/src/wirings/actor-flow/actor-flow.types.ts +1 -1
  58. package/src/wirings/actor-flow/index.ts +1 -1
  59. package/src/wirings/actor-flow/run-conversation.test.ts +12 -6
  60. package/src/wirings/actor-flow/run-conversation.ts +36 -41
  61. package/src/wirings/cli/command-parser.test.ts +60 -0
  62. package/src/wirings/cli/command-parser.ts +12 -1
  63. package/src/wirings/rpc/rpc-runner.test.ts +28 -5
  64. package/src/wirings/rpc/rpc-runner.ts +1 -1
  65. package/src/wirings/workflow/dsl/workflow-dsl.types.ts +86 -2
  66. package/src/wirings/workflow/feature.test.ts +131 -0
  67. package/src/wirings/workflow/feature.ts +78 -0
  68. package/src/wirings/workflow/index.ts +73 -0
  69. package/src/wirings/workflow/pikku-scenario-service.ts +682 -0
  70. package/src/wirings/workflow/pikku-workflow-service.test.ts +55 -0
  71. package/src/wirings/workflow/pikku-workflow-service.ts +196 -208
  72. package/src/wirings/workflow/scenario-cookie-jar.test.ts +108 -0
  73. package/src/wirings/workflow/scenario-cookie-jar.ts +65 -0
  74. package/src/wirings/workflow/scenario-hooks.test.ts +212 -0
  75. package/src/wirings/workflow/scenario-poll.test.ts +66 -0
  76. package/src/wirings/workflow/scenario-poll.ts +36 -0
  77. package/src/wirings/workflow/scenario-prose.test.ts +152 -0
  78. package/src/wirings/workflow/scenario-prose.ts +79 -0
  79. package/src/wirings/workflow/scenario-service.test.ts +155 -0
  80. package/src/wirings/workflow/scenario-step-guards.ts +43 -0
  81. package/src/wirings/workflow/scenario-step.test.ts +441 -8
  82. package/src/wirings/workflow/scenario-step.types.ts +157 -0
  83. package/src/wirings/workflow/workflow.types.ts +98 -1
  84. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,108 @@
1
+ import { describe, test, after } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { createServer, type Server } from 'node:http'
4
+
5
+ import { createCookieJar } from './scenario-cookie-jar.js'
6
+
7
+ // Minimal target: hands out cookies, and reports back what it was sent.
8
+ const startTarget = async () => {
9
+ let issued = 0
10
+ const server: Server = createServer((req, res) => {
11
+ if (req.url === '/set') {
12
+ issued++
13
+ res.setHeader('set-cookie', [
14
+ `session=s${issued}; Path=/; HttpOnly`,
15
+ `csrf=c${issued}; Path=/`,
16
+ ])
17
+ res.writeHead(200, { 'content-type': 'application/json' }).end('{}')
18
+ return
19
+ }
20
+ res.writeHead(200, { 'content-type': 'application/json' }).end(
21
+ JSON.stringify({
22
+ cookie: req.headers.cookie ?? '',
23
+ origin: req.headers.origin ?? '',
24
+ })
25
+ )
26
+ })
27
+ await new Promise<void>((done) => server.listen(0, '127.0.0.1', done))
28
+ const { port } = server.address() as { port: number }
29
+ return { server, apiUrl: `http://127.0.0.1:${port}` }
30
+ }
31
+
32
+ describe('createCookieJar', () => {
33
+ test('sends back the cookies the target set, on the next request', async () => {
34
+ const { server, apiUrl } = await startTarget()
35
+ after(() => server.close())
36
+
37
+ const { fetch: call } = createCookieJar(apiUrl)
38
+ await call(`${apiUrl}/set`)
39
+ const seen = await (await call(`${apiUrl}/echo`)).json()
40
+
41
+ assert.match(seen.cookie, /session=s1/)
42
+ assert.match(seen.cookie, /csrf=c1/)
43
+ })
44
+
45
+ test('stamps the origin Better Auth checks on state-changing calls', async () => {
46
+ const { server, apiUrl } = await startTarget()
47
+ after(() => server.close())
48
+
49
+ const seen = await (
50
+ await createCookieJar(apiUrl).fetch(`${apiUrl}/echo`)
51
+ ).json()
52
+
53
+ assert.equal(seen.origin, apiUrl)
54
+ })
55
+
56
+ test('replaces a cookie the target reissues rather than sending both', async () => {
57
+ const { server, apiUrl } = await startTarget()
58
+ after(() => server.close())
59
+
60
+ const { fetch: call } = createCookieJar(apiUrl)
61
+ await call(`${apiUrl}/set`)
62
+ await call(`${apiUrl}/set`)
63
+ const seen = await (await call(`${apiUrl}/echo`)).json()
64
+
65
+ assert.match(seen.cookie, /session=s2/)
66
+ assert.doesNotMatch(seen.cookie, /session=s1/)
67
+ })
68
+
69
+ test('keeps each jar to itself, so one actor never inherits another session', async () => {
70
+ const { server, apiUrl } = await startTarget()
71
+ after(() => server.close())
72
+
73
+ const first = createCookieJar(apiUrl)
74
+ await first.fetch(`${apiUrl}/set`)
75
+ const other = await (
76
+ await createCookieJar(apiUrl).fetch(`${apiUrl}/echo`)
77
+ ).json()
78
+
79
+ assert.equal(other.cookie, '')
80
+ })
81
+
82
+ test('forgets the session when cleared, so a re-login starts fresh', async () => {
83
+ const { server, apiUrl } = await startTarget()
84
+ after(() => server.close())
85
+
86
+ const jar = createCookieJar(apiUrl)
87
+ await jar.fetch(`${apiUrl}/set`)
88
+ assert.equal(jar.empty, false)
89
+ jar.clear()
90
+ assert.equal(jar.empty, true)
91
+
92
+ const seen = await (await jar.fetch(`${apiUrl}/echo`)).json()
93
+ assert.equal(seen.cookie, '')
94
+ })
95
+
96
+ test('leaves the caller headers alone', async () => {
97
+ const { server, apiUrl } = await startTarget()
98
+ after(() => server.close())
99
+
100
+ const headers = new Headers({ 'x-test': 'kept' })
101
+ const { fetch: call } = createCookieJar(apiUrl)
102
+ await call(`${apiUrl}/set`)
103
+ await call(`${apiUrl}/echo`, { headers })
104
+
105
+ assert.equal(headers.get('cookie'), null)
106
+ assert.equal(headers.get('x-test'), 'kept')
107
+ })
108
+ })
@@ -0,0 +1,65 @@
1
+ /** A session held the way a browser holds one, for code driving a real target. */
2
+ export interface ScenarioCookieJar {
3
+ /** `fetch`, but it sends what the target has set and keeps what it sets. */
4
+ fetch: typeof fetch
5
+ /** Forgets the session — what an actor does before signing in again. */
6
+ clear(): void
7
+ /**
8
+ * Whether the target has set anything yet. This is a fact about the jar, not
9
+ * about the session: a target that sets a CSRF or locale cookie before anyone
10
+ * signs in fills the jar without establishing one. Whoever needs to know
11
+ * whether a sign-in happened has to track the sign-in.
12
+ */
13
+ readonly empty: boolean
14
+ }
15
+
16
+ /**
17
+ * A `fetch` that remembers cookies.
18
+ *
19
+ * A browser persists the session cookie on its own; anything driving a target
20
+ * from this process has to. Every response is read, not just the sign-in, so a
21
+ * cookie the target rotates mid-session is followed rather than dropped.
22
+ *
23
+ * The jar is a closure local, so two jars never share a session — which is what
24
+ * lets one scenario sign in as several people without one of them inheriting
25
+ * the other's session.
26
+ *
27
+ * It also stamps `Origin`, because Better Auth rejects a state-changing POST
28
+ * whose Origin does not match its baseURL.
29
+ */
30
+ export const createCookieJar = (apiUrl: string): ScenarioCookieJar => {
31
+ const jar = new Map<string, string>()
32
+ const origin = new URL(apiUrl).origin
33
+ return {
34
+ fetch: async (input, init) => {
35
+ const headers = new Headers(init?.headers)
36
+ headers.set('origin', origin)
37
+ const held = [...jar].map(([name, value]) => `${name}=${value}`)
38
+ const caller = headers.get('cookie')
39
+ if (held.length > 0 || caller) {
40
+ headers.set('cookie', [caller, ...held].filter(Boolean).join('; '))
41
+ }
42
+ const response = await fetch(input, { ...init, headers })
43
+ for (const raw of response.headers.getSetCookie()) {
44
+ const [pair] = raw.split(';')
45
+ const separator = pair!.indexOf('=')
46
+ if (separator > 0) {
47
+ const name = pair!.slice(0, separator)
48
+ const value = pair!.slice(separator + 1)
49
+ // An empty value is how a target deletes a cookie — drop the name
50
+ // rather than holding a cookie whose value says it is gone.
51
+ if (value) {
52
+ jar.set(name, value)
53
+ } else {
54
+ jar.delete(name)
55
+ }
56
+ }
57
+ }
58
+ return response
59
+ },
60
+ clear: () => jar.clear(),
61
+ get empty() {
62
+ return jar.size === 0
63
+ },
64
+ }
65
+ }
@@ -0,0 +1,212 @@
1
+ import { describe, test, beforeEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { createScenarioRunner } from './pikku-scenario-service.js'
5
+ import { pikkuState, resetPikkuState } from '../../pikku-state.js'
6
+ import { addWorkflow } from './dsl/workflow-runner.js'
7
+ import type { ScenarioActors } from '../../services/scenario-actors-service.js'
8
+
9
+ const noopLogger = {
10
+ error() {},
11
+ info() {},
12
+ warn() {},
13
+ debug() {},
14
+ }
15
+
16
+ const actors = {
17
+ admin: {
18
+ name: 'admin',
19
+ email: 'admin@actors.local',
20
+ invoke: async () => ({}),
21
+ invokeRaw: async () => ({ status: 200, ok: true, body: {} }),
22
+ },
23
+ } as unknown as ScenarioActors
24
+
25
+ type ScenarioHooks = {
26
+ before?: (services: any, data: any, wire: any) => Promise<void> | void
27
+ after?: (services: any, data: any, wire: any) => Promise<void> | void
28
+ }
29
+
30
+ /**
31
+ * Registers a scenario (or, with `source: 'dsl'`, a plain workflow) and runs it
32
+ * inline, returning the error it threw rather than propagating — every hook
33
+ * assertion here is about what ran, not about how the caller saw it.
34
+ */
35
+ const runScenario = async (
36
+ name: string,
37
+ config: ScenarioHooks & { func: (...args: any[]) => any },
38
+ { input = {}, source = 'scenario' as 'scenario' | 'dsl' } = {}
39
+ ) => {
40
+ const { workflowService: ws, scenarioService } = createScenarioRunner()
41
+
42
+ pikkuState(null, 'package', 'singletonServices', {
43
+ logger: noopLogger,
44
+ marker: 'singleton',
45
+ } as any)
46
+
47
+ const metaState = pikkuState(null, 'workflows', 'meta')
48
+ metaState[name] = {
49
+ name,
50
+ pikkuFuncId: name,
51
+ source,
52
+ graphHash: `${name}-hash`,
53
+ } as any
54
+
55
+ const functionMetaState = pikkuState(null, 'function', 'meta')
56
+ functionMetaState[name] = {
57
+ name,
58
+ sessionless: true,
59
+ permissions: [],
60
+ } as any
61
+
62
+ addWorkflow(name, config as any)
63
+
64
+ let error: any
65
+ try {
66
+ await ws.startWorkflow(name, input, {} as any, {}, { actors })
67
+ } catch (e: any) {
68
+ error = e
69
+ }
70
+ const runs = await ws.listRuns({ workflowName: name })
71
+ const run = runs[0]
72
+ return { error, run, ws }
73
+ }
74
+
75
+ describe('scenario before/after hooks', () => {
76
+ beforeEach(() => resetPikkuState())
77
+
78
+ test('before runs, then the scenario, then after', async () => {
79
+ const order: string[] = []
80
+ await runScenario('hooksOrder', {
81
+ before: async () => {
82
+ order.push('before')
83
+ },
84
+ after: async () => {
85
+ order.push('after')
86
+ },
87
+ func: async () => {
88
+ order.push('func')
89
+ return { ok: true }
90
+ },
91
+ })
92
+ assert.deepEqual(order, ['before', 'func', 'after'])
93
+ })
94
+
95
+ test('after still runs when the scenario throws', async () => {
96
+ const order: string[] = []
97
+ const { error, run } = await runScenario('hooksAfterOnFailure', {
98
+ after: async () => {
99
+ order.push('after')
100
+ },
101
+ func: async () => {
102
+ order.push('func')
103
+ throw new Error('scenario blew up')
104
+ },
105
+ })
106
+ assert.deepEqual(order, ['func', 'after'])
107
+ assert.equal(error?.message, 'scenario blew up')
108
+ assert.equal(run?.status, 'failed')
109
+ })
110
+
111
+ test('before throwing skips the scenario but still runs after', async () => {
112
+ const order: string[] = []
113
+ const { error, run } = await runScenario('hooksBeforeThrows', {
114
+ before: async () => {
115
+ order.push('before')
116
+ throw new Error('setup failed')
117
+ },
118
+ after: async () => {
119
+ order.push('after')
120
+ },
121
+ func: async () => {
122
+ order.push('func')
123
+ return { ok: true }
124
+ },
125
+ })
126
+ assert.deepEqual(order, ['before', 'after'], 'the func must not run')
127
+ assert.match(String(error?.message), /setup failed/)
128
+ assert.equal(run?.status, 'failed')
129
+ })
130
+
131
+ test('after throwing fails a run that would otherwise have passed', async () => {
132
+ const { error, run } = await runScenario('hooksAfterThrows', {
133
+ after: async () => {
134
+ throw new Error('teardown failed')
135
+ },
136
+ func: async () => ({ ok: true }),
137
+ })
138
+ assert.match(String(error?.message), /teardown failed/)
139
+ assert.equal(run?.status, 'failed')
140
+ })
141
+
142
+ test('after throwing never masks the original failure', async () => {
143
+ const { error } = await runScenario('hooksAfterMasks', {
144
+ after: async () => {
145
+ throw new Error('teardown failed')
146
+ },
147
+ func: async () => {
148
+ throw new Error('the real failure')
149
+ },
150
+ })
151
+ assert.equal(
152
+ error?.message,
153
+ 'the real failure',
154
+ 'the scenario failure must be what the caller sees'
155
+ )
156
+ assert.match(
157
+ String((error as any)?.cause?.message),
158
+ /teardown failed/,
159
+ 'the teardown failure must survive as the cause'
160
+ )
161
+ })
162
+
163
+ test('hooks receive the services, the input and the run actors', async () => {
164
+ const seen: any[] = []
165
+ await runScenario(
166
+ 'hooksArgs',
167
+ {
168
+ before: async (services, data, wire) => {
169
+ seen.push({
170
+ marker: services.marker,
171
+ data,
172
+ actor: wire?.actors?.admin?.name,
173
+ })
174
+ },
175
+ after: async (services, data, wire) => {
176
+ seen.push({
177
+ marker: services.marker,
178
+ data,
179
+ actor: wire?.actors?.admin?.name,
180
+ })
181
+ },
182
+ func: async () => ({ ok: true }),
183
+ },
184
+ { input: { seed: 7 } }
185
+ )
186
+ assert.deepEqual(seen, [
187
+ { marker: 'singleton', data: { seed: 7 }, actor: 'admin' },
188
+ { marker: 'singleton', data: { seed: 7 }, actor: 'admin' },
189
+ ])
190
+ })
191
+
192
+ test('hooks are scenario-only — a plain DSL workflow never runs them', async () => {
193
+ const order: string[] = []
194
+ await runScenario(
195
+ 'hooksNotForWorkflows',
196
+ {
197
+ before: async () => {
198
+ order.push('before')
199
+ },
200
+ after: async () => {
201
+ order.push('after')
202
+ },
203
+ func: async () => {
204
+ order.push('func')
205
+ return { ok: true }
206
+ },
207
+ },
208
+ { source: 'dsl' }
209
+ )
210
+ assert.deepEqual(order, ['func'])
211
+ })
212
+ })
@@ -0,0 +1,66 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { pollUntil } from './scenario-poll.js'
5
+
6
+ describe('pollUntil', () => {
7
+ test('answers with the first attempt that produced something', async () => {
8
+ let attempts = 0
9
+ const found = await pollUntil(
10
+ () => {
11
+ attempts++
12
+ return attempts === 3 ? 'ready' : undefined
13
+ },
14
+ { intervalMs: 1 }
15
+ )
16
+
17
+ assert.equal(found, 'ready')
18
+ assert.equal(attempts, 3)
19
+ })
20
+
21
+ test('gives up at the deadline rather than looping forever', async () => {
22
+ const started = Date.now()
23
+ const found = await pollUntil(() => undefined, {
24
+ timeoutMs: 30,
25
+ intervalMs: 5,
26
+ })
27
+
28
+ assert.equal(found, undefined)
29
+ assert.ok(Date.now() - started < 1_000, 'returned promptly')
30
+ })
31
+
32
+ test('always attempts at least once, however short the deadline', async () => {
33
+ let attempts = 0
34
+ const found = await pollUntil(
35
+ () => {
36
+ attempts++
37
+ return 'immediate'
38
+ },
39
+ { timeoutMs: 0 }
40
+ )
41
+
42
+ assert.equal(attempts, 1)
43
+ assert.equal(found, 'immediate')
44
+ })
45
+
46
+ test('awaits an async attempt', async () => {
47
+ const found = await pollUntil(async () => 'awaited', { intervalMs: 1 })
48
+ assert.equal(found, 'awaited')
49
+ })
50
+
51
+ test('treats a falsy-but-defined answer as an answer', async () => {
52
+ // `false` is what a "did it happen?" probe returns, and it is a result —
53
+ // only `undefined` means "not yet".
54
+ let attempts = 0
55
+ const found = await pollUntil(
56
+ () => {
57
+ attempts++
58
+ return false
59
+ },
60
+ { timeoutMs: 50, intervalMs: 1 }
61
+ )
62
+
63
+ assert.equal(found, false)
64
+ assert.equal(attempts, 1)
65
+ })
66
+ })
@@ -0,0 +1,36 @@
1
+ /** How long to keep trying, and how long to wait between tries. */
2
+ export interface PollOptions {
3
+ /** Total time to keep attempting before giving up. Default 15s. */
4
+ timeoutMs?: number
5
+ /** Gap between attempts. Default 250ms. */
6
+ intervalMs?: number
7
+ }
8
+
9
+ /**
10
+ * Attempt something until it answers, or the deadline passes.
11
+ *
12
+ * `undefined` means "not yet" and nothing else — `false`, `0` and `''` are all
13
+ * answers, because a probe asking whether something happened reports `false`
14
+ * when it did not. Answering `undefined` at the deadline rather than throwing
15
+ * leaves the error to the caller, who is the only one who knows what was being
16
+ * waited for.
17
+ *
18
+ * A step that polls its target — a delivery reaching a terminal status, a run
19
+ * finishing — reaches for this rather than writing the deadline loop again.
20
+ */
21
+ export const pollUntil = async <T>(
22
+ attempt: () => Promise<T | undefined> | T | undefined,
23
+ { timeoutMs = 15_000, intervalMs = 250 }: PollOptions = {}
24
+ ): Promise<T | undefined> => {
25
+ const deadline = Date.now() + timeoutMs
26
+ for (;;) {
27
+ const answer = await attempt()
28
+ if (answer !== undefined) {
29
+ return answer
30
+ }
31
+ if (Date.now() >= deadline) {
32
+ return undefined
33
+ }
34
+ await new Promise((done) => setTimeout(done, intervalMs))
35
+ }
36
+ }
@@ -0,0 +1,152 @@
1
+ import { describe, test } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+
4
+ import { composeStepProse, renderStepTemplate } from './scenario-prose.js'
5
+
6
+ describe('renderStepTemplate', () => {
7
+ test('substitutes the step input into its placeholders', () => {
8
+ assert.equal(
9
+ renderStepTemplate('sees {packageName}', {
10
+ packageName: '@pikku/addon-stripe',
11
+ }),
12
+ 'sees @pikku/addon-stripe'
13
+ )
14
+ })
15
+
16
+ test('renders numbers and booleans as words rather than JSON', () => {
17
+ assert.equal(
18
+ renderStepTemplate('sees at least {atLeast} addons, listed {listed}', {
19
+ atLeast: 10,
20
+ listed: true,
21
+ }),
22
+ 'sees at least 10 addons, listed true'
23
+ )
24
+ })
25
+
26
+ test('an absent optional value leaves no gap in the sentence', () => {
27
+ assert.equal(
28
+ renderStepTemplate('sees {packageName} {state}', {
29
+ packageName: '@pikku/addon-stripe',
30
+ }),
31
+ 'sees @pikku/addon-stripe'
32
+ )
33
+ })
34
+
35
+ test('a placeholder repeated in the template is substituted each time', () => {
36
+ assert.equal(
37
+ renderStepTemplate('searches {query} and sees {query}', {
38
+ query: 'stripe',
39
+ }),
40
+ 'searches stripe and sees stripe'
41
+ )
42
+ })
43
+
44
+ test('a non-primitive value is serialised compactly', () => {
45
+ assert.equal(
46
+ renderStepTemplate('sends {payload}', { payload: { a: 1 } }),
47
+ 'sends {"a":1}'
48
+ )
49
+ })
50
+
51
+ test('no recorded input leaves the template legible rather than half-rendered', () => {
52
+ assert.equal(renderStepTemplate('sees {packageName}', undefined), 'sees')
53
+ })
54
+ })
55
+
56
+ describe('composeStepProse', () => {
57
+ test('renders a template against the step input in place of the description', () => {
58
+ assert.equal(
59
+ composeStepProse({
60
+ phase: 'then',
61
+ description: 'sees an addon in the gallery',
62
+ template: 'sees {packageName}',
63
+ input: { packageName: '@pikku/addon-todos' },
64
+ actor: 'admin',
65
+ }),
66
+ 'Then the admin sees @pikku/addon-todos'
67
+ )
68
+ })
69
+
70
+ test('falls back to the description when the step declares no template', () => {
71
+ assert.equal(
72
+ composeStepProse({
73
+ phase: 'then',
74
+ description: 'sees an addon in the gallery',
75
+ input: { packageName: '@pikku/addon-todos' },
76
+ actor: 'admin',
77
+ }),
78
+ 'Then the admin sees an addon in the gallery'
79
+ )
80
+ })
81
+ })
82
+
83
+ describe('composeStepProse basics', () => {
84
+ test('renders the gherkin keyword, the actor and the description', () => {
85
+ assert.equal(
86
+ composeStepProse({
87
+ phase: 'given',
88
+ description: 'buys an apple',
89
+ actor: 'shopper',
90
+ }),
91
+ 'Given the shopper buys an apple'
92
+ )
93
+ assert.equal(
94
+ composeStepProse({
95
+ phase: 'then',
96
+ description: 'sees a receipt',
97
+ actor: 'shopper',
98
+ }),
99
+ 'Then the shopper sees a receipt'
100
+ )
101
+ })
102
+
103
+ test('an actorless step reads as a plain sentence', () => {
104
+ assert.equal(
105
+ composeStepProse({
106
+ phase: 'given',
107
+ description: 'the app data is reset',
108
+ }),
109
+ 'Given the app data is reset'
110
+ )
111
+ })
112
+
113
+ test('the neutral `step` phase adds no keyword', () => {
114
+ assert.equal(
115
+ composeStepProse({
116
+ phase: 'step',
117
+ description: 'refreshes the dashboard',
118
+ actor: 'admin',
119
+ }),
120
+ 'the admin refreshes the dashboard'
121
+ )
122
+ })
123
+
124
+ test('a keyword width lines the sentences up under each other', () => {
125
+ const rendered = (
126
+ [
127
+ { phase: 'given', description: 'buys an apple' },
128
+ { phase: 'when', description: 'checks out' },
129
+ { phase: 'then', description: 'sees a receipt' },
130
+ { phase: 'step', description: 'waits' },
131
+ ] as const
132
+ ).map((step) =>
133
+ composeStepProse({ ...step, actor: 'shopper', keywordWidth: 5 })
134
+ )
135
+
136
+ assert.deepEqual(rendered, [
137
+ 'Given the shopper buys an apple',
138
+ 'When the shopper checks out',
139
+ 'Then the shopper sees a receipt',
140
+ ' the shopper waits',
141
+ ])
142
+ const columns = new Set(rendered.map((line) => line.indexOf('the shopper')))
143
+ assert.equal(columns.size, 1, 'every sentence starts in the same column')
144
+ })
145
+
146
+ test('prose degrades to just the description when nothing else is known', () => {
147
+ assert.equal(
148
+ composeStepProse({ phase: 'step', description: 'does the thing' }),
149
+ 'does the thing'
150
+ )
151
+ })
152
+ })