@pikku/inspector 0.12.46 → 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 +197 -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 +98 -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.test.ts +36 -2
- package/src/utils/post-process.ts +111 -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,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
|
@@ -27,6 +27,7 @@ export type {
|
|
|
27
27
|
VersionManifest,
|
|
28
28
|
VersionManifestEntry,
|
|
29
29
|
} from './utils/contract-hashes.js'
|
|
30
|
+
export type { InspectorFeature, InspectorFeatureEntry } from './types.js'
|
|
30
31
|
export { serializeMCPJson } from './utils/serialize-mcp-json.js'
|
|
31
32
|
export type { OpenAPISpecInfo } from './utils/serialize-openapi-json.js'
|
|
32
33
|
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
|
+
})
|
package/src/types.ts
CHANGED
|
@@ -273,6 +273,12 @@ export type InspectorOptions = Partial<{
|
|
|
273
273
|
rootDir: string
|
|
274
274
|
isAddon: boolean
|
|
275
275
|
sourceFile: ts.SourceFile
|
|
276
|
+
/**
|
|
277
|
+
* The project's tsconfig, read for its path mappings only. The inspector's
|
|
278
|
+
* program needs them to resolve imports the project reaches through `paths`;
|
|
279
|
+
* unlike `schemaConfig.tsconfig` this is also needed on a setup-only run.
|
|
280
|
+
*/
|
|
281
|
+
tsconfig: string
|
|
276
282
|
types: Partial<{
|
|
277
283
|
configFileType: string
|
|
278
284
|
userSessionType: string
|
|
@@ -423,6 +429,36 @@ export interface AuthDefinition {
|
|
|
423
429
|
services: FunctionServicesMeta
|
|
424
430
|
}
|
|
425
431
|
|
|
432
|
+
/** One statically resolved entry of a feature's `scenarios` array. */
|
|
433
|
+
export interface InspectorFeatureEntry {
|
|
434
|
+
/** The scenario's declared export name, with any import alias followed back. */
|
|
435
|
+
scenario: string
|
|
436
|
+
/** A `{ scenario, data }` entry's literal data — gherkin's `Examples:`. */
|
|
437
|
+
data?: unknown
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** A `pikkuFeature` export, as far as it can be read from the source. */
|
|
441
|
+
export interface InspectorFeature {
|
|
442
|
+
path: string
|
|
443
|
+
exportedName: string
|
|
444
|
+
name?: string
|
|
445
|
+
description?: string
|
|
446
|
+
tags?: string[]
|
|
447
|
+
entries: InspectorFeatureEntry[]
|
|
448
|
+
/**
|
|
449
|
+
* Entries that are not literal — a spread, a `.map()`, a call. Counted rather
|
|
450
|
+
* than dropped so the console can say the listing is partial instead of
|
|
451
|
+
* quietly showing a short feature.
|
|
452
|
+
*/
|
|
453
|
+
unresolvedEntries: number
|
|
454
|
+
/**
|
|
455
|
+
* Feature hooks run once around the whole group, never per scenario, and are
|
|
456
|
+
* runtime-only — so only their presence is recorded.
|
|
457
|
+
*/
|
|
458
|
+
hasBefore: boolean
|
|
459
|
+
hasAfter: boolean
|
|
460
|
+
}
|
|
461
|
+
|
|
426
462
|
export interface InspectorState {
|
|
427
463
|
rootDir: string // Root directory inferred from source files
|
|
428
464
|
singletonServicesTypeImportMap: PathToNameAndType
|
|
@@ -466,6 +502,15 @@ export interface InspectorState {
|
|
|
466
502
|
files: Map<string, { path: string; exportedName: string }>
|
|
467
503
|
graphMeta: SerializedWorkflowGraphs
|
|
468
504
|
graphFiles: Map<string, { path: string; exportedName: string }>
|
|
505
|
+
/**
|
|
506
|
+
* `pikkuFeature` exports, keyed by export identifier.
|
|
507
|
+
*
|
|
508
|
+
* Everything the config states literally is extracted here, because a
|
|
509
|
+
* feature is the document structure the console renders. Entries built by
|
|
510
|
+
* an ordinary loop cannot be enumerated statically — those are counted in
|
|
511
|
+
* `unresolvedEntries` and resolved at runtime by object identity instead.
|
|
512
|
+
*/
|
|
513
|
+
featureFiles: Map<string, InspectorFeature>
|
|
469
514
|
invokedWorkflows: Set<string>
|
|
470
515
|
}
|
|
471
516
|
rpc: {
|
|
@@ -45,6 +45,40 @@ export function funcIdToTypeName(id: string): string {
|
|
|
45
45
|
.join('')
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/**
|
|
49
|
+
* `pikkuScenarioStep({ name })` is the one wrapper whose `name` is its identity:
|
|
50
|
+
* scenarios reference a step by that string, so it must become the pikkuFuncId
|
|
51
|
+
* (and hence the generated step map's key), not just a cosmetic label the way
|
|
52
|
+
* `pikkuMiddleware`/`pikkuAgent` use it.
|
|
53
|
+
*/
|
|
54
|
+
function extractScenarioStepName(
|
|
55
|
+
callExpr: ts.Node,
|
|
56
|
+
result: ExtractedFunctionName
|
|
57
|
+
): void {
|
|
58
|
+
if (
|
|
59
|
+
!ts.isCallExpression(callExpr) ||
|
|
60
|
+
!ts.isIdentifier(callExpr.expression) ||
|
|
61
|
+
callExpr.expression.text !== 'pikkuScenarioStep'
|
|
62
|
+
) {
|
|
63
|
+
return
|
|
64
|
+
}
|
|
65
|
+
const firstArg = callExpr.arguments[0]
|
|
66
|
+
if (!firstArg || !ts.isObjectLiteralExpression(firstArg)) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
for (const prop of firstArg.properties) {
|
|
70
|
+
if (
|
|
71
|
+
ts.isPropertyAssignment(prop) &&
|
|
72
|
+
ts.isIdentifier(prop.name) &&
|
|
73
|
+
prop.name.text === 'name' &&
|
|
74
|
+
ts.isStringLiteral(prop.initializer)
|
|
75
|
+
) {
|
|
76
|
+
result.explicitName = prop.initializer.text
|
|
77
|
+
return
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
48
82
|
export function extractFunctionName(
|
|
49
83
|
callExpr: ts.Node,
|
|
50
84
|
checker: ts.TypeChecker,
|
|
@@ -63,6 +97,8 @@ export function extractFunctionName(
|
|
|
63
97
|
version: null,
|
|
64
98
|
}
|
|
65
99
|
|
|
100
|
+
extractScenarioStepName(callExpr, result)
|
|
101
|
+
|
|
66
102
|
const workflowHelpers = new Set([
|
|
67
103
|
'workflow',
|
|
68
104
|
'workflowStart',
|
|
@@ -63,6 +63,7 @@ function makeState(
|
|
|
63
63
|
functionsMeta?: Record<string, any>
|
|
64
64
|
addonFunctions?: Record<string, Record<string, any>>
|
|
65
65
|
addonRequiredParentServices?: string[]
|
|
66
|
+
authServices?: string[]
|
|
66
67
|
} = {}
|
|
67
68
|
): Omit<InspectorState, 'typesLookup'> {
|
|
68
69
|
return {
|
|
@@ -99,6 +100,13 @@ function makeState(
|
|
|
99
100
|
rpc: { internalMeta: {}, exposedMeta: {} },
|
|
100
101
|
addonFunctions: overrides.addonFunctions ?? {},
|
|
101
102
|
addonRequiredParentServices: overrides.addonRequiredParentServices ?? [],
|
|
103
|
+
auth: overrides.authServices
|
|
104
|
+
? {
|
|
105
|
+
definition: {
|
|
106
|
+
services: { optimized: true, services: overrides.authServices },
|
|
107
|
+
},
|
|
108
|
+
}
|
|
109
|
+
: {},
|
|
102
110
|
} as unknown as Omit<InspectorState, 'typesLookup'>
|
|
103
111
|
}
|
|
104
112
|
|
|
@@ -306,6 +314,26 @@ describe('aggregateRequiredServices — per-function addon services', () => {
|
|
|
306
314
|
})
|
|
307
315
|
})
|
|
308
316
|
|
|
317
|
+
describe('aggregateRequiredServices — the auth factory’s own services', () => {
|
|
318
|
+
test('an auth definition contributes its services before its handler exists', () => {
|
|
319
|
+
// The state a clean checkout produces: the project declares
|
|
320
|
+
// `pikkuBetterAuth`, so the definition is inspected, but the handler the CLI
|
|
321
|
+
// generates from it has not been written yet — so nothing in functions.meta
|
|
322
|
+
// mentions the services `authorize` reads.
|
|
323
|
+
const state = makeState({ authServices: ['kysely', 'jwt'] })
|
|
324
|
+
aggregateRequiredServices(state)
|
|
325
|
+
const required = state.serviceAggregation.requiredServices
|
|
326
|
+
assert.ok(required.has('kysely'))
|
|
327
|
+
assert.ok(required.has('jwt'))
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
test('a project without auth is unaffected', () => {
|
|
331
|
+
const state = makeState()
|
|
332
|
+
aggregateRequiredServices(state)
|
|
333
|
+
assert.equal(state.serviceAggregation.requiredServices.size, 0)
|
|
334
|
+
})
|
|
335
|
+
})
|
|
336
|
+
|
|
309
337
|
describe('override validation resolves the override target (value), not the logical key', () => {
|
|
310
338
|
test('validateSecretOverrides accepts an override whose value is a declared secret', () => {
|
|
311
339
|
const { logger, criticals } = makeCriticalLogger()
|
|
@@ -422,7 +450,10 @@ describe('validateRemoteAddonDependencies (wireRemoteAddon must be a devDependen
|
|
|
422
450
|
})
|
|
423
451
|
)
|
|
424
452
|
assert.equal(criticals.length, 1)
|
|
425
|
-
assert.equal(
|
|
453
|
+
assert.equal(
|
|
454
|
+
criticals[0]!.code,
|
|
455
|
+
ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY
|
|
456
|
+
)
|
|
426
457
|
assert.match(criticals[0]!.message, /devDependencies/)
|
|
427
458
|
} finally {
|
|
428
459
|
rmSync(dir, { recursive: true, force: true })
|
|
@@ -441,7 +472,10 @@ describe('validateRemoteAddonDependencies (wireRemoteAddon must be a devDependen
|
|
|
441
472
|
})
|
|
442
473
|
)
|
|
443
474
|
assert.equal(criticals.length, 1)
|
|
444
|
-
assert.equal(
|
|
475
|
+
assert.equal(
|
|
476
|
+
criticals[0]!.code,
|
|
477
|
+
ErrorCode.REMOTE_ADDON_NOT_DEV_DEPENDENCY
|
|
478
|
+
)
|
|
445
479
|
} finally {
|
|
446
480
|
rmSync(dir, { recursive: true, force: true })
|
|
447
481
|
}
|