@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,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
|
+
}
|
package/src/add/add-functions.ts
CHANGED
|
@@ -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) => {
|
|
@@ -386,15 +387,27 @@ export const addFunctions: AddWiring = (
|
|
|
386
387
|
}
|
|
387
388
|
|
|
388
389
|
// Match identifiers that contain both "pikku" and "func" (case insensitive),
|
|
389
|
-
// plus pikkuScenario (
|
|
390
|
+
// plus pikkuScenario and pikkuScenarioStep (workflow wrappers without "func"
|
|
391
|
+
// in their names)
|
|
390
392
|
const pikkuFuncPattern = /pikku.*func/i
|
|
391
393
|
if (
|
|
392
394
|
!pikkuFuncPattern.test(expression.text) &&
|
|
393
|
-
expression.text !== 'pikkuScenario'
|
|
395
|
+
expression.text !== 'pikkuScenario' &&
|
|
396
|
+
expression.text !== 'pikkuScenarioStep'
|
|
394
397
|
) {
|
|
395
398
|
return
|
|
396
399
|
}
|
|
397
400
|
|
|
401
|
+
// A scenario step is bundled like any invoked function, but is deliberately
|
|
402
|
+
// never RPC-registered: a step may drive a browser, so exposing it over the
|
|
403
|
+
// network would put a test harness on the public API surface.
|
|
404
|
+
const isScenarioStep = expression.text === 'pikkuScenarioStep'
|
|
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
|
+
|
|
398
411
|
// only handle calls like pikkuFunc(...)
|
|
399
412
|
if (!ts.isIdentifier(expression) || !expression.text.startsWith('pikku')) {
|
|
400
413
|
return
|
|
@@ -412,6 +425,17 @@ export const addFunctions: AddWiring = (
|
|
|
412
425
|
return
|
|
413
426
|
}
|
|
414
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
|
+
|
|
415
439
|
let title: string | undefined
|
|
416
440
|
let tags: string[] | undefined
|
|
417
441
|
let summary: string | undefined
|
|
@@ -427,6 +451,8 @@ export const addFunctions: AddWiring = (
|
|
|
427
451
|
let workflowQueued: boolean | undefined
|
|
428
452
|
let workflowRetries: number | undefined
|
|
429
453
|
let workflowTimeout: string | undefined
|
|
454
|
+
let scenarioStepBrowser: boolean | undefined
|
|
455
|
+
let scenarioStepTemplate: string | undefined
|
|
430
456
|
let scopes: string[] | undefined
|
|
431
457
|
let version: number | undefined
|
|
432
458
|
let objectNode: ts.ObjectLiteralExpression | undefined
|
|
@@ -532,6 +558,12 @@ export const addFunctions: AddWiring = (
|
|
|
532
558
|
workflowTimeout = getPropertyValue(firstArg, 'workflowTimeout') as
|
|
533
559
|
| string
|
|
534
560
|
| undefined
|
|
561
|
+
scenarioStepBrowser = getPropertyValue(firstArg, 'browser') as
|
|
562
|
+
| boolean
|
|
563
|
+
| undefined
|
|
564
|
+
scenarioStepTemplate = getPropertyValue(firstArg, 'template') as
|
|
565
|
+
| string
|
|
566
|
+
| undefined
|
|
535
567
|
scopes = getArrayPropertyValue(firstArg, 'scopes') ?? undefined
|
|
536
568
|
|
|
537
569
|
// Extract approvalDescription identifier reference
|
|
@@ -1078,6 +1110,8 @@ export const addFunctions: AddWiring = (
|
|
|
1078
1110
|
outputs: outputNames.filter((n) => n !== 'void') ?? null,
|
|
1079
1111
|
expose: expose || undefined,
|
|
1080
1112
|
remote: remote || undefined,
|
|
1113
|
+
scenarioStep: isScenarioStep || undefined,
|
|
1114
|
+
scenario: isScenario || undefined,
|
|
1081
1115
|
mcp: mcpEnabled || undefined,
|
|
1082
1116
|
readonly: readonly_ || undefined,
|
|
1083
1117
|
deploy: deploy || undefined,
|
|
@@ -1086,6 +1120,8 @@ export const addFunctions: AddWiring = (
|
|
|
1086
1120
|
workflowQueued: workflowQueued === true ? true : undefined,
|
|
1087
1121
|
workflowRetries: workflowRetries ?? undefined,
|
|
1088
1122
|
workflowTimeout: workflowTimeout ?? undefined,
|
|
1123
|
+
scenarioStepBrowser: scenarioStepBrowser === true ? true : undefined,
|
|
1124
|
+
scenarioStepTemplate: scenarioStepTemplate || undefined,
|
|
1089
1125
|
scopes: scopes ?? undefined,
|
|
1090
1126
|
implementationHash,
|
|
1091
1127
|
version,
|
|
@@ -1182,7 +1218,7 @@ export const addFunctions: AddWiring = (
|
|
|
1182
1218
|
return
|
|
1183
1219
|
}
|
|
1184
1220
|
|
|
1185
|
-
if (remote) {
|
|
1221
|
+
if (remote && !isScenarioStep) {
|
|
1186
1222
|
state.rpc.invokedFunctions.add(pikkuFuncId)
|
|
1187
1223
|
// The consumer-facing surface a wireRemoteAddon imports (mirrors exposedMeta)
|
|
1188
1224
|
state.rpc.remoteMeta[name] = pikkuFuncId
|
|
@@ -1192,7 +1228,7 @@ export const addFunctions: AddWiring = (
|
|
|
1192
1228
|
})
|
|
1193
1229
|
}
|
|
1194
1230
|
|
|
1195
|
-
if (expose) {
|
|
1231
|
+
if (expose && !isScenarioStep) {
|
|
1196
1232
|
state.rpc.exposedMeta[name] = pikkuFuncId
|
|
1197
1233
|
state.rpc.exposedFiles.set(name, {
|
|
1198
1234
|
path: node.getSourceFile().fileName,
|
|
@@ -1202,12 +1238,14 @@ export const addFunctions: AddWiring = (
|
|
|
1202
1238
|
state.serviceAggregation.usedFunctions.add(pikkuFuncId)
|
|
1203
1239
|
}
|
|
1204
1240
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1241
|
+
if (!isScenarioStep) {
|
|
1242
|
+
// We add it to internal meta to allow autocomplete for everything
|
|
1243
|
+
state.rpc.internalMeta[name] = pikkuFuncId
|
|
1207
1244
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1245
|
+
if (version !== undefined) {
|
|
1246
|
+
state.rpc.internalMeta[pikkuFuncId] = pikkuFuncId
|
|
1247
|
+
state.rpc.invokedFunctions.add(pikkuFuncId)
|
|
1248
|
+
}
|
|
1211
1249
|
}
|
|
1212
1250
|
|
|
1213
1251
|
// But we only import the actual function if it's actually invoked to keep
|