@pikku/inspector 0.12.47 → 0.12.48

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 (50) hide show
  1. package/CHANGELOG.md +185 -0
  2. package/README.md +28 -2
  3. package/dist/add/add-feature.d.ts +19 -0
  4. package/dist/add/add-feature.js +200 -0
  5. package/dist/add/add-functions.js +24 -9
  6. package/dist/add/add-workflow.js +22 -29
  7. package/dist/error-codes.d.ts +3 -0
  8. package/dist/error-codes.js +3 -0
  9. package/dist/index.d.ts +1 -0
  10. package/dist/inspector.js +39 -2
  11. package/dist/types.d.ts +43 -0
  12. package/dist/utils/extract-function-name.js +27 -0
  13. package/dist/utils/post-process.d.ts +26 -0
  14. package/dist/utils/post-process.js +85 -0
  15. package/dist/utils/serialize-inspector-state.d.ts +2 -1
  16. package/dist/utils/serialize-inspector-state.js +2 -0
  17. package/dist/utils/workflow/dsl/extract-dsl-workflow.js +203 -19
  18. package/dist/utils/workflow/dsl/patterns.d.ts +21 -0
  19. package/dist/utils/workflow/dsl/patterns.js +30 -0
  20. package/dist/utils/workflow/dsl/validation.d.ts +5 -0
  21. package/dist/utils/workflow/dsl/validation.js +9 -0
  22. package/dist/utils/workflow/graph/convert-dsl-to-graph.js +36 -0
  23. package/dist/utils/workflow/graph/workflow-graph.types.d.ts +5 -1
  24. package/dist/visit.js +2 -0
  25. package/package.json +4 -3
  26. package/src/add/add-feature.test.ts +306 -0
  27. package/src/add/add-feature.ts +253 -0
  28. package/src/add/add-functions.ts +29 -9
  29. package/src/add/add-scenario-step.test.ts +311 -0
  30. package/src/add/add-workflow-silent-drops.test.ts +98 -0
  31. package/src/add/add-workflow.ts +25 -37
  32. package/src/add/scenario-not-extractable.test.ts +159 -0
  33. package/src/add/scenario-services.test.ts +103 -0
  34. package/src/add/scenario-skip.test.ts +83 -0
  35. package/src/error-codes.ts +3 -0
  36. package/src/index.ts +1 -0
  37. package/src/inspector.ts +50 -1
  38. package/src/tsconfig-paths.test.ts +90 -0
  39. package/src/types.ts +45 -0
  40. package/src/utils/extract-function-name.ts +36 -0
  41. package/src/utils/post-process.ts +97 -0
  42. package/src/utils/serialize-inspector-state.ts +8 -1
  43. package/src/utils/workflow/dsl/extract-dsl-workflow.ts +236 -22
  44. package/src/utils/workflow/dsl/patterns.ts +38 -0
  45. package/src/utils/workflow/dsl/validation.ts +12 -0
  46. package/src/utils/workflow/graph/convert-dsl-to-graph-skip.test.ts +35 -0
  47. package/src/utils/workflow/graph/convert-dsl-to-graph.ts +36 -0
  48. package/src/utils/workflow/graph/workflow-graph.types.ts +5 -0
  49. package/src/visit.ts +2 -0
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,306 @@
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
+
9
+ function makeLogger(
10
+ criticals: Array<{ code: string; message: string }>
11
+ ): InspectorLogger {
12
+ return {
13
+ debug: () => {},
14
+ info: () => {},
15
+ warn: () => {},
16
+ error: () => {},
17
+ diagnostic: ({ code, message }: any) => {
18
+ criticals.push({ code, message })
19
+ },
20
+ critical: (code: any, message: string) => {
21
+ criticals.push({ code, message })
22
+ },
23
+ hasCriticalErrors: () => criticals.length > 0,
24
+ } as InspectorLogger
25
+ }
26
+
27
+ const SCENARIOS = [
28
+ "import { pikkuScenario } from '@pikku/core/workflow'",
29
+ 'export const lazyLoadScenario = pikkuScenario({',
30
+ " title: 'A credential is loaded on first use',",
31
+ " tags: ['credential'],",
32
+ ' func: async () => ({ ok: true }),',
33
+ '})',
34
+ 'export const roundTripScenario = pikkuScenario({',
35
+ " title: 'A credential round-trips',",
36
+ ' func: async (_services, _data: { name: string }) => ({ ok: true }),',
37
+ '})',
38
+ ].join('\n')
39
+
40
+ async function run(featureSource: string) {
41
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-feature-'))
42
+ const scenarioFile = join(rootDir, 'credential.scenario.ts')
43
+ const featureFile = join(rootDir, 'credential.feature.ts')
44
+ await writeFile(scenarioFile, SCENARIOS)
45
+ await writeFile(featureFile, featureSource)
46
+ const criticals: Array<{ code: string; message: string }> = []
47
+ const state = await inspect(
48
+ makeLogger(criticals),
49
+ [scenarioFile, featureFile],
50
+ { rootDir }
51
+ )
52
+ return {
53
+ state,
54
+ criticals,
55
+ featureFile,
56
+ cleanup: () => rm(rootDir, { recursive: true, force: true }),
57
+ }
58
+ }
59
+
60
+ describe('addFeature', () => {
61
+ test('records where a feature is exported from', async () => {
62
+ const { state, criticals, featureFile, cleanup } = await run(
63
+ [
64
+ "import { pikkuFeature } from '@pikku/core/workflow'",
65
+ "import { lazyLoadScenario } from './credential.scenario.js'",
66
+ 'export const credentialFeature = pikkuFeature({',
67
+ " name: 'Credential API',",
68
+ " tags: ['credential'],",
69
+ ' scenarios: [lazyLoadScenario],',
70
+ '})',
71
+ ].join('\n')
72
+ )
73
+ try {
74
+ assert.deepEqual(criticals, [])
75
+ assert.deepEqual(
76
+ [...state.workflows.featureFiles.entries()],
77
+ [
78
+ [
79
+ 'credentialFeature',
80
+ {
81
+ path: featureFile,
82
+ exportedName: 'credentialFeature',
83
+ name: 'Credential API',
84
+ tags: ['credential'],
85
+ entries: [{ scenario: 'lazyLoadScenario' }],
86
+ unresolvedEntries: 0,
87
+ hasBefore: false,
88
+ hasAfter: false,
89
+ },
90
+ ],
91
+ ]
92
+ )
93
+ } finally {
94
+ await cleanup()
95
+ }
96
+ })
97
+
98
+ test('extracts the feature name, description and tags', async () => {
99
+ const { state, criticals, cleanup } = await run(
100
+ [
101
+ "import { pikkuFeature } from '@pikku/core/workflow'",
102
+ "import { lazyLoadScenario } from './credential.scenario.js'",
103
+ 'export const credentialFeature = pikkuFeature({',
104
+ " name: 'Credential API',",
105
+ " description: 'How credentials are loaded and rotated',",
106
+ " tags: ['credential', 'console'],",
107
+ ' scenarios: [lazyLoadScenario],',
108
+ '})',
109
+ ].join('\n')
110
+ )
111
+ try {
112
+ assert.deepEqual(criticals, [])
113
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
114
+ assert.equal(feature.name, 'Credential API')
115
+ assert.equal(
116
+ feature.description,
117
+ 'How credentials are loaded and rotated'
118
+ )
119
+ assert.deepEqual(feature.tags, ['credential', 'console'])
120
+ } finally {
121
+ await cleanup()
122
+ }
123
+ })
124
+
125
+ test('resolves a literal scenarios array to scenario names, in order', async () => {
126
+ const { state, criticals, cleanup } = await run(
127
+ [
128
+ "import { pikkuFeature } from '@pikku/core/workflow'",
129
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
130
+ 'export const credentialFeature = pikkuFeature({',
131
+ " name: 'Credential API',",
132
+ ' scenarios: [roundTripScenario, lazyLoadScenario],',
133
+ '})',
134
+ ].join('\n')
135
+ )
136
+ try {
137
+ assert.deepEqual(criticals, [])
138
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
139
+ assert.deepEqual(feature.entries, [
140
+ { scenario: 'roundTripScenario' },
141
+ { scenario: 'lazyLoadScenario' },
142
+ ])
143
+ assert.equal(feature.unresolvedEntries, 0)
144
+ } finally {
145
+ await cleanup()
146
+ }
147
+ })
148
+
149
+ test('resolves an aliased import back to the declared scenario name', async () => {
150
+ const { state, criticals, cleanup } = await run(
151
+ [
152
+ "import { pikkuFeature } from '@pikku/core/workflow'",
153
+ "import { lazyLoadScenario as lazy } from './credential.scenario.js'",
154
+ 'export const credentialFeature = pikkuFeature({',
155
+ " name: 'Credential API',",
156
+ ' scenarios: [lazy],',
157
+ '})',
158
+ ].join('\n')
159
+ )
160
+ try {
161
+ assert.deepEqual(criticals, [])
162
+ assert.deepEqual(
163
+ state.workflows.featureFiles.get('credentialFeature')!.entries,
164
+ [{ scenario: 'lazyLoadScenario' }]
165
+ )
166
+ } finally {
167
+ await cleanup()
168
+ }
169
+ })
170
+
171
+ test('resolves a { scenario, data } entry with its literal data', async () => {
172
+ const { state, criticals, cleanup } = await run(
173
+ [
174
+ "import { pikkuFeature } from '@pikku/core/workflow'",
175
+ "import { roundTripScenario } from './credential.scenario.js'",
176
+ 'export const credentialFeature = pikkuFeature({',
177
+ " name: 'Credential API',",
178
+ ' scenarios: [',
179
+ " { scenario: roundTripScenario, data: { name: 'stripe', retries: 2 } },",
180
+ " { scenario: roundTripScenario, data: { name: 'google', retries: 0 } },",
181
+ ' ],',
182
+ '})',
183
+ ].join('\n')
184
+ )
185
+ try {
186
+ assert.deepEqual(criticals, [])
187
+ assert.deepEqual(
188
+ state.workflows.featureFiles.get('credentialFeature')!.entries,
189
+ [
190
+ {
191
+ scenario: 'roundTripScenario',
192
+ data: { name: 'stripe', retries: 2 },
193
+ },
194
+ {
195
+ scenario: 'roundTripScenario',
196
+ data: { name: 'google', retries: 0 },
197
+ },
198
+ ]
199
+ )
200
+ } finally {
201
+ await cleanup()
202
+ }
203
+ })
204
+
205
+ test('records whether the feature has before/after hooks', async () => {
206
+ const { state, criticals, cleanup } = await run(
207
+ [
208
+ "import { pikkuFeature } from '@pikku/core/workflow'",
209
+ "import { lazyLoadScenario } from './credential.scenario.js'",
210
+ 'export const credentialFeature = pikkuFeature({',
211
+ " name: 'Credential API',",
212
+ ' before: async () => {},',
213
+ ' scenarios: [lazyLoadScenario],',
214
+ '})',
215
+ ].join('\n')
216
+ )
217
+ try {
218
+ assert.deepEqual(criticals, [])
219
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
220
+ assert.equal(feature.hasBefore, true)
221
+ assert.equal(feature.hasAfter, false)
222
+ } finally {
223
+ await cleanup()
224
+ }
225
+ })
226
+
227
+ test('a feature built by a loop keeps its literal entries and counts the rest', async () => {
228
+ // A spread of `.map()` cannot be enumerated statically — membership for it
229
+ // is resolved at runtime by object identity. Everything literal alongside it
230
+ // is still extracted, and the dynamic portion is counted rather than lost.
231
+ const { state, criticals, cleanup } = await run(
232
+ [
233
+ "import { pikkuFeature } from '@pikku/core/workflow'",
234
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
235
+ 'export const credentialFeature = pikkuFeature({',
236
+ " name: 'Credential API',",
237
+ ' scenarios: [',
238
+ ' lazyLoadScenario,',
239
+ " ...['stripe', 'google'].map((name) => ({",
240
+ ' scenario: roundTripScenario,',
241
+ ' data: { name },',
242
+ ' })),',
243
+ ' ],',
244
+ '})',
245
+ ].join('\n')
246
+ )
247
+ try {
248
+ assert.deepEqual(criticals, [])
249
+ const feature = state.workflows.featureFiles.get('credentialFeature')!
250
+ assert.deepEqual(feature.entries, [{ scenario: 'lazyLoadScenario' }])
251
+ assert.equal(feature.unresolvedEntries, 1)
252
+ } finally {
253
+ await cleanup()
254
+ }
255
+ })
256
+
257
+ test('several features in one file are all recorded', async () => {
258
+ const { state, criticals, cleanup } = await run(
259
+ [
260
+ "import { pikkuFeature } from '@pikku/core/workflow'",
261
+ "import { lazyLoadScenario, roundTripScenario } from './credential.scenario.js'",
262
+ "export const readFeature = pikkuFeature({ name: 'Read', scenarios: [lazyLoadScenario] })",
263
+ "export const writeFeature = pikkuFeature({ name: 'Write', scenarios: [roundTripScenario] })",
264
+ ].join('\n')
265
+ )
266
+ try {
267
+ assert.deepEqual(criticals, [])
268
+ assert.deepEqual(
269
+ [...state.workflows.featureFiles.keys()],
270
+ ['readFeature', 'writeFeature']
271
+ )
272
+ } finally {
273
+ await cleanup()
274
+ }
275
+ })
276
+
277
+ test('a feature that is not exported is a critical error', async () => {
278
+ const { state, criticals, cleanup } = await run(
279
+ [
280
+ "import { pikkuFeature } from '@pikku/core/workflow'",
281
+ "import { lazyLoadScenario } from './credential.scenario.js'",
282
+ "const credentialFeature = pikkuFeature({ name: 'Credential API', scenarios: [lazyLoadScenario] })",
283
+ 'void credentialFeature',
284
+ ].join('\n')
285
+ )
286
+ try {
287
+ assert.equal(state.workflows.featureFiles.size, 0)
288
+ assert.equal(criticals.length, 1)
289
+ assert.match(criticals[0]!.message, /must be assigned to an export/)
290
+ } finally {
291
+ await cleanup()
292
+ }
293
+ })
294
+
295
+ test('a project with no features records none', async () => {
296
+ const { state, criticals, cleanup } = await run(
297
+ "export const nothing = 'here'\n"
298
+ )
299
+ try {
300
+ assert.deepEqual(criticals, [])
301
+ assert.equal(state.workflows.featureFiles.size, 0)
302
+ } finally {
303
+ await cleanup()
304
+ }
305
+ })
306
+ })
@@ -0,0 +1,253 @@
1
+ import * as ts from 'typescript'
2
+ import { extractFunctionName } from '../utils/extract-function-name.js'
3
+ import { ErrorCode } from '../error-codes.js'
4
+ import type {
5
+ AddWiring,
6
+ InspectorFeature,
7
+ InspectorFeatureEntry,
8
+ } from '../types.js'
9
+
10
+ /**
11
+ * Reads a fully literal expression into a plain value.
12
+ *
13
+ * Used for a `{ scenario, data }` entry's `data`, which is gherkin's
14
+ * `Examples:` row. Anything not literal yields `undefined` — a computed row
15
+ * belongs to the runtime-resolved portion, not to the extracted meta.
16
+ */
17
+ const literalValue = (node: ts.Expression): unknown => {
18
+ if (ts.isStringLiteralLike(node)) {
19
+ return node.text
20
+ }
21
+ if (ts.isNumericLiteral(node)) {
22
+ return Number(node.text)
23
+ }
24
+ if (node.kind === ts.SyntaxKind.TrueKeyword) {
25
+ return true
26
+ }
27
+ if (node.kind === ts.SyntaxKind.FalseKeyword) {
28
+ return false
29
+ }
30
+ if (node.kind === ts.SyntaxKind.NullKeyword) {
31
+ return null
32
+ }
33
+ if (
34
+ ts.isPrefixUnaryExpression(node) &&
35
+ node.operator === ts.SyntaxKind.MinusToken &&
36
+ ts.isNumericLiteral(node.operand)
37
+ ) {
38
+ return -Number(node.operand.text)
39
+ }
40
+ if (ts.isArrayLiteralExpression(node)) {
41
+ const values: unknown[] = []
42
+ for (const element of node.elements) {
43
+ const value = literalValue(element)
44
+ if (value === undefined) {
45
+ return undefined
46
+ }
47
+ values.push(value)
48
+ }
49
+ return values
50
+ }
51
+ if (ts.isObjectLiteralExpression(node)) {
52
+ const value: Record<string, unknown> = {}
53
+ for (const property of node.properties) {
54
+ if (!ts.isPropertyAssignment(property)) {
55
+ return undefined
56
+ }
57
+ const key =
58
+ ts.isIdentifier(property.name) || ts.isStringLiteralLike(property.name)
59
+ ? property.name.text
60
+ : undefined
61
+ if (key === undefined) {
62
+ return undefined
63
+ }
64
+ const propertyValue = literalValue(property.initializer)
65
+ if (propertyValue === undefined) {
66
+ return undefined
67
+ }
68
+ value[key] = propertyValue
69
+ }
70
+ return value
71
+ }
72
+ return undefined
73
+ }
74
+
75
+ /**
76
+ * Resolves a scenario reference to the name it is declared under, following an
77
+ * import alias back to the original export so `{ x as y }` records `x`.
78
+ */
79
+ const scenarioName = (
80
+ node: ts.Expression,
81
+ checker: ts.TypeChecker
82
+ ): string | undefined => {
83
+ if (!ts.isIdentifier(node)) {
84
+ return undefined
85
+ }
86
+ const symbol = checker.getSymbolAtLocation(node)
87
+ if (!symbol) {
88
+ return node.text
89
+ }
90
+ const resolved =
91
+ symbol.flags & ts.SymbolFlags.Alias
92
+ ? checker.getAliasedSymbol(symbol)
93
+ : symbol
94
+ return resolved.getName() || node.text
95
+ }
96
+
97
+ const getProperty = (
98
+ config: ts.ObjectLiteralExpression,
99
+ name: string
100
+ ): ts.Expression | undefined => {
101
+ const property = config.properties.find(
102
+ (p) =>
103
+ ts.isPropertyAssignment(p) &&
104
+ ts.isIdentifier(p.name) &&
105
+ p.name.text === name
106
+ )
107
+ return property && ts.isPropertyAssignment(property)
108
+ ? property.initializer
109
+ : undefined
110
+ }
111
+
112
+ const hasProperty = (
113
+ config: ts.ObjectLiteralExpression,
114
+ name: string
115
+ ): boolean =>
116
+ config.properties.some(
117
+ (p) =>
118
+ (ts.isPropertyAssignment(p) ||
119
+ ts.isMethodDeclaration(p) ||
120
+ ts.isShorthandPropertyAssignment(p)) &&
121
+ ts.isIdentifier(p.name) &&
122
+ p.name.text === name
123
+ )
124
+
125
+ const stringProperty = (
126
+ config: ts.ObjectLiteralExpression,
127
+ name: string
128
+ ): string | undefined => {
129
+ const value = getProperty(config, name)
130
+ return value && ts.isStringLiteralLike(value) ? value.text : undefined
131
+ }
132
+
133
+ const stringArrayProperty = (
134
+ config: ts.ObjectLiteralExpression,
135
+ name: string
136
+ ): string[] | undefined => {
137
+ const value = getProperty(config, name)
138
+ if (!value || !ts.isArrayLiteralExpression(value)) {
139
+ return undefined
140
+ }
141
+ const values = value.elements
142
+ .filter(ts.isStringLiteralLike)
143
+ .map((e) => e.text)
144
+ return values.length === value.elements.length ? values : undefined
145
+ }
146
+
147
+ const readEntry = (
148
+ element: ts.Expression,
149
+ checker: ts.TypeChecker
150
+ ): InspectorFeatureEntry | undefined => {
151
+ if (ts.isIdentifier(element)) {
152
+ const scenario = scenarioName(element, checker)
153
+ return scenario ? { scenario } : undefined
154
+ }
155
+
156
+ if (ts.isObjectLiteralExpression(element)) {
157
+ const reference = getProperty(element, 'scenario')
158
+ if (!reference) {
159
+ return undefined
160
+ }
161
+ const scenario = scenarioName(reference, checker)
162
+ if (!scenario) {
163
+ return undefined
164
+ }
165
+ const dataNode = getProperty(element, 'data')
166
+ if (!dataNode) {
167
+ return { scenario }
168
+ }
169
+ const data = literalValue(dataNode)
170
+ return data === undefined ? undefined : { scenario, data }
171
+ }
172
+
173
+ return undefined
174
+ }
175
+
176
+ /**
177
+ * Inspector for `pikkuFeature()` calls.
178
+ *
179
+ * A feature is the document structure the console renders — its name,
180
+ * description, tags and the order of its scenarios are what a reader sees — so
181
+ * everything the config states literally is extracted here.
182
+ *
183
+ * What cannot be extracted is a scenarios entry that is not literal:
184
+ * `...['stripe', 'google'].map((name) => ({ scenario, data: { name } }))` is
185
+ * ordinary TypeScript, and no AST walk can enumerate it. Those entries are
186
+ * counted in `unresolvedEntries` rather than dropped, and membership for them
187
+ * is resolved at runtime by object identity (`resolveFeatureScenarios`), where
188
+ * the array has actually been evaluated.
189
+ *
190
+ * The export location is recorded either way, which is what the CLI needs to
191
+ * emit its `addFeature(...)` wiring line.
192
+ */
193
+ export const addFeature: AddWiring = (logger, node, checker, state) => {
194
+ if (!ts.isCallExpression(node)) {
195
+ return
196
+ }
197
+
198
+ const expression = node.expression
199
+ if (!ts.isIdentifier(expression) || expression.text !== 'pikkuFeature') {
200
+ return
201
+ }
202
+
203
+ const { exportedName } = extractFunctionName(node, checker, state.rootDir)
204
+
205
+ if (!exportedName) {
206
+ logger.critical(
207
+ ErrorCode.MISSING_NAME,
208
+ `A pikkuFeature() must be assigned to an export — the export identifier is the feature's id.`
209
+ )
210
+ return
211
+ }
212
+
213
+ const feature: InspectorFeature = {
214
+ path: node.getSourceFile().fileName,
215
+ exportedName,
216
+ entries: [],
217
+ unresolvedEntries: 0,
218
+ hasBefore: false,
219
+ hasAfter: false,
220
+ }
221
+
222
+ const [config] = node.arguments
223
+ if (config && ts.isObjectLiteralExpression(config)) {
224
+ const name = stringProperty(config, 'name')
225
+ const description = stringProperty(config, 'description')
226
+ const tags = stringArrayProperty(config, 'tags')
227
+ if (name !== undefined) {
228
+ feature.name = name
229
+ }
230
+ if (description !== undefined) {
231
+ feature.description = description
232
+ }
233
+ if (tags !== undefined) {
234
+ feature.tags = tags
235
+ }
236
+ feature.hasBefore = hasProperty(config, 'before')
237
+ feature.hasAfter = hasProperty(config, 'after')
238
+
239
+ const scenarios = getProperty(config, 'scenarios')
240
+ if (scenarios && ts.isArrayLiteralExpression(scenarios)) {
241
+ for (const element of scenarios.elements) {
242
+ const entry = readEntry(element, checker)
243
+ if (entry) {
244
+ feature.entries.push(entry)
245
+ } else {
246
+ feature.unresolvedEntries += 1
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ state.workflows.featureFiles.set(exportedName, feature)
253
+ }
@@ -386,15 +386,22 @@ export const addFunctions: AddWiring = (
386
386
  }
387
387
 
388
388
  // Match identifiers that contain both "pikku" and "func" (case insensitive),
389
- // plus pikkuScenario (a workflow wrapper without "func" in its name)
389
+ // plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
390
+ // in their names)
390
391
  const pikkuFuncPattern = /pikku.*func/i
391
392
  if (
392
393
  !pikkuFuncPattern.test(expression.text) &&
393
- expression.text !== 'pikkuScenario'
394
+ expression.text !== 'pikkuScenario' &&
395
+ expression.text !== 'pikkuScenarioStep'
394
396
  ) {
395
397
  return
396
398
  }
397
399
 
400
+ // A scenario step is bundled like any invoked function, but is deliberately
401
+ // never RPC-registered: a step may drive a browser, so exposing it over the
402
+ // network would put a test harness on the public API surface.
403
+ const isScenarioStep = expression.text === 'pikkuScenarioStep'
404
+
398
405
  // only handle calls like pikkuFunc(...)
399
406
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
400
407
  return
@@ -427,6 +434,8 @@ export const addFunctions: AddWiring = (
427
434
  let workflowQueued: boolean | undefined
428
435
  let workflowRetries: number | undefined
429
436
  let workflowTimeout: string | undefined
437
+ let scenarioStepBrowser: boolean | undefined
438
+ let scenarioStepTemplate: string | undefined
430
439
  let scopes: string[] | undefined
431
440
  let version: number | undefined
432
441
  let objectNode: ts.ObjectLiteralExpression | undefined
@@ -532,6 +541,12 @@ export const addFunctions: AddWiring = (
532
541
  workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
533
542
  | string
534
543
  | undefined
544
+ scenarioStepBrowser = getPropertyValue(firstArg, 'browser') as
545
+ | boolean
546
+ | undefined
547
+ scenarioStepTemplate = getPropertyValue(firstArg, 'template') as
548
+ | string
549
+ | undefined
535
550
  scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined
536
551
 
537
552
  // Extract approvalDescription identifier reference
@@ -1078,6 +1093,7 @@ export const addFunctions: AddWiring = (
1078
1093
  outputs: outputNames.filter((n) => n !== 'void') ?? null,
1079
1094
  expose: expose || undefined,
1080
1095
  remote: remote || undefined,
1096
+ scenarioStep: isScenarioStep || undefined,
1081
1097
  mcp: mcpEnabled || undefined,
1082
1098
  readonly: readonly_ || undefined,
1083
1099
  deploy: deploy || undefined,
@@ -1086,6 +1102,8 @@ export const addFunctions: AddWiring = (
1086
1102
  workflowQueued: workflowQueued === true ? true : undefined,
1087
1103
  workflowRetries: workflowRetries ?? undefined,
1088
1104
  workflowTimeout: workflowTimeout ?? undefined,
1105
+ scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
1106
+ scenarioStepTemplate: scenarioStepTemplate || undefined,
1089
1107
  scopes: scopes ?? undefined,
1090
1108
  implementationHash,
1091
1109
  version,
@@ -1182,7 +1200,7 @@ export const addFunctions: AddWiring = (
1182
1200
  return
1183
1201
  }
1184
1202
 
1185
- if (remote) {
1203
+ if (remote && !isScenarioStep) {
1186
1204
  state.rpc.invokedFunctions.add(pikkuFuncId)
1187
1205
  // The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
1188
1206
  state.rpc.remoteMeta[name] = pikkuFuncId
@@ -1192,7 +1210,7 @@ export const addFunctions: AddWiring = (
1192
1210
  })
1193
1211
  }
1194
1212
 
1195
- if (expose) {
1213
+ if (expose && !isScenarioStep) {
1196
1214
  state.rpc.exposedMeta[name] = pikkuFuncId
1197
1215
  state.rpc.exposedFiles.set(name, {
1198
1216
  path: node.getSourceFile().fileName,
@@ -1202,12 +1220,14 @@ export const addFunctions: AddWiring = (
1202
1220
  state.serviceAggregation.usedFunctions.add(pikkuFuncId)
1203
1221
  }
1204
1222
 
1205
- // We add it to internal meta to allow autocomplete for everything
1206
- state.rpc.internalMeta[name] = pikkuFuncId
1223
+ if (!isScenarioStep) {
1224
+ // We add it to internal meta to allow autocomplete for everything
1225
+ state.rpc.internalMeta[name] = pikkuFuncId
1207
1226
 
1208
- if (version !== undefined) {
1209
- state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId
1210
- state.rpc.invokedFunctions.add(pikkuFuncId)
1227
+ if (version !== undefined) {
1228
+ state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId
1229
+ state.rpc.invokedFunctions.add(pikkuFuncId)
1230
+ }
1211
1231
  }
1212
1232
 
1213
1233
  // But we only import the actual function if it's actually invoked to keep