@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,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
|
+
})
|
|
@@ -0,0 +1,159 @@
|
|
|
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 STEPS = [
|
|
28
|
+
"import { pikkuScenarioStep } from '@pikku/core'",
|
|
29
|
+
'export const buysAnApple = pikkuScenarioStep({',
|
|
30
|
+
" name: 'buysAnApple',",
|
|
31
|
+
" description: 'buys an apple',",
|
|
32
|
+
' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
|
|
33
|
+
'})',
|
|
34
|
+
].join('\n')
|
|
35
|
+
|
|
36
|
+
const source = (wrapper: string, body: string[]) =>
|
|
37
|
+
[
|
|
38
|
+
`import { ${wrapper} } from '@pikku/core/workflow'`,
|
|
39
|
+
'declare const actors: Record<string, any>',
|
|
40
|
+
`export const shopFlow = ${wrapper}(async (_services, _input, { scenario, workflow }: any) => {`,
|
|
41
|
+
...body.map((line) => ` ${line}`),
|
|
42
|
+
' return { ok: true }',
|
|
43
|
+
'})',
|
|
44
|
+
].join('\n')
|
|
45
|
+
|
|
46
|
+
async function run(wrapper: string, body: string[]) {
|
|
47
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-extract-'))
|
|
48
|
+
const stepsFile = join(rootDir, 'shop.steps.ts')
|
|
49
|
+
const scenarioFile = join(rootDir, 'shop.scenario.ts')
|
|
50
|
+
await writeFile(stepsFile, STEPS)
|
|
51
|
+
await writeFile(scenarioFile, source(wrapper, body))
|
|
52
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
53
|
+
const state = await inspect(
|
|
54
|
+
makeLogger(criticals),
|
|
55
|
+
[stepsFile, scenarioFile],
|
|
56
|
+
{
|
|
57
|
+
rootDir,
|
|
58
|
+
}
|
|
59
|
+
)
|
|
60
|
+
return {
|
|
61
|
+
state,
|
|
62
|
+
criticals,
|
|
63
|
+
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* A scenario that fails DSL extraction silently registers with zero steps,
|
|
69
|
+
* because the fallback path (`getWorkflowInvocations`) understands `do`/`sleep`
|
|
70
|
+
* but not `step`/`given`/`when`/`then`. That is the PKU678 silent-drop class,
|
|
71
|
+
* and for scenarios it must be loud.
|
|
72
|
+
*/
|
|
73
|
+
describe('a scenario that cannot be extracted', () => {
|
|
74
|
+
test('a top-level try/catch is a PKU679 critical, not a silent zero-step scenario', async () => {
|
|
75
|
+
const { state, criticals, cleanup } = await run('pikkuScenario', [
|
|
76
|
+
'try {',
|
|
77
|
+
" await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
78
|
+
'} catch {}',
|
|
79
|
+
])
|
|
80
|
+
try {
|
|
81
|
+
assert.equal(
|
|
82
|
+
state.workflows.meta.shopFlow,
|
|
83
|
+
undefined,
|
|
84
|
+
'an unextractable scenario must not register at all, rather than register empty'
|
|
85
|
+
)
|
|
86
|
+
assert.ok(
|
|
87
|
+
criticals.find((c) => c.code === 'PKU679'),
|
|
88
|
+
`expected a PKU679 critical for an unextractable scenario, got: ${JSON.stringify(criticals)}`
|
|
89
|
+
)
|
|
90
|
+
} finally {
|
|
91
|
+
await cleanup()
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('an extractable scenario raises no PKU679', async () => {
|
|
96
|
+
const { criticals, cleanup } = await run('pikkuScenario', [
|
|
97
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
98
|
+
])
|
|
99
|
+
try {
|
|
100
|
+
assert.equal(
|
|
101
|
+
criticals.filter((c) => c.code === 'PKU679').length,
|
|
102
|
+
0,
|
|
103
|
+
`a valid scenario must not raise PKU679, got: ${JSON.stringify(criticals)}`
|
|
104
|
+
)
|
|
105
|
+
} finally {
|
|
106
|
+
await cleanup()
|
|
107
|
+
}
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
test('a scenario that declares no input parameter is still extractable', async () => {
|
|
111
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-noinput-'))
|
|
112
|
+
const scenarioFile = join(rootDir, 'fails.scenario.ts')
|
|
113
|
+
await writeFile(
|
|
114
|
+
scenarioFile,
|
|
115
|
+
[
|
|
116
|
+
"import { pikkuScenario } from '@pikku/core/workflow'",
|
|
117
|
+
'export const alwaysFails = pikkuScenario({',
|
|
118
|
+
" title: 'Always fails (test fixture)',",
|
|
119
|
+
" tags: ['scenario'],",
|
|
120
|
+
" func: async () => { throw new Error('always fails') },",
|
|
121
|
+
'})',
|
|
122
|
+
].join('\n')
|
|
123
|
+
)
|
|
124
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
125
|
+
const state = await inspect(makeLogger(criticals), [scenarioFile], {
|
|
126
|
+
rootDir,
|
|
127
|
+
})
|
|
128
|
+
try {
|
|
129
|
+
assert.ok(
|
|
130
|
+
state.workflows.meta.alwaysFails,
|
|
131
|
+
`a scenario that ignores its input must still register, got criticals: ${JSON.stringify(criticals)}`
|
|
132
|
+
)
|
|
133
|
+
assert.equal(
|
|
134
|
+
criticals.filter((c) => c.code === 'PKU679').length,
|
|
135
|
+
0,
|
|
136
|
+
`declaring no input parameter is legitimate, got: ${JSON.stringify(criticals)}`
|
|
137
|
+
)
|
|
138
|
+
} finally {
|
|
139
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('a complex workflow still falls back quietly — only scenarios are strict', async () => {
|
|
144
|
+
const { criticals, cleanup } = await run('pikkuWorkflowComplexFunc', [
|
|
145
|
+
'try {',
|
|
146
|
+
" await workflow.do('buy', 'buysAnApple', { qty: 1 })",
|
|
147
|
+
'} catch {}',
|
|
148
|
+
])
|
|
149
|
+
try {
|
|
150
|
+
assert.equal(
|
|
151
|
+
criticals.filter((c) => c.code === 'PKU679').length,
|
|
152
|
+
0,
|
|
153
|
+
`pikkuWorkflowComplexFunc legitimately falls back to basic extraction, got: ${JSON.stringify(criticals)}`
|
|
154
|
+
)
|
|
155
|
+
} finally {
|
|
156
|
+
await cleanup()
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
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 }) => {
|
|
18
|
+
criticals.push({ code, message })
|
|
19
|
+
},
|
|
20
|
+
critical: (code: any, message: string) => {
|
|
21
|
+
criticals.push({ code, message })
|
|
22
|
+
},
|
|
23
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const stepFile = (dir: string) => join(dir, 'my.steps.ts')
|
|
28
|
+
const stepSource = [
|
|
29
|
+
"import { pikkuSessionlessFunc } from '@pikku/core'",
|
|
30
|
+
'export const getTodos = pikkuSessionlessFunc({',
|
|
31
|
+
' func: async ({ logger }) => [{ done: true }],',
|
|
32
|
+
'})',
|
|
33
|
+
].join('\n')
|
|
34
|
+
|
|
35
|
+
describe('scenarios may only destructure logger/config', () => {
|
|
36
|
+
test('a scenario destructuring a real service is a PKU673 critical', async () => {
|
|
37
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-svc-'))
|
|
38
|
+
const scenarioFile = join(rootDir, 'my.scenario.ts')
|
|
39
|
+
await writeFile(stepFile(rootDir), stepSource)
|
|
40
|
+
await writeFile(
|
|
41
|
+
scenarioFile,
|
|
42
|
+
[
|
|
43
|
+
"import { pikkuScenario } from '@pikku/core/workflow'",
|
|
44
|
+
'declare const actors: Record<string, any>',
|
|
45
|
+
'export const badFlow = pikkuScenario(async ({ kysely }: any, _input, { workflow }) => {',
|
|
46
|
+
" await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
|
|
47
|
+
' return { ok: true }',
|
|
48
|
+
'})',
|
|
49
|
+
].join('\n')
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
53
|
+
try {
|
|
54
|
+
await inspect(makeLogger(criticals), [stepFile(rootDir), scenarioFile], {
|
|
55
|
+
rootDir,
|
|
56
|
+
})
|
|
57
|
+
const pku673 = criticals.find((c) => c.code === 'PKU673')
|
|
58
|
+
assert.ok(
|
|
59
|
+
pku673,
|
|
60
|
+
`expected a PKU673 critical for a scenario destructuring 'kysely', got: ${JSON.stringify(criticals)}`
|
|
61
|
+
)
|
|
62
|
+
assert.ok(
|
|
63
|
+
pku673.message.includes('kysely'),
|
|
64
|
+
`PKU673 should name the offending service, got: ${pku673.message}`
|
|
65
|
+
)
|
|
66
|
+
} finally {
|
|
67
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
test('a scenario destructuring only logger is allowed', async () => {
|
|
72
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-sc-ok-'))
|
|
73
|
+
const scenarioFile = join(rootDir, 'my.scenario.ts')
|
|
74
|
+
await writeFile(stepFile(rootDir), stepSource)
|
|
75
|
+
await writeFile(
|
|
76
|
+
scenarioFile,
|
|
77
|
+
[
|
|
78
|
+
"import { pikkuScenario } from '@pikku/core/workflow'",
|
|
79
|
+
'declare const actors: Record<string, any>',
|
|
80
|
+
'export const goodFlow = pikkuScenario(async ({ logger }, _input, { workflow }) => {',
|
|
81
|
+
" await workflow.do('Read todos', 'getTodos', {}, { actor: actors.pm })",
|
|
82
|
+
' return { ok: true }',
|
|
83
|
+
'})',
|
|
84
|
+
].join('\n')
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
const criticals: Array<{ code: string; message: string }> = []
|
|
88
|
+
try {
|
|
89
|
+
const state = await inspect(
|
|
90
|
+
makeLogger(criticals),
|
|
91
|
+
[stepFile(rootDir), scenarioFile],
|
|
92
|
+
{ rootDir }
|
|
93
|
+
)
|
|
94
|
+
assert.ok(
|
|
95
|
+
!criticals.some((c) => c.code === 'PKU673'),
|
|
96
|
+
`logger is allowed in a scenario; got: ${JSON.stringify(criticals)}`
|
|
97
|
+
)
|
|
98
|
+
assert.equal((state.workflows.meta as any).goodFlow?.scenario, true)
|
|
99
|
+
} finally {
|
|
100
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
101
|
+
}
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scenario can declare why it is held out of a default run. The reason has to
|
|
3
|
+
* reach the meta, because the runner reports skips from there — if it were
|
|
4
|
+
* dropped in extraction the scenario would silently run anyway, which is the
|
|
5
|
+
* opposite of what the author asked for.
|
|
6
|
+
*/
|
|
7
|
+
import { strict as assert } from 'assert'
|
|
8
|
+
import { describe, test } from 'node:test'
|
|
9
|
+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
10
|
+
import { tmpdir } from 'node:os'
|
|
11
|
+
import { join } from 'node:path'
|
|
12
|
+
import { inspect } from '../inspector.js'
|
|
13
|
+
import type { InspectorLogger } from '../types.js'
|
|
14
|
+
|
|
15
|
+
const logger = {
|
|
16
|
+
debug: () => {},
|
|
17
|
+
info: () => {},
|
|
18
|
+
warn: () => {},
|
|
19
|
+
error: () => {},
|
|
20
|
+
diagnostic: () => {},
|
|
21
|
+
critical: () => {},
|
|
22
|
+
hasCriticalErrors: () => false,
|
|
23
|
+
} as unknown as InspectorLogger
|
|
24
|
+
|
|
25
|
+
const STEPS = [
|
|
26
|
+
"import { pikkuScenarioStep } from '@pikku/core'",
|
|
27
|
+
'export const buysAnApple = pikkuScenarioStep({',
|
|
28
|
+
" name: 'buysAnApple',",
|
|
29
|
+
" description: 'buys an apple',",
|
|
30
|
+
' func: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
|
|
31
|
+
'})',
|
|
32
|
+
].join('\n')
|
|
33
|
+
|
|
34
|
+
const scenarioSource = (properties: string[]) =>
|
|
35
|
+
[
|
|
36
|
+
"import { pikkuScenario } from '@pikku/core/workflow'",
|
|
37
|
+
'export const shopFlow = pikkuScenario({',
|
|
38
|
+
...properties.map((line) => ` ${line}`),
|
|
39
|
+
' func: async (_services, _input, { scenario }: any) => {',
|
|
40
|
+
" await scenario.when('buys an apple', 'buysAnApple', { qty: 1 })",
|
|
41
|
+
' return { ok: true }',
|
|
42
|
+
' },',
|
|
43
|
+
'})',
|
|
44
|
+
].join('\n')
|
|
45
|
+
|
|
46
|
+
async function inspectScenario(properties: string[]) {
|
|
47
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-scenario-skip-'))
|
|
48
|
+
const stepsFile = join(rootDir, 'shop.steps.ts')
|
|
49
|
+
const scenarioFile = join(rootDir, 'shop.scenario.ts')
|
|
50
|
+
await writeFile(stepsFile, STEPS)
|
|
51
|
+
await writeFile(scenarioFile, scenarioSource(properties))
|
|
52
|
+
const state = await inspect(logger, [stepsFile, scenarioFile], { rootDir })
|
|
53
|
+
return {
|
|
54
|
+
meta: state.workflows.meta['shopFlow'] as any,
|
|
55
|
+
cleanup: () => rm(rootDir, { recursive: true, force: true }),
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe('scenario skip', () => {
|
|
60
|
+
test('a declared skip reason reaches the meta', async () => {
|
|
61
|
+
const { meta, cleanup } = await inspectScenario([
|
|
62
|
+
"title: 'Installs an addon',",
|
|
63
|
+
"skip: 'mutates the project — needs a fresh server',",
|
|
64
|
+
])
|
|
65
|
+
try {
|
|
66
|
+
assert.equal(meta.skip, 'mutates the project — needs a fresh server')
|
|
67
|
+
} finally {
|
|
68
|
+
await cleanup()
|
|
69
|
+
}
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
test('a scenario that declares none carries none', async () => {
|
|
73
|
+
const { meta, cleanup } = await inspectScenario([
|
|
74
|
+
"title: 'Installs an addon',",
|
|
75
|
+
])
|
|
76
|
+
try {
|
|
77
|
+
assert.equal(meta.skip, undefined)
|
|
78
|
+
assert.equal(meta.scenario, true)
|
|
79
|
+
} finally {
|
|
80
|
+
await cleanup()
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
})
|
package/src/error-codes.ts
CHANGED
|
@@ -22,6 +22,9 @@ export enum ErrorCode {
|
|
|
22
22
|
CLI_CLIENTSIDE_RENDERER_HAS_SERVICES = 'PKU672',
|
|
23
23
|
SCENARIO_HAS_SERVICES = 'PKU673',
|
|
24
24
|
EXPECT_EVENTUALLY_SCENARIO_ONLY = 'PKU675',
|
|
25
|
+
SCENARIO_BROWSER_STEP_NEEDS_ACTOR = 'PKU677',
|
|
26
|
+
SCENARIO_STEP_TARGET_NOT_STATIC = 'PKU678',
|
|
27
|
+
SCENARIO_NOT_EXTRACTABLE = 'PKU679',
|
|
25
28
|
WORKFLOW_ORCHESTRATOR_NOT_CONFIGURED = 'PKU600',
|
|
26
29
|
INVALID_DSL_WORKFLOW = 'PKU641',
|
|
27
30
|
WORKFLOW_GRAPH_ADDON_NOT_WIRED = 'PKU642',
|
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,
|
|
@@ -27,6 +32,7 @@ export type {
|
|
|
27
32
|
VersionManifest,
|
|
28
33
|
VersionManifestEntry,
|
|
29
34
|
} from './utils/contract-hashes.js'
|
|
35
|
+
export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
|
|
30
36
|
export { serializeMCPJson } from './utils/serialize-mcp-json.js'
|
|
31
37
|
export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
|
|
32
38
|
export {
|
package/src/inspector.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as ts from 'typescript'
|
|
2
2
|
import { performance } from 'perf_hooks'
|
|
3
|
-
import { resolve } from 'path'
|
|
3
|
+
import { dirname, resolve } from 'path'
|
|
4
4
|
import { visitSetup, visitFunctions, visitRoutes } from './visit.js'
|
|
5
5
|
import { TypesMap } from './types-map.js'
|
|
6
6
|
import type {
|
|
@@ -27,6 +27,8 @@ import {
|
|
|
27
27
|
computeRequiredSchemas,
|
|
28
28
|
computeDiagnostics,
|
|
29
29
|
validateSchemaWiringSeparation,
|
|
30
|
+
validateScenarioServices,
|
|
31
|
+
validateScenarioSteps,
|
|
30
32
|
validateWorkflowGraphAddons,
|
|
31
33
|
} from './utils/post-process.js'
|
|
32
34
|
import { generateOpenAPISpec } from './utils/serialize-openapi-json.js'
|
|
@@ -121,6 +123,7 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
121
123
|
files: new Map(),
|
|
122
124
|
graphMeta: {},
|
|
123
125
|
graphFiles: new Map(),
|
|
126
|
+
featureFiles: new Map(),
|
|
124
127
|
invokedWorkflows: new Set(),
|
|
125
128
|
},
|
|
126
129
|
rpc: {
|
|
@@ -240,6 +243,49 @@ export function getInitialInspectorState(rootDir: string): InspectorState {
|
|
|
240
243
|
}
|
|
241
244
|
}
|
|
242
245
|
|
|
246
|
+
/**
|
|
247
|
+
* The path mappings the inspector's program has to share with the project.
|
|
248
|
+
*
|
|
249
|
+
* The inspector builds its own program rather than reusing the project's, which
|
|
250
|
+
* is what keeps a cold run affordable — but a project that reaches its generated
|
|
251
|
+
* code through a tsconfig `paths` entry resolves that import through those
|
|
252
|
+
* mappings and nothing else. Without them the factory an inspected function is
|
|
253
|
+
* passed to resolves to `any`, so a function whose input type comes from the
|
|
254
|
+
* factory's contextual type (rather than an explicit generic or a named schema)
|
|
255
|
+
* silently records no input at all.
|
|
256
|
+
*
|
|
257
|
+
* Only resolution keys are copied. Type-inference options such as `strict` stay
|
|
258
|
+
* the inspector's own, because changing those would change the recorded types.
|
|
259
|
+
*/
|
|
260
|
+
const readProjectPathMappings = (
|
|
261
|
+
logger: InspectorLogger,
|
|
262
|
+
tsconfig: string | undefined
|
|
263
|
+
): ts.CompilerOptions => {
|
|
264
|
+
if (!tsconfig) {
|
|
265
|
+
return {}
|
|
266
|
+
}
|
|
267
|
+
const configPath = resolve(tsconfig)
|
|
268
|
+
const configFile = ts.readConfigFile(configPath, ts.sys.readFile)
|
|
269
|
+
if (configFile.error || !configFile.config) {
|
|
270
|
+
logger.debug(
|
|
271
|
+
`Could not read ${configPath} for path mappings; imports that rely on them will not resolve`
|
|
272
|
+
)
|
|
273
|
+
return {}
|
|
274
|
+
}
|
|
275
|
+
const { options: projectOptions } = ts.parseJsonConfigFileContent(
|
|
276
|
+
configFile.config,
|
|
277
|
+
ts.sys,
|
|
278
|
+
dirname(configPath)
|
|
279
|
+
)
|
|
280
|
+
const { baseUrl, paths, rootDirs, pathsBasePath } = projectOptions
|
|
281
|
+
return {
|
|
282
|
+
...(baseUrl !== undefined ? { baseUrl } : {}),
|
|
283
|
+
...(paths !== undefined ? { paths } : {}),
|
|
284
|
+
...(rootDirs !== undefined ? { rootDirs } : {}),
|
|
285
|
+
...(pathsBasePath !== undefined ? { pathsBasePath } : {}),
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
243
289
|
export const inspect = async (
|
|
244
290
|
logger: InspectorLogger,
|
|
245
291
|
routeFiles: string[],
|
|
@@ -247,6 +293,7 @@ export const inspect = async (
|
|
|
247
293
|
): Promise<InspectorState> => {
|
|
248
294
|
const normalizedRouteFiles = routeFiles.map((file) => resolve(file))
|
|
249
295
|
const compilerOptions: ts.CompilerOptions = {
|
|
296
|
+
...readProjectPathMappings(logger, options.tsconfig),
|
|
250
297
|
target: ts.ScriptTarget.ESNext,
|
|
251
298
|
module: ts.ModuleKind.Node16,
|
|
252
299
|
skipLibCheck: true,
|
|
@@ -407,6 +454,8 @@ export const inspect = async (
|
|
|
407
454
|
computeDiagnostics(state)
|
|
408
455
|
validateSchemaWiringSeparation(logger, state)
|
|
409
456
|
validateWorkflowGraphAddons(logger, state)
|
|
457
|
+
validateScenarioServices(logger, state)
|
|
458
|
+
validateScenarioSteps(logger, state)
|
|
410
459
|
|
|
411
460
|
if (options.openAPI) {
|
|
412
461
|
state.openAPISpec = await generateOpenAPISpec(
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { strict as assert } from 'assert'
|
|
2
|
+
import { describe, test } from 'node:test'
|
|
3
|
+
import { mkdir, 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
|
+
const logger: InspectorLogger = {
|
|
10
|
+
debug: () => {},
|
|
11
|
+
info: () => {},
|
|
12
|
+
warn: () => {},
|
|
13
|
+
error: () => {},
|
|
14
|
+
diagnostic: () => {},
|
|
15
|
+
critical: () => {},
|
|
16
|
+
hasCriticalErrors: () => false,
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const FACTORY = [
|
|
20
|
+
'export type Config = {',
|
|
21
|
+
' name: string',
|
|
22
|
+
' func: (services: any, data: { qty: number }) => Promise<{ ok: boolean }>',
|
|
23
|
+
'}',
|
|
24
|
+
'export const pikkuSessionlessFunc = (config: Config): Config => config',
|
|
25
|
+
].join('\n')
|
|
26
|
+
|
|
27
|
+
// The parameter is deliberately unannotated: its type can only come from the
|
|
28
|
+
// factory's contextual type, which is exactly what an unresolved import loses.
|
|
29
|
+
const funcSource = (specifier: string) =>
|
|
30
|
+
[
|
|
31
|
+
`import { pikkuSessionlessFunc } from '${specifier}'`,
|
|
32
|
+
'export const buysAnApple = pikkuSessionlessFunc({',
|
|
33
|
+
" name: 'buysAnApple',",
|
|
34
|
+
' func: async (_services, data) => ({ ok: data.qty > 0 }),',
|
|
35
|
+
'})',
|
|
36
|
+
].join('\n')
|
|
37
|
+
|
|
38
|
+
const makeProject = async (specifier: string) => {
|
|
39
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-tsconfig-paths-'))
|
|
40
|
+
await mkdir(join(rootDir, 'generated'))
|
|
41
|
+
await mkdir(join(rootDir, 'src'))
|
|
42
|
+
await writeFile(
|
|
43
|
+
join(rootDir, 'tsconfig.json'),
|
|
44
|
+
JSON.stringify({
|
|
45
|
+
compilerOptions: {
|
|
46
|
+
module: 'NodeNext',
|
|
47
|
+
moduleResolution: 'NodeNext',
|
|
48
|
+
paths: { '#gen/*': ['./generated/*'] },
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
)
|
|
52
|
+
await writeFile(
|
|
53
|
+
join(rootDir, 'package.json'),
|
|
54
|
+
JSON.stringify({ type: 'module' })
|
|
55
|
+
)
|
|
56
|
+
await writeFile(join(rootDir, 'generated', 'factory.ts'), FACTORY)
|
|
57
|
+
const funcFile = join(rootDir, 'src', 'apple.func.ts')
|
|
58
|
+
await writeFile(funcFile, funcSource(specifier))
|
|
59
|
+
return { rootDir, funcFile, tsconfig: join(rootDir, 'tsconfig.json') }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
describe('tsconfig path mappings', () => {
|
|
63
|
+
test('a relative import gives the function its input type', async () => {
|
|
64
|
+
const { rootDir, funcFile } = await makeProject('../generated/factory.js')
|
|
65
|
+
try {
|
|
66
|
+
const state = await inspect(logger, [funcFile], { rootDir })
|
|
67
|
+
const meta = state.functions.meta['buysAnApple']
|
|
68
|
+
assert.ok(meta, 'buysAnApple meta should exist')
|
|
69
|
+
assert.deepStrictEqual(meta!.inputs, ['BuysAnAppleInput'])
|
|
70
|
+
} finally {
|
|
71
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
test('an import that only resolves through tsconfig paths keeps its input type', async () => {
|
|
76
|
+
const { rootDir, funcFile, tsconfig } = await makeProject('#gen/factory.js')
|
|
77
|
+
try {
|
|
78
|
+
const state = await inspect(logger, [funcFile], { rootDir, tsconfig })
|
|
79
|
+
const meta = state.functions.meta['buysAnApple']
|
|
80
|
+
assert.ok(meta, 'buysAnApple meta should exist')
|
|
81
|
+
assert.deepStrictEqual(
|
|
82
|
+
meta!.inputs,
|
|
83
|
+
['BuysAnAppleInput'],
|
|
84
|
+
'without the project path mappings the factory resolves to `any`, and the input is silently dropped'
|
|
85
|
+
)
|
|
86
|
+
} finally {
|
|
87
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
88
|
+
}
|
|
89
|
+
})
|
|
90
|
+
})
|