@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.
- package/CHANGELOG.md +185 -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 +24 -9
- package/dist/add/add-workflow.js +22 -29
- package/dist/error-codes.d.ts +3 -0
- package/dist/error-codes.js +3 -0
- package/dist/index.d.ts +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 +85 -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 +29 -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-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 +1 -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.ts +97 -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
|
@@ -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 (
|
|
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
|
-
|
|
1206
|
-
|
|
1223
|
+
if (!isScenarioStep) {
|
|
1224
|
+
// We add it to internal meta to allow autocomplete for everything
|
|
1225
|
+
state.rpc.internalMeta[name] = pikkuFuncId
|
|
1207
1226
|
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
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
|