@pikku/inspector 0.12.47 → 0.12.49
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/CHANGELOG.md +218 -0
- package/LICENSE +106 -0
- package/README.md +28 -2
- package/dist/add/add-feature.d.ts +19 -0
- package/dist/add/add-feature.js +200 -0
- package/dist/add/add-functions.js +38 -9
- package/dist/add/add-workflow.js +22 -29
- package/dist/add/scenario-instrumentation.d.ts +24 -0
- package/dist/add/scenario-instrumentation.js +29 -0
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/inspector.js +39 -2
- package/dist/types.d.ts +43 -0
- package/dist/utils/extract-function-name.js +27 -0
- package/dist/utils/post-process.d.ts +26 -0
- package/dist/utils/post-process.js +92 -0
- package/dist/utils/serialize-inspector-state.d.ts +2 -1
- package/dist/utils/serialize-inspector-state.js +2 -0
- package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
- package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
- package/dist/utils/workflow/dsl/patterns.js +30 -0
- package/dist/utils/workflow/dsl/validation.d.ts +5 -0
- package/dist/utils/workflow/dsl/validation.js +9 -0
- package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
- package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
- package/dist/visit.js +2 -0
- package/package.json +4 -3
- package/src/add/add-feature.test.ts +306 -0
- package/src/add/add-feature.ts +253 -0
- package/src/add/add-functions.ts +47 -9
- package/src/add/add-scenario-step.test.ts +311 -0
- package/src/add/add-workflow-silent-drops.test.ts +98 -0
- package/src/add/add-workflow.ts +25 -37
- package/src/add/scenario-instrumentation.test.ts +89 -0
- package/src/add/scenario-instrumentation.ts +36 -0
- package/src/add/scenario-marker.test.ts +89 -0
- package/src/add/scenario-not-extractable.test.ts +159 -0
- package/src/add/scenario-services.test.ts +103 -0
- package/src/add/scenario-skip.test.ts +83 -0
- package/src/error-codes.ts +3 -0
- package/src/index.ts +6 -0
- package/src/inspector.ts +50 -1
- package/src/tsconfig-paths.test.ts +90 -0
- package/src/types.ts +45 -0
- package/src/utils/extract-function-name.ts +36 -0
- package/src/utils/post-process.test.ts +30 -1
- package/src/utils/post-process.ts +103 -0
- package/src/utils/serialize-inspector-state.ts +8 -1
- package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
- package/src/utils/workflow/dsl/patterns.ts +38 -0
- package/src/utils/workflow/dsl/validation.ts +12 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
- package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
- package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
- package/src/visit.ts +2 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../types.js'
|
|
8
|
+
import type { ScenarioStepMeta, WorkflowStepMeta } from '@pikku/core/workflow'
|
|
9
|
+
|
|
10
|
+
function makeLogger(
|
|
11
|
+
criticals: Array<{ code: string; message: string }>
|
|
12
|
+
): InspectorLogger {
|
|
13
|
+
return {
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
diagnostic: ({ code, message }: any) => {
|
|
19
|
+
criticals.push({ code, message })
|
|
20
|
+
},
|
|
21
|
+
critical: (code: any, message: string) => {
|
|
22
|
+
criticals.push({ code, message })
|
|
23
|
+
},
|
|
24
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
25
|
+
} as InspectorLogger
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const STEPS = [
|
|
29
|
+
"import { pikkuScenarioStep } from '@pikku/core'",
|
|
30
|
+
'export const buysAnApple = pikkuScenarioStep({',
|
|
31
|
+
" name: 'buysAnApple',",
|
|
32
|
+
" description: 'buys an apple',",
|
|
33
|
+
' browser: true,',
|
|
34
|
+
' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
|
|
35
|
+
'})',
|
|
36
|
+
'export const seesAReceipt = pikkuScenarioStep({',
|
|
37
|
+
" name: 'seesAReceipt',",
|
|
38
|
+
" description: 'sees a receipt',",
|
|
39
|
+
' func: async ({ logger }) => ({ ok: true }),',
|
|
40
|
+
'})',
|
|
41
|
+
].join('\n')
|
|
42
|
+
|
|
43
|
+
const scenarioSource = (body: string[], preamble: string[] = []) =>
|
|
44
|
+
[
|
|
45
|
+
"import { pikkuScenario } from '@pikku/core/workflow'",
|
|
46
|
+
'declare const actors: Record<string, any>',
|
|
47
|
+
...preamble,
|
|
48
|
+
'export const shopFlow = pikkuScenario(async (_services, _input, { scenario }: any) => {',
|
|
49
|
+
...body.map((line) => ` ${line}`),
|
|
50
|
+
' return { ok: true }',
|
|
51
|
+
'})',
|
|
52
|
+
].join('\n')
|
|
53
|
+
|
|
54
|
+
async function run(body: string[], preamble: string[] = []) {
|
|
55
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-step-'))
|
|
56
|
+
const stepsFile = join(rootDir, 'shop.steps.ts')
|
|
57
|
+
const scenarioFile = join(rootDir, 'shop.scenario.ts')
|
|
58
|
+
await writeFile(stepsFile, STEPS)
|
|
59
|
+
await writeFile(scenarioFile, scenarioSource(body, preamble))
|
|
60
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
61
|
+
const state = await inspect(
|
|
62
|
+
makeLogger(criticals),
|
|
63
|
+
[stepsFile, scenarioFile],
|
|
64
|
+
{
|
|
65
|
+
rootDir,
|
|
66
|
+
}
|
|
67
|
+
)
|
|
68
|
+
return {
|
|
69
|
+
state,
|
|
70
|
+
criticals,
|
|
71
|
+
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const flatten = (steps: WorkflowStepMeta[]): WorkflowStepMeta[] =>
|
|
76
|
+
steps.flatMap((step) => [
|
|
77
|
+
step,
|
|
78
|
+
...('body' in step && step.body
|
|
79
|
+
? flatten(step.body as WorkflowStepMeta[])
|
|
80
|
+
: []),
|
|
81
|
+
])
|
|
82
|
+
|
|
83
|
+
const scenarioSteps = (state: any): ScenarioStepMeta[] =>
|
|
84
|
+
flatten(state.workflows.meta.shopFlow?.steps ?? []).filter(
|
|
85
|
+
(step): step is ScenarioStepMeta => step.type === 'scenarioStep'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
describe('pikkuScenarioStep', () => {
|
|
89
|
+
test('a step is registered under its declared name, not its export name', async () => {
|
|
90
|
+
const { state, cleanup } = await run([
|
|
91
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
92
|
+
])
|
|
93
|
+
try {
|
|
94
|
+
assert.ok(
|
|
95
|
+
state.functions.meta['buysAnApple'],
|
|
96
|
+
`expected a function registered as 'buysAnApple', got: ${Object.keys(state.functions.meta).join(', ')}`
|
|
97
|
+
)
|
|
98
|
+
} finally {
|
|
99
|
+
await cleanup()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('a step is never registered as an RPC — a browser step must not be network-callable', async () => {
|
|
104
|
+
const { state, cleanup } = await run([
|
|
105
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
106
|
+
])
|
|
107
|
+
try {
|
|
108
|
+
assert.equal(
|
|
109
|
+
state.rpc.internalMeta['buysAnApple'],
|
|
110
|
+
undefined,
|
|
111
|
+
'scenario steps must not be RPC-callable'
|
|
112
|
+
)
|
|
113
|
+
assert.equal(
|
|
114
|
+
state.rpc.exposedMeta['buysAnApple'],
|
|
115
|
+
undefined,
|
|
116
|
+
'scenario steps must not be exposed over RPC'
|
|
117
|
+
)
|
|
118
|
+
} finally {
|
|
119
|
+
await cleanup()
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
test('given/when/then record phase, target and actor', async () => {
|
|
124
|
+
const { state, cleanup } = await run([
|
|
125
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
126
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
127
|
+
])
|
|
128
|
+
try {
|
|
129
|
+
const steps = scenarioSteps(state)
|
|
130
|
+
assert.equal(
|
|
131
|
+
steps.length,
|
|
132
|
+
2,
|
|
133
|
+
`expected 2 scenario steps, got ${steps.length}`
|
|
134
|
+
)
|
|
135
|
+
assert.equal(steps[0]!.phase, 'given')
|
|
136
|
+
assert.equal(steps[0]!.stepFunc, 'buysAnApple')
|
|
137
|
+
assert.equal(steps[0]!.actor, 'shopper')
|
|
138
|
+
assert.equal(steps[1]!.phase, 'then')
|
|
139
|
+
assert.equal(steps[1]!.stepFunc, 'seesAReceipt')
|
|
140
|
+
} finally {
|
|
141
|
+
await cleanup()
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('the step target is bundled, so the step function is wired', async () => {
|
|
146
|
+
const { state, cleanup } = await run([
|
|
147
|
+
"await scenario.step('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
148
|
+
])
|
|
149
|
+
try {
|
|
150
|
+
assert.ok(
|
|
151
|
+
state.rpc.invokedFunctions.has('buysAnApple'),
|
|
152
|
+
`expected 'buysAnApple' to be bundled, got: ${[...state.rpc.invokedFunctions].join(', ')}`
|
|
153
|
+
)
|
|
154
|
+
} finally {
|
|
155
|
+
await cleanup()
|
|
156
|
+
}
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('a step inside a for..of loop is extracted, not silently dropped', async () => {
|
|
160
|
+
const { state, cleanup } = await run([
|
|
161
|
+
'const quantities = [1, 2]',
|
|
162
|
+
'for (const qty of quantities) {',
|
|
163
|
+
" await scenario.when('buys an apple', 'buysAnApple', { qty }, { actor: actors.shopper })",
|
|
164
|
+
'}',
|
|
165
|
+
])
|
|
166
|
+
try {
|
|
167
|
+
const steps = scenarioSteps(state)
|
|
168
|
+
assert.equal(
|
|
169
|
+
steps.length,
|
|
170
|
+
1,
|
|
171
|
+
`expected the looped step to appear in the fanout body, got ${steps.length} scenario steps`
|
|
172
|
+
)
|
|
173
|
+
assert.equal(steps[0]!.stepFunc, 'buysAnApple')
|
|
174
|
+
assert.ok(
|
|
175
|
+
state.rpc.invokedFunctions.has('buysAnApple'),
|
|
176
|
+
'a looped step must still be bundled'
|
|
177
|
+
)
|
|
178
|
+
} finally {
|
|
179
|
+
await cleanup()
|
|
180
|
+
}
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
test('a non-literal step target is a PKU678 critical', async () => {
|
|
184
|
+
const { criticals, cleanup } = await run([
|
|
185
|
+
'const target = Math.random() > 0.5 ? "buysAnApple" : "seesAReceipt"',
|
|
186
|
+
"await scenario.step('buys something', target as any, {}, { actor: actors.shopper })",
|
|
187
|
+
])
|
|
188
|
+
try {
|
|
189
|
+
assert.ok(
|
|
190
|
+
criticals.find((c) => c.code === 'PKU678'),
|
|
191
|
+
`expected a PKU678 critical for a dynamic step target, got: ${JSON.stringify(criticals)}`
|
|
192
|
+
)
|
|
193
|
+
} finally {
|
|
194
|
+
await cleanup()
|
|
195
|
+
}
|
|
196
|
+
})
|
|
197
|
+
|
|
198
|
+
test('a browser step called without an actor is a PKU677 critical', async () => {
|
|
199
|
+
const { criticals, cleanup } = await run([
|
|
200
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 })",
|
|
201
|
+
])
|
|
202
|
+
try {
|
|
203
|
+
assert.ok(
|
|
204
|
+
criticals.find((c) => c.code === 'PKU677'),
|
|
205
|
+
`expected a PKU677 critical for a browser step without an actor, got: ${JSON.stringify(criticals)}`
|
|
206
|
+
)
|
|
207
|
+
} finally {
|
|
208
|
+
await cleanup()
|
|
209
|
+
}
|
|
210
|
+
})
|
|
211
|
+
|
|
212
|
+
test('a non-browser step without an actor is allowed', async () => {
|
|
213
|
+
const { criticals, cleanup } = await run([
|
|
214
|
+
"await scenario.given('sees a receipt', 'seesAReceipt', {})",
|
|
215
|
+
])
|
|
216
|
+
try {
|
|
217
|
+
assert.equal(
|
|
218
|
+
criticals.filter((c) => c.code === 'PKU677').length,
|
|
219
|
+
0,
|
|
220
|
+
`setup steps need no actor, got: ${JSON.stringify(criticals)}`
|
|
221
|
+
)
|
|
222
|
+
} finally {
|
|
223
|
+
await cleanup()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('a step is marked as a step RPC in its function meta', async () => {
|
|
228
|
+
const { state, cleanup } = await run([
|
|
229
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
230
|
+
])
|
|
231
|
+
try {
|
|
232
|
+
// The fourth RPC kind, alongside public/private/remote: a name that is
|
|
233
|
+
// dispatched inside a run and refused everywhere else.
|
|
234
|
+
assert.equal(state.functions.meta['buysAnApple']?.scenarioStep, true)
|
|
235
|
+
assert.equal(state.functions.meta['seesAReceipt']?.scenarioStep, true)
|
|
236
|
+
} finally {
|
|
237
|
+
await cleanup()
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
test('an ordinary function is not a step RPC', async () => {
|
|
242
|
+
const { state, cleanup } = await run([
|
|
243
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
244
|
+
])
|
|
245
|
+
try {
|
|
246
|
+
assert.equal(state.functions.meta['shopFlow']?.scenarioStep, undefined)
|
|
247
|
+
} finally {
|
|
248
|
+
await cleanup()
|
|
249
|
+
}
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
test('a closure in the scenario body does not have its statements whitelisted', async () => {
|
|
253
|
+
const { state, cleanup } = await run([
|
|
254
|
+
'const safely = async (fn: any) => { try { return await fn() } catch { return null } }',
|
|
255
|
+
'void safely',
|
|
256
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
257
|
+
])
|
|
258
|
+
try {
|
|
259
|
+
const steps = scenarioSteps(state)
|
|
260
|
+
assert.equal(
|
|
261
|
+
steps.length,
|
|
262
|
+
1,
|
|
263
|
+
`a try/catch inside a closure must not drop the scenario's steps, got ${steps.length}`
|
|
264
|
+
)
|
|
265
|
+
assert.equal(steps[0]!.stepFunc, 'buysAnApple')
|
|
266
|
+
} finally {
|
|
267
|
+
await cleanup()
|
|
268
|
+
}
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
test('destructuring a step result is reported, not silently dropped', async () => {
|
|
272
|
+
const { state, criticals, cleanup } = await run([
|
|
273
|
+
"const { ok } = await scenario.given('sees a receipt', 'seesAReceipt', {})",
|
|
274
|
+
'void ok',
|
|
275
|
+
])
|
|
276
|
+
try {
|
|
277
|
+
const steps = scenarioSteps(state)
|
|
278
|
+
assert.equal(
|
|
279
|
+
steps.length,
|
|
280
|
+
0,
|
|
281
|
+
'the DSL does not model a destructured step result'
|
|
282
|
+
)
|
|
283
|
+
assert.ok(
|
|
284
|
+
criticals.find((c) => c.code === 'PKU679'),
|
|
285
|
+
`a step the DSL cannot model must be loud, got: ${JSON.stringify(criticals)}`
|
|
286
|
+
)
|
|
287
|
+
} finally {
|
|
288
|
+
await cleanup()
|
|
289
|
+
}
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
test('a module-level constant used as step input is inlined, not read off the trigger', async () => {
|
|
293
|
+
const { state, cleanup } = await run(
|
|
294
|
+
[
|
|
295
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: QTY }, { actor: actors.shopper })",
|
|
296
|
+
],
|
|
297
|
+
['const QTY = 3']
|
|
298
|
+
)
|
|
299
|
+
try {
|
|
300
|
+
const steps = scenarioSteps(state)
|
|
301
|
+
assert.equal(steps.length, 1)
|
|
302
|
+
assert.deepEqual(
|
|
303
|
+
steps[0]!.inputs?.qty,
|
|
304
|
+
{ from: 'literal', value: 3 },
|
|
305
|
+
`a constant declared outside the scenario is not a trigger field, got: ${JSON.stringify(steps[0]!.inputs)}`
|
|
306
|
+
)
|
|
307
|
+
} finally {
|
|
308
|
+
await cleanup()
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
})
|
|
@@ -146,6 +146,104 @@ describe('DSL extraction — constructs that must not be silently dropped', () =
|
|
|
146
146
|
)
|
|
147
147
|
})
|
|
148
148
|
|
|
149
|
+
test('a counting for-of (Array(n).keys()) hard-errors instead of silently dropping its step', async () => {
|
|
150
|
+
const { invoked, steps, diags } = await inspectWorkflow(
|
|
151
|
+
[
|
|
152
|
+
' for (const i of [...Array(3).keys()]) {',
|
|
153
|
+
" await workflow.do(`poll #${i}`, 'rpcA', {})",
|
|
154
|
+
' }',
|
|
155
|
+
' return { ok: true }',
|
|
156
|
+
].join('\n')
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
// The DSL extractor can't model a computed/counting iterable, so it must
|
|
160
|
+
// FAIL LOUDLY (INVALID_DSL_WORKFLOW), not swallow the loop and every
|
|
161
|
+
// workflow.do inside it — the silent drop is exactly what stranded
|
|
162
|
+
// checkSandboxHostCapacity out of invokedFunctions and bricked prod.
|
|
163
|
+
assert.ok(
|
|
164
|
+
diags.length > 0,
|
|
165
|
+
'a counting for-of must raise a diagnostic, not vanish silently'
|
|
166
|
+
)
|
|
167
|
+
assert.ok(
|
|
168
|
+
diags.some((d) => /for-of/i.test(d)),
|
|
169
|
+
`the diagnostic must name the offending for-of, got: ${JSON.stringify(diags)}`
|
|
170
|
+
)
|
|
171
|
+
// And it must NOT have quietly produced a fanout with zero body steps.
|
|
172
|
+
assert.ok(
|
|
173
|
+
!invoked.includes('rpcA') || diags.length > 0,
|
|
174
|
+
'if rpcA was dropped the user must be told'
|
|
175
|
+
)
|
|
176
|
+
const fanout = steps.find((s) => s.type === 'fanout')
|
|
177
|
+
assert.ok(
|
|
178
|
+
!fanout,
|
|
179
|
+
'a counting loop must not serialize as a fanout — it is not a data-array fanout'
|
|
180
|
+
)
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The diagnostic used to blame the iterable in every case, including this one
|
|
185
|
+
* where the iterable is a perfectly good data array. That sent readers looking
|
|
186
|
+
* at `data.tagsToRemove` when the real obstacle was the `if` in the body, so
|
|
187
|
+
* the message has to distinguish the two causes.
|
|
188
|
+
*/
|
|
189
|
+
test('a branch inside a fanout body hard-errors, and the diagnostic blames the branch not the iterable', async () => {
|
|
190
|
+
const { steps, diags } = await inspectWorkflow(
|
|
191
|
+
[
|
|
192
|
+
' for (const tag of data.tags) {',
|
|
193
|
+
' if (tag.length > 2) {',
|
|
194
|
+
" await workflow.do('add', 'rpcA', { tag })",
|
|
195
|
+
' }',
|
|
196
|
+
' }',
|
|
197
|
+
' return { ok: true }',
|
|
198
|
+
].join('\n')
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
assert.ok(
|
|
202
|
+
diags.length > 0,
|
|
203
|
+
'a branch inside a fanout body must raise a diagnostic, not vanish silently'
|
|
204
|
+
)
|
|
205
|
+
assert.ok(
|
|
206
|
+
diags.some((d) => /if\/switch/.test(d)),
|
|
207
|
+
`the diagnostic must name the branch as the cause, got: ${JSON.stringify(diags)}`
|
|
208
|
+
)
|
|
209
|
+
assert.ok(
|
|
210
|
+
!diags.some((d) => /iterable must be a data array/.test(d)),
|
|
211
|
+
`data.tags IS a data array — the diagnostic must not blame it, got: ${JSON.stringify(diags)}`
|
|
212
|
+
)
|
|
213
|
+
const fanout = steps.find((s) => s.type === 'fanout')
|
|
214
|
+
assert.ok(!fanout, 'a fanout whose body was dropped must not serialize')
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The hazard the hard error exists for is a *dropped step*. A loop that only
|
|
219
|
+
* massages locals has no step to drop, so failing the build on it is a false
|
|
220
|
+
* positive — and it fired on real workflows that were doing nothing wrong.
|
|
221
|
+
*/
|
|
222
|
+
test('a for-of that calls no workflow step is not an error — there is nothing to drop', async () => {
|
|
223
|
+
const { steps, diags } = await inspectWorkflow(
|
|
224
|
+
[
|
|
225
|
+
" const first = await workflow.do('get', 'rpcA', {})",
|
|
226
|
+
' const merged: Record<string, string> = {}',
|
|
227
|
+
' for (const dup of data.duplicates) {',
|
|
228
|
+
' if (dup.phone && !first.phone) {',
|
|
229
|
+
' merged.phone = dup.phone',
|
|
230
|
+
' }',
|
|
231
|
+
' }',
|
|
232
|
+
' return { ok: true }',
|
|
233
|
+
].join('\n')
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
assert.deepEqual(
|
|
237
|
+
diags,
|
|
238
|
+
[],
|
|
239
|
+
`a pure-computation loop must not fail the build, got: ${JSON.stringify(diags)}`
|
|
240
|
+
)
|
|
241
|
+
assert.ok(
|
|
242
|
+
steps.some((s) => s.type === 'rpc'),
|
|
243
|
+
'the surrounding steps must still be extracted'
|
|
244
|
+
)
|
|
245
|
+
})
|
|
246
|
+
|
|
149
247
|
test('a brace-less for-of body is still extracted as a sequential fanout', async () => {
|
|
150
248
|
const { invoked, steps } = await inspectWorkflow(
|
|
151
249
|
[
|
package/src/add/add-workflow.ts
CHANGED
|
@@ -97,6 +97,10 @@ export function collectInvokedRPCs(
|
|
|
97
97
|
if (step.options?.onError) {
|
|
98
98
|
rpcs.add(step.options.onError)
|
|
99
99
|
}
|
|
100
|
+
} else if (step.type === 'scenarioStep' && step.stepFunc) {
|
|
101
|
+
// Steps are bundled like RPCs — the runner calls them through
|
|
102
|
+
// runPikkuFunc — but they are deliberately never RPC-registered.
|
|
103
|
+
rpcs.add(step.stepFunc)
|
|
100
104
|
} else if (step.type === 'branch') {
|
|
101
105
|
for (const branch of step.branches) {
|
|
102
106
|
collectInvokedRPCs(branch.steps, rpcs)
|
|
@@ -108,9 +112,9 @@ export function collectInvokedRPCs(
|
|
|
108
112
|
}
|
|
109
113
|
if (step.defaultSteps) collectInvokedRPCs(step.defaultSteps, rpcs)
|
|
110
114
|
} else if (step.type === 'fanout' && step.body) {
|
|
111
|
-
collectInvokedRPCs(step.body, rpcs)
|
|
115
|
+
collectInvokedRPCs(step.body as WorkflowStepMeta[], rpcs)
|
|
112
116
|
} else if (step.type === 'parallel' && step.children) {
|
|
113
|
-
collectInvokedRPCs(step.children, rpcs)
|
|
117
|
+
collectInvokedRPCs(step.children as WorkflowStepMeta[], rpcs)
|
|
114
118
|
}
|
|
115
119
|
}
|
|
116
120
|
}
|
|
@@ -282,6 +286,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
282
286
|
let description: string | undefined
|
|
283
287
|
let errors: string[] | undefined
|
|
284
288
|
let expose: boolean | undefined
|
|
289
|
+
let skip: string | undefined
|
|
285
290
|
|
|
286
291
|
if (ts.isObjectLiteralExpression(firstArg)) {
|
|
287
292
|
const metadata = getCommonWireMetaData(
|
|
@@ -299,6 +304,7 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
299
304
|
errors = metadata.errors
|
|
300
305
|
|
|
301
306
|
expose = getPropertyValue(firstArg, 'expose') as boolean | undefined
|
|
307
|
+
skip = (getPropertyValue(firstArg, 'skip') as string | null) ?? undefined
|
|
302
308
|
}
|
|
303
309
|
|
|
304
310
|
// Validate that we got a valid function
|
|
@@ -391,6 +397,15 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
391
397
|
`Workflow '${workflowName}' does not conform to DSL workflow rules:\n${result.reason || 'Unknown error'}`
|
|
392
398
|
)
|
|
393
399
|
return
|
|
400
|
+
} else if (wrapperType === 'scenario') {
|
|
401
|
+
// The fallback below understands do/sleep but not step/given/when/then,
|
|
402
|
+
// so a scenario that lands here registers with zero steps. Silence would
|
|
403
|
+
// leave the whole scenario passing vacuously.
|
|
404
|
+
logger.critical(
|
|
405
|
+
ErrorCode.SCENARIO_NOT_EXTRACTABLE,
|
|
406
|
+
`Scenario '${workflowName}' could not be extracted, so none of its steps were recorded and it would run as an empty scenario:\n${result.reason || 'Unknown error'}`
|
|
407
|
+
)
|
|
408
|
+
return
|
|
394
409
|
} else {
|
|
395
410
|
// For pikkuWorkflowComplexFunc, fall back to basic extraction
|
|
396
411
|
logger.debug(
|
|
@@ -432,43 +447,16 @@ export const addWorkflow: AddWiring = (logger, node, checker, state) => {
|
|
|
432
447
|
errors,
|
|
433
448
|
tags,
|
|
434
449
|
expose,
|
|
450
|
+
skip,
|
|
435
451
|
scenario: wrapperType === 'scenario' ? true : undefined,
|
|
436
452
|
actors: actorNames.length > 0 ? actorNames : undefined,
|
|
437
453
|
}
|
|
438
454
|
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
(svc) => svc !== 'logger' && svc !== 'config'
|
|
447
|
-
)
|
|
448
|
-
if (disallowed.length > 0) {
|
|
449
|
-
logger.critical(
|
|
450
|
-
ErrorCode.SCENARIO_HAS_SERVICES,
|
|
451
|
-
`Scenario '${workflowName}' destructures services: ${disallowed.join(', ')}. ` +
|
|
452
|
-
`Scenarios may only use 'logger'/'config' — drive everything else through ` +
|
|
453
|
-
`actor steps (workflow.do(step, rpc, data, { actor: actors.x })) so the flow ` +
|
|
454
|
-
`runs against the target environment.`
|
|
455
|
-
)
|
|
456
|
-
return
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
// Workflow functions require platform services that aren't visible
|
|
461
|
-
// through parameter destructuring (they're accessed via workflow.do/sleep)
|
|
462
|
-
if (funcMeta?.services) {
|
|
463
|
-
for (const svc of [
|
|
464
|
-
'workflowService',
|
|
465
|
-
'workflowRunService',
|
|
466
|
-
'schedulerService',
|
|
467
|
-
'queueService',
|
|
468
|
-
]) {
|
|
469
|
-
if (!funcMeta.services.services.includes(svc)) {
|
|
470
|
-
funcMeta.services.services.push(svc)
|
|
471
|
-
}
|
|
472
|
-
}
|
|
473
|
-
}
|
|
455
|
+
// The scenario "logger/config only" rule (PKU673) and the implicit
|
|
456
|
+
// workflow platform-service injection both used to live here, reading
|
|
457
|
+
// state.functions.meta[pikkuFuncId]. addWorkflow runs in the visitSetup
|
|
458
|
+
// pass, which completes before addFunctions populates that meta, so both
|
|
459
|
+
// were dead. The rule now lives in validateScenarioServices (post-process),
|
|
460
|
+
// and aggregateRequiredServices already adds the platform services globally
|
|
461
|
+
// whenever any workflow exists.
|
|
474
462
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { inspect } from '../inspector.js'
|
|
7
|
+
import type { InspectorLogger } from '../types.js'
|
|
8
|
+
import {
|
|
9
|
+
SCENARIO_INSTRUMENTATION_FUNCTIONS,
|
|
10
|
+
isScenarioInstrumentationFunction,
|
|
11
|
+
} from './scenario-instrumentation.js'
|
|
12
|
+
|
|
13
|
+
const silentLogger: InspectorLogger = {
|
|
14
|
+
debug: () => {},
|
|
15
|
+
info: () => {},
|
|
16
|
+
warn: () => {},
|
|
17
|
+
error: () => {},
|
|
18
|
+
diagnostic: () => {},
|
|
19
|
+
critical: () => {},
|
|
20
|
+
hasCriticalErrors: () => false,
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('scenario instrumentation', () => {
|
|
24
|
+
test('recognises the instrumentation names, including versioned ids', () => {
|
|
25
|
+
for (const name of SCENARIO_INSTRUMENTATION_FUNCTIONS) {
|
|
26
|
+
assert.ok(isScenarioInstrumentationFunction(name), name)
|
|
27
|
+
assert.ok(isScenarioInstrumentationFunction(`${name}@v2`), `${name}@v2`)
|
|
28
|
+
}
|
|
29
|
+
assert.ok(!isScenarioInstrumentationFunction('createUser'))
|
|
30
|
+
assert.ok(!isScenarioInstrumentationFunction(undefined))
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
test('a project still carrying the retired scaffold registers none of it', async () => {
|
|
34
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-instr-'))
|
|
35
|
+
const file = join(rootDir, 'scenarios.gen.ts')
|
|
36
|
+
|
|
37
|
+
// What `scaffold.scenarios` used to write into the project. Whatever a
|
|
38
|
+
// project has checked in, none of it may reach the app's meta or RPC surface:
|
|
39
|
+
// `pikku dev` registers the real implementations itself.
|
|
40
|
+
await writeFile(
|
|
41
|
+
file,
|
|
42
|
+
[
|
|
43
|
+
"import { pikkuFunc } from '@pikku/core'",
|
|
44
|
+
'export const pikkuScenarioTakeLiveCoverage = pikkuFunc({',
|
|
45
|
+
" tags: ['pikku'],",
|
|
46
|
+
' expose: true,',
|
|
47
|
+
' auth: true,',
|
|
48
|
+
' func: async () => null,',
|
|
49
|
+
'})',
|
|
50
|
+
'export const pikkuScenarioResetStubs = pikkuFunc({',
|
|
51
|
+
" tags: ['pikku'],",
|
|
52
|
+
' expose: true,',
|
|
53
|
+
' auth: true,',
|
|
54
|
+
' func: async () => ({ enabled: true }),',
|
|
55
|
+
'})',
|
|
56
|
+
'export const createUser = pikkuFunc({',
|
|
57
|
+
' expose: true,',
|
|
58
|
+
' func: async () => ({ ok: true }),',
|
|
59
|
+
'})',
|
|
60
|
+
].join('\n')
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
const state = await inspect(silentLogger, [file], { rootDir })
|
|
65
|
+
|
|
66
|
+
for (const name of SCENARIO_INSTRUMENTATION_FUNCTIONS) {
|
|
67
|
+
assert.ok(
|
|
68
|
+
!(name in state.functions.meta),
|
|
69
|
+
`${name} must not be in the app function meta`
|
|
70
|
+
)
|
|
71
|
+
assert.ok(
|
|
72
|
+
!(name in state.rpc.internalMeta),
|
|
73
|
+
`${name} must not be in the internal RPC meta`
|
|
74
|
+
)
|
|
75
|
+
assert.ok(
|
|
76
|
+
!(name in state.rpc.exposedMeta),
|
|
77
|
+
`${name} must not be exposed`
|
|
78
|
+
)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The application function in the same file is untouched — the drop is by
|
|
82
|
+
// name, not by file.
|
|
83
|
+
assert.ok(state.functions.meta['createUser'])
|
|
84
|
+
assert.strictEqual(state.rpc.exposedMeta['createUser'], 'createUser')
|
|
85
|
+
} finally {
|
|
86
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The instrumentation RPCs `pikku scenario run` calls on the server it is
|
|
3
|
+
* testing: snapshot/reset live coverage, and reset/read recorded stub calls.
|
|
4
|
+
*
|
|
5
|
+
* They are pikku's own, not the application's. They used to be scaffolded into
|
|
6
|
+
* the project as ordinary `pikkuFunc`s, which made them indistinguishable from
|
|
7
|
+
* application code — so they were registered in the app bootstrap, listed in the
|
|
8
|
+
* app's function and RPC meta, and shipped, `expose: true`, in every deployed
|
|
9
|
+
* bundle. A deployment has no use for them: coverage and stub inspection are
|
|
10
|
+
* things you do to a development server.
|
|
11
|
+
*
|
|
12
|
+
* The inspector therefore ignores these names wherever it finds them, and
|
|
13
|
+
* `pikku dev` registers the implementations itself. Ignoring by name (rather
|
|
14
|
+
* than deleting the scaffold and requiring every project to regenerate) is what
|
|
15
|
+
* keeps an existing project's checked-in `scenarios.gen.ts` from deploying.
|
|
16
|
+
*/
|
|
17
|
+
export const SCENARIO_INSTRUMENTATION_FUNCTIONS = [
|
|
18
|
+
'pikkuScenarioTakeLiveCoverage',
|
|
19
|
+
'pikkuScenarioResetLiveCoverage',
|
|
20
|
+
'pikkuScenarioResetStubs',
|
|
21
|
+
'pikkuScenarioGetStubCalls',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
export type ScenarioInstrumentationFunction =
|
|
25
|
+
(typeof SCENARIO_INSTRUMENTATION_FUNCTIONS)[number]
|
|
26
|
+
|
|
27
|
+
const names = new Set<string>(SCENARIO_INSTRUMENTATION_FUNCTIONS)
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* True for a scenario instrumentation function, by name. Versioned ids
|
|
31
|
+
* (`name@v2`) are matched on their base so a project that bumps one is still
|
|
32
|
+
* covered.
|
|
33
|
+
*/
|
|
34
|
+
export const isScenarioInstrumentationFunction = (
|
|
35
|
+
name: string | undefined
|
|
36
|
+
): boolean => (name ? names.has(name.split('@')[0]!) : false)
|