@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.
Files changed (58) hide show
  1. package/CHANGELOG.md +218 -0
  2. package/LICENSE +106 -0
  3. package/README.md +28 -2
  4. package/dist/add/add-feature.d.ts +19 -0
  5. package/dist/add/add-feature.js +200 -0
  6. package/dist/add/add-functions.js +38 -9
  7. package/dist/add/add-workflow.js +22 -29
  8. package/dist/add/scenario-instrumentation.d.ts +24 -0
  9. package/dist/add/scenario-instrumentation.js +29 -0
  10. package/dist/error-codes.d.ts +3 -0
  11. package/dist/error-codes.js +3 -0
  12. package/dist/index.d.ts +3 -0
  13. package/dist/index.js +1 -0
  14. package/dist/inspector.js +39 -2
  15. package/dist/types.d.ts +43 -0
  16. package/dist/utils/extract-function-name.js +27 -0
  17. package/dist/utils/post-process.d.ts +26 -0
  18. package/dist/utils/post-process.js +92 -0
  19. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  20. package/dist/utils/serialize-inspector-state.js +2 -0
  21. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  22. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  23. package/dist/utils/workflow/dsl/patterns.js +30 -0
  24. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  25. package/dist/utils/workflow/dsl/validation.js +9 -0
  26. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  27. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  28. package/dist/visit.js +2 -0
  29. package/package.json +4 -3
  30. package/src/add/add-feature.test.ts +306 -0
  31. package/src/add/add-feature.ts +253 -0
  32. package/src/add/add-functions.ts +47 -9
  33. package/src/add/add-scenario-step.test.ts +311 -0
  34. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  35. package/src/add/add-workflow.ts +25 -37
  36. package/src/add/scenario-instrumentation.test.ts +89 -0
  37. package/src/add/scenario-instrumentation.ts +36 -0
  38. package/src/add/scenario-marker.test.ts +89 -0
  39. package/src/add/scenario-not-extractable.test.ts +159 -0
  40. package/src/add/scenario-services.test.ts +103 -0
  41. package/src/add/scenario-skip.test.ts +83 -0
  42. package/src/error-codes.ts +3 -0
  43. package/src/index.ts +6 -0
  44. package/src/inspector.ts +50 -1
  45. package/src/tsconfig-paths.test.ts +90 -0
  46. package/src/types.ts +45 -0
  47. package/src/utils/extract-function-name.ts +36 -0
  48. package/src/utils/post-process.test.ts +30 -1
  49. package/src/utils/post-process.ts +103 -0
  50. package/src/utils/serialize-inspector-state.ts +8 -1
  51. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  52. package/src/utils/workflow/dsl/patterns.ts +38 -0
  53. package/src/utils/workflow/dsl/validation.ts +12 -0
  54. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  55. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  56. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  57. package/src/visit.ts +2 -0
  58. 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
  [
@@ -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
- // Scenarios are pure stories of remote RPCs (same rule as client-side CLI
440
- // renderers): the func may only destructure logger/config — everything else
441
- // must go through actor steps so the flow runs against the TARGET
442
- // environment, never local services.
443
- const funcMeta = state.functions.meta[pikkuFuncId]
444
- if (wrapperType === 'scenario' && funcMeta?.services) {
445
- const disallowed = funcMeta.services.services.filter(
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)