@pikku/inspector 0.12.48 → 0.12.50

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.
@@ -21,6 +21,7 @@ import { resolvePermissions } from '../utils/permissions.js'
21
21
  import { extractWireNames } from '../utils/post-process.js'
22
22
  import { ErrorCode } from '../error-codes.js'
23
23
  import { findPiiPaths } from '../utils/check-pii-output.js'
24
+ import { isScenarioInstrumentationFunction } from './scenario-instrumentation.js'
24
25
  import type { NodeType } from '@pikku/core/node'
25
26
 
26
27
  const isValidVariableName = (name: string) => {
@@ -402,6 +403,11 @@ export const addFunctions: AddWiring = (
402
403
  // network would put a test harness on the public API surface.
403
404
  const isScenarioStep = expression.text === 'pikkuScenarioStep'
404
405
 
406
+ // A scenario's own body. Marked so the codegen partitions and the deploy
407
+ // analyzer can tell it apart from an application workflow without having to
408
+ // reach into the workflow graph.
409
+ const isScenario = expression.text === 'pikkuScenario'
410
+
405
411
  // only handle calls like pikkuFunc(...)
406
412
  if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
407
413
  return
@@ -419,6 +425,17 @@ export const addFunctions: AddWiring = (
419
425
  return
420
426
  }
421
427
 
428
+ // pikku's own scenario instrumentation, which older projects still carry as a
429
+ // scaffolded file in their source. It is not application code and must not be
430
+ // registered, metadata'd or deployed as if it were — `pikku dev` registers the
431
+ // implementations itself, and that is the only server that should have them.
432
+ if (
433
+ isScenarioInstrumentationFunction(pikkuFuncId) ||
434
+ isScenarioInstrumentationFunction(name)
435
+ ) {
436
+ return
437
+ }
438
+
422
439
  let title: string | undefined
423
440
  let tags: string[] | undefined
424
441
  let summary: string | undefined
@@ -1094,6 +1111,7 @@ export const addFunctions: AddWiring = (
1094
1111
  expose: expose || undefined,
1095
1112
  remote: remote || undefined,
1096
1113
  scenarioStep: isScenarioStep || undefined,
1114
+ scenario: isScenario || undefined,
1097
1115
  mcp: mcpEnabled || undefined,
1098
1116
  readonly: readonly_ || undefined,
1099
1117
  deploy: deploy || undefined,
@@ -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)
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The `scenario` / `scenarioStep` markers in the *function* meta are what every
3
+ * deployment boundary downstream reads: the codegen partition, the RPC meta
4
+ * filter, the schema split and the deploy analyzer. Asserting them on a real
5
+ * `pikkuScenario(...)` is what makes those filters mean anything — filters tested
6
+ * against hand-written meta would keep passing if extraction stopped setting it.
7
+ */
8
+ import { strict as assert } from 'assert'
9
+ import { describe, test } from 'node:test'
10
+ import { mkdtemp, rm, writeFile } from 'node:fs/promises'
11
+ import { tmpdir } from 'node:os'
12
+ import { join } from 'node:path'
13
+ import { inspect } from '../inspector.js'
14
+ import type { InspectorLogger } from '../types.js'
15
+
16
+ const logger = {
17
+ debug: () => {},
18
+ info: () => {},
19
+ warn: () => {},
20
+ error: () => {},
21
+ diagnostic: () => {},
22
+ critical: () => {},
23
+ hasCriticalErrors: () => false,
24
+ } as unknown as InspectorLogger
25
+
26
+ const STEPS = [
27
+ "import { pikkuScenarioStep } from '@pikku/core'",
28
+ 'export const buysAnApple = pikkuScenarioStep({',
29
+ " name: 'buysAnApple',",
30
+ " description: 'buys an apple',",
31
+ ' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
32
+ '})',
33
+ ].join('\n')
34
+
35
+ const APP = [
36
+ "import { pikkuFunc } from '@pikku/core'",
37
+ 'export const createTodo = pikkuFunc({',
38
+ ' expose: true,',
39
+ ' func: async (_services, data: { title: string }) => ({ id: data.title }),',
40
+ '})',
41
+ ].join('\n')
42
+
43
+ const SCENARIO = [
44
+ "import { pikkuScenario } from '@pikku/core/workflow'",
45
+ 'export const shopFlow = pikkuScenario({',
46
+ " title: 'Buys an apple',",
47
+ ' func: async (_services, _input, { scenario }: any) => {',
48
+ " await scenario.when('buys an apple', 'buysAnApple', { qty: 1 })",
49
+ ' return { ok: true }',
50
+ ' },',
51
+ '})',
52
+ ].join('\n')
53
+
54
+ describe('scenario markers in the function meta', () => {
55
+ test('a real pikkuScenario and pikkuScenarioStep are marked, an application function is not', async () => {
56
+ const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-marker-'))
57
+ const files = [
58
+ [join(rootDir, 'shop.steps.ts'), STEPS],
59
+ [join(rootDir, 'shop.scenario.ts'), SCENARIO],
60
+ [join(rootDir, 'todo.function.ts'), APP],
61
+ ] as const
62
+ await Promise.all(files.map(([path, source]) => writeFile(path, source)))
63
+
64
+ try {
65
+ const state = await inspect(
66
+ logger,
67
+ files.map(([path]) => path),
68
+ { rootDir }
69
+ )
70
+
71
+ const scenario = state.functions.meta['shopFlow']
72
+ assert.ok(scenario, 'the scenario body is a function and must have meta')
73
+ assert.equal(scenario.scenario, true)
74
+ assert.equal(scenario.scenarioStep, undefined)
75
+
76
+ const step = state.functions.meta['buysAnApple']
77
+ assert.ok(step)
78
+ assert.equal(step.scenarioStep, true)
79
+ assert.equal(step.scenario, undefined)
80
+
81
+ const app = state.functions.meta['createTodo']
82
+ assert.ok(app)
83
+ assert.equal(app.scenario, undefined)
84
+ assert.equal(app.scenarioStep, undefined)
85
+ } finally {
86
+ await rm(rootDir, { recursive: true, force: true })
87
+ }
88
+ })
89
+ })
package/src/index.ts CHANGED
@@ -5,6 +5,11 @@ export type { FilesAndMethodsErrors } from './utils/get-files-and-methods.js'
5
5
  export { ErrorCode } from './error-codes.js'
6
6
  export type { DiagnosticSeverity, CodedDiagnostic } from './error-codes.js'
7
7
  export { AUTH_HANDLER_FUNC_ID } from './add/add-auth.js'
8
+ export {
9
+ SCENARIO_INSTRUMENTATION_FUNCTIONS,
10
+ isScenarioInstrumentationFunction,
11
+ } from './add/scenario-instrumentation.js'
12
+ export type { ScenarioInstrumentationFunction } from './add/scenario-instrumentation.js'
8
13
  export {
9
14
  serializeInspectorState,
10
15
  deserializeInspectorState,
@@ -64,6 +64,7 @@ function makeState(
64
64
  addonFunctions?: Record<string, Record<string, any>>
65
65
  addonRequiredParentServices?: string[]
66
66
  authServices?: string[]
67
+ graphMeta?: Record<string, any>
67
68
  } = {}
68
69
  ): Omit<InspectorState, 'typesLookup'> {
69
70
  return {
@@ -95,7 +96,7 @@ function makeState(
95
96
  scheduledTasks: { meta: {} },
96
97
  mcpEndpoints: { toolsMeta: {}, promptsMeta: {}, resourcesMeta: {} },
97
98
  agents: { agentsMeta: {} },
98
- workflows: { meta: {}, graphMeta: {} },
99
+ workflows: { meta: {}, graphMeta: overrides.graphMeta ?? {} },
99
100
  wireServicesMeta: new Map(),
100
101
  rpc: { internalMeta: {}, exposedMeta: {} },
101
102
  addonFunctions: overrides.addonFunctions ?? {},
@@ -117,6 +118,34 @@ const CONSOLE_PARENT_SERVICES = [
117
118
  'credentialService',
118
119
  ]
119
120
 
121
+ describe('aggregateRequiredServices — synthetic workflow queue workers', () => {
122
+ const graphMeta = {
123
+ orderWorkflow: {
124
+ name: 'orderWorkflow',
125
+ source: 'dsl',
126
+ nodes: { 'step-1': { rpcName: 'chargeCard' } },
127
+ },
128
+ loginScenario: {
129
+ name: 'loginScenario',
130
+ source: 'scenario',
131
+ nodes: { 'step-1': { rpcName: 'opensPage' } },
132
+ },
133
+ }
134
+
135
+ // A scenario IS a workflow, so it used to be handed an orchestrator queue
136
+ // worker in the app's queue meta — which the app bootstrap registers and every
137
+ // deployed bundle therefore carries, leaving a provider to create a production
138
+ // queue named after a test. Nothing dispatches a scenario through a queue:
139
+ // `pikku scenario run` executes its steps in-process.
140
+ test('a scenario gets no orchestrator queue worker', () => {
141
+ const state = makeState({ graphMeta })
142
+ aggregateRequiredServices(state)
143
+ assert.deepEqual(Object.keys(state.queueWorkers.meta), [
144
+ 'wf-orchestrator-order-workflow',
145
+ ])
146
+ })
147
+ })
148
+
120
149
  describe('aggregateRequiredServices — per-function addon services', () => {
121
150
  test('a used addon function adds only its own parent services', () => {
122
151
  const state = makeState({
@@ -262,6 +262,12 @@ export function aggregateRequiredServices(
262
262
  // per-workflow queue entries and falls back to shared queue names.
263
263
  for (const [, graph] of Object.entries(state.workflows.graphMeta)) {
264
264
  if (!graph.nodes || !graph.name) continue
265
+ // A scenario is a workflow, but it only ever runs in-process under
266
+ // `pikku scenario run` — no step of one is dispatched through a queue. The
267
+ // synthetic entries were pure leakage: they put a
268
+ // `wf-orchestrator-<scenario>` worker into the app's queue meta, which every
269
+ // bundle imports and a provider then creates as a real production queue.
270
+ if (graph.source === 'scenario') continue
265
271
 
266
272
  const toKebab = (s: string) =>
267
273
  s
@@ -0,0 +1,124 @@
1
+ import { test, describe } from 'node:test'
2
+ import { strict as assert } from 'node:assert'
3
+ import {
4
+ mkdtempSync,
5
+ rmSync,
6
+ statSync,
7
+ utimesSync,
8
+ writeFileSync,
9
+ } from 'node:fs'
10
+ import { tmpdir } from 'node:os'
11
+ import { join } from 'node:path'
12
+ import {
13
+ readDiskTSSchemas,
14
+ writeDiskTSSchemas,
15
+ type SchemaDep,
16
+ } from './schema-generator.js'
17
+ import type { InspectorLogger } from '../types.js'
18
+
19
+ const silentLogger = {
20
+ debug: () => {},
21
+ info: () => {},
22
+ warn: () => {},
23
+ error: () => {},
24
+ } as unknown as InspectorLogger
25
+
26
+ const KEY = 'test-key'
27
+ const SCHEMAS = { Thing: { type: 'object' } }
28
+
29
+ /** A cache directory and one source file the cached schemas claim to derive from. */
30
+ function makeCache(sourceContent: string): {
31
+ cacheDir: string
32
+ source: string
33
+ deps: SchemaDep[]
34
+ cleanup: () => void
35
+ } {
36
+ const dir = mkdtempSync(join(tmpdir(), 'pikku-schema-cache-'))
37
+ const source = join(dir, 'thing.ts')
38
+ writeFileSync(source, sourceContent)
39
+ const stat = statSync(source)
40
+ return {
41
+ cacheDir: dir,
42
+ source,
43
+ deps: [{ path: source, mtimeMs: stat.mtimeMs, size: stat.size }],
44
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
45
+ }
46
+ }
47
+
48
+ describe('TS schema disk cache', () => {
49
+ test('serves the cached schemas while their sources are untouched', () => {
50
+ const { cacheDir, deps, cleanup } = makeCache('export interface Thing {}')
51
+ try {
52
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
53
+ const hit = readDiskTSSchemas(silentLogger, cacheDir, KEY)
54
+ assert.deepEqual(hit?.schemas, SCHEMAS)
55
+ } finally {
56
+ cleanup()
57
+ }
58
+ })
59
+
60
+ test('discards the cache when a type it was built from changes', () => {
61
+ const { cacheDir, source, deps, cleanup } = makeCache(
62
+ 'export interface Thing {}'
63
+ )
64
+ try {
65
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
66
+ // The shape changes but the name does not, so the cache key — which hashes
67
+ // only the synthesized custom-types source — is identical. This is the case
68
+ // that used to serve a stale schema, and it survived `rm -rf .pikku`
69
+ // because the cache lives under node_modules/.cache.
70
+ writeFileSync(source, 'export interface Thing { added: string }')
71
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
72
+ } finally {
73
+ cleanup()
74
+ }
75
+ })
76
+
77
+ test('discards the cache when a source is rewritten to the same size', () => {
78
+ const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
79
+ try {
80
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
81
+ // Same byte count, different type: size alone would call this unchanged, so
82
+ // the mtime half of the fingerprint is what has to catch it.
83
+ writeFileSync(source, 'export type A = 2')
84
+ utimesSync(source, new Date(), new Date(Date.now() + 5000))
85
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
86
+ } finally {
87
+ cleanup()
88
+ }
89
+ })
90
+
91
+ test('discards the cache when a source it was built from is deleted', () => {
92
+ const { cacheDir, source, deps, cleanup } = makeCache('export type A = 1')
93
+ try {
94
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
95
+ rmSync(source)
96
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
97
+ } finally {
98
+ cleanup()
99
+ }
100
+ })
101
+
102
+ test('discards a cache written before deps were tracked', () => {
103
+ const { cacheDir, cleanup } = makeCache('export type A = 1')
104
+ try {
105
+ writeFileSync(
106
+ join(cacheDir, 'ts-schemas.json'),
107
+ JSON.stringify({ key: KEY, schemas: SCHEMAS })
108
+ )
109
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, KEY), null)
110
+ } finally {
111
+ cleanup()
112
+ }
113
+ })
114
+
115
+ test('still misses on a different key', () => {
116
+ const { cacheDir, deps, cleanup } = makeCache('export type A = 1')
117
+ try {
118
+ writeDiskTSSchemas(silentLogger, cacheDir, KEY, SCHEMAS, deps)
119
+ assert.equal(readDiskTSSchemas(silentLogger, cacheDir, 'other'), null)
120
+ } finally {
121
+ cleanup()
122
+ }
123
+ })
124
+ })
@@ -5,7 +5,7 @@ import { tmpdir } from 'os'
5
5
  import { join } from 'path'
6
6
 
7
7
  import { getInitialInspectorState } from '../inspector.js'
8
- import { generateAllSchemas } from './schema-generator.js'
8
+ import { generateAllSchemas, schemaRuntimeFile } from './schema-generator.js'
9
9
 
10
10
  const debugLines: string[] = []
11
11
  const logger = {
@@ -162,3 +162,64 @@ describe('generateAllSchemas', () => {
162
162
  )
163
163
  })
164
164
  })
165
+
166
+ /**
167
+ * A schema imported from a built workspace package resolves to that package's
168
+ * declaration file, and a `.d.ts` has no runtime exports at all — every one of
169
+ * them is a type. Importing it to read the zod object yields an empty module and
170
+ * every schema in it is reported missing, so the emitted JS beside it is what has
171
+ * to be imported.
172
+ */
173
+ describe('schemaRuntimeFile', () => {
174
+ test('a source file the developer wrote is imported as it is', () => {
175
+ const written = join(projectDir, 'src', 'types.ts')
176
+ assert.equal(schemaRuntimeFile(written), written)
177
+ })
178
+
179
+ test('a declaration file resolves to the JS emitted beside it', () => {
180
+ writeFileSync(
181
+ join(projectDir, 'src', 'reindex.d.ts'),
182
+ 'export declare const a: number\n'
183
+ )
184
+ writeFileSync(join(projectDir, 'src', 'reindex.js'), 'export const a = 1\n')
185
+ assert.equal(
186
+ schemaRuntimeFile(join(projectDir, 'src', 'reindex.d.ts')),
187
+ join(projectDir, 'src', 'reindex.js')
188
+ )
189
+ })
190
+
191
+ test('.d.mts resolves to the ESM file beside it', () => {
192
+ writeFileSync(
193
+ join(projectDir, 'src', 'esm.d.mts'),
194
+ 'export declare const a: number\n'
195
+ )
196
+ writeFileSync(join(projectDir, 'src', 'esm.mjs'), 'export const a = 1\n')
197
+ assert.equal(
198
+ schemaRuntimeFile(join(projectDir, 'src', 'esm.d.mts')),
199
+ join(projectDir, 'src', 'esm.mjs')
200
+ )
201
+ })
202
+
203
+ test('.d.cts resolves to the CommonJS file beside it', () => {
204
+ // Its own test rather than a second assertion above: the two extensions are
205
+ // separate branches, and one covering both is one that passes while half of
206
+ // it is broken.
207
+ writeFileSync(
208
+ join(projectDir, 'src', 'commonjs.d.cts'),
209
+ 'export declare const a: number\n'
210
+ )
211
+ writeFileSync(join(projectDir, 'src', 'commonjs.cjs'), 'exports.a = 1\n')
212
+ assert.equal(
213
+ schemaRuntimeFile(join(projectDir, 'src', 'commonjs.d.cts')),
214
+ join(projectDir, 'src', 'commonjs.cjs')
215
+ )
216
+ })
217
+
218
+ test('a declaration file with no emitted JS keeps its own path', () => {
219
+ // So the "could not find exported schema" error still names a file the
220
+ // developer can open, rather than one that was never written.
221
+ const orphan = join(projectDir, 'src', 'types-only.d.ts')
222
+ writeFileSync(orphan, 'export declare const a: number\n')
223
+ assert.equal(schemaRuntimeFile(orphan), orphan)
224
+ })
225
+ })