@pikku/inspector 0.12.50 → 0.12.52
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 +441 -0
- package/OPTIMIZATION-PLAN.md +9 -3
- package/dist/add/add-auth.d.ts +1 -1
- package/dist/add/add-auth.js +1 -1
- package/dist/add/add-cli.js +7 -0
- package/dist/add/add-credential.js +1 -1
- package/dist/add/add-functions.js +156 -7
- package/dist/add/add-http-route.js +16 -6
- package/dist/add/add-middleware.js +46 -3
- package/dist/add/add-personas.d.ts +13 -0
- package/dist/add/add-personas.js +210 -0
- package/dist/add/add-scope.js +1 -1
- package/dist/add/add-secret.js +1 -1
- package/dist/add/add-system-role.d.ts +2 -0
- package/dist/add/add-system-role.js +102 -0
- package/dist/add/add-variable.js +1 -1
- package/dist/add/add-wire-addon.js +31 -0
- package/dist/error-codes.d.ts +10 -1
- package/dist/error-codes.js +11 -0
- package/dist/index.d.ts +1 -1
- package/dist/inspector.js +33 -1
- package/dist/types.d.ts +46 -2
- package/dist/utils/annotate-http-route-auth.d.ts +22 -0
- package/dist/utils/annotate-http-route-auth.js +48 -0
- package/dist/utils/extract-function-name.js +13 -4
- package/dist/utils/extract-function-node.js +18 -1
- package/dist/utils/extract-secret-usage.d.ts +9 -0
- package/dist/utils/extract-secret-usage.js +61 -0
- package/dist/utils/literal-properties.d.ts +22 -0
- package/dist/utils/literal-properties.js +69 -0
- package/dist/utils/load-addon-functions-meta.js +26 -1
- package/dist/utils/permissions.js +8 -0
- package/dist/utils/post-process.d.ts +68 -3
- package/dist/utils/post-process.js +243 -13
- package/dist/utils/secret-alias-services.d.ts +8 -0
- package/dist/utils/secret-alias-services.js +45 -0
- package/dist/utils/secret-brokers.d.ts +2 -0
- package/dist/utils/secret-brokers.js +9 -0
- package/dist/utils/serialize-inspector-state.d.ts +10 -0
- package/dist/utils/serialize-inspector-state.js +18 -0
- package/dist/utils/serialize-middleware-groups-meta.js +3 -0
- package/dist/utils/validate-exposed-functions-gated.d.ts +14 -0
- package/dist/utils/validate-exposed-functions-gated.js +85 -0
- package/dist/utils/validate-tags-resolve-to-middleware.d.ts +20 -0
- package/dist/utils/validate-tags-resolve-to-middleware.js +41 -0
- package/dist/visit.js +4 -0
- package/package.json +3 -3
- package/src/add/add-auth.test.ts +5 -3
- package/src/add/add-auth.ts +1 -1
- package/src/add/add-cli.ts +8 -0
- package/src/add/add-credential.ts +1 -1
- package/src/add/add-functions.test.ts +263 -1
- package/src/add/add-functions.ts +219 -9
- package/src/add/add-http-route.ts +20 -7
- package/src/add/add-middleware.test.ts +196 -0
- package/src/add/add-middleware.ts +64 -4
- package/src/add/add-personas.test.ts +268 -0
- package/src/add/add-personas.ts +276 -0
- package/src/add/add-scenario-step.test.ts +49 -3
- package/src/add/add-scope.test.ts +30 -30
- package/src/add/add-scope.ts +1 -1
- package/src/add/add-secret.ts +1 -1
- package/src/add/add-subject-scenario-steps.test.ts +161 -0
- package/src/add/add-system-role.test.ts +214 -0
- package/src/add/add-system-role.ts +164 -0
- package/src/add/add-variable.ts +1 -1
- package/src/add/add-wire-addon.test.ts +111 -0
- package/src/add/add-wire-addon.ts +29 -0
- package/src/add/addon-bans.test.ts +4 -4
- package/src/add/config-value-metadata.test.ts +11 -11
- package/src/add/wire-name-literal.test.ts +2 -2
- package/src/error-codes.ts +13 -0
- package/src/index.ts +4 -1
- package/src/inspector.ts +39 -0
- package/src/types.ts +47 -2
- package/src/utils/annotate-http-route-auth.test.ts +163 -0
- package/src/utils/annotate-http-route-auth.ts +52 -0
- package/src/utils/extract-function-name.ts +14 -4
- package/src/utils/extract-function-node.ts +18 -6
- package/src/utils/extract-secret-usage.test.ts +81 -0
- package/src/utils/extract-secret-usage.ts +73 -0
- package/src/utils/literal-properties.ts +97 -0
- package/src/utils/load-addon-functions-meta.ts +32 -2
- package/src/utils/permissions-shorthand.test.ts +79 -0
- package/src/utils/permissions.ts +7 -0
- package/src/utils/post-process.test.ts +133 -0
- package/src/utils/post-process.ts +310 -12
- package/src/utils/secret-alias-services.test.ts +146 -0
- package/src/utils/secret-alias-services.ts +58 -0
- package/src/utils/secret-brokers.test.ts +53 -0
- package/src/utils/secret-brokers.ts +11 -0
- package/src/utils/serialize-inspector-state.ts +28 -0
- package/src/utils/serialize-middleware-groups-meta.ts +3 -0
- package/src/utils/validate-exposed-functions-gated.test.ts +230 -0
- package/src/utils/validate-exposed-functions-gated.ts +94 -0
- package/src/utils/validate-tags-resolve-to-middleware.test.ts +105 -0
- package/src/utils/validate-tags-resolve-to-middleware.ts +48 -0
- package/src/visit.ts +4 -0
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,268 @@
|
|
|
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 { ErrorCode } from '../error-codes.js'
|
|
8
|
+
import type { InspectorLogger } from '../types.js'
|
|
9
|
+
|
|
10
|
+
const makeLogger = (criticals: Array<{ code: ErrorCode; message: string }>) =>
|
|
11
|
+
({
|
|
12
|
+
debug: () => {},
|
|
13
|
+
info: () => {},
|
|
14
|
+
warn: () => {},
|
|
15
|
+
error: () => {},
|
|
16
|
+
diagnostic: ({ code, message }) => {
|
|
17
|
+
criticals.push({ code, message })
|
|
18
|
+
},
|
|
19
|
+
critical: (code: ErrorCode, message: string) => {
|
|
20
|
+
criticals.push({ code, message })
|
|
21
|
+
},
|
|
22
|
+
hasCriticalErrors: () => criticals.length > 0,
|
|
23
|
+
}) satisfies InspectorLogger
|
|
24
|
+
|
|
25
|
+
const inspectSource = async (source: string) => {
|
|
26
|
+
const rootDir = await mkdtemp(join(tmpdir(), 'pikku-add-personas-'))
|
|
27
|
+
const file = join(rootDir, 'personas.ts')
|
|
28
|
+
await writeFile(file, source)
|
|
29
|
+
const criticals: Array<{ code: ErrorCode; message: string }> = []
|
|
30
|
+
try {
|
|
31
|
+
const state = await inspect(makeLogger(criticals), [file], { rootDir })
|
|
32
|
+
return { state, criticals }
|
|
33
|
+
} finally {
|
|
34
|
+
await rm(rootDir, { recursive: true, force: true })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The declared world every persona below is checked against. */
|
|
39
|
+
const withRoles = (...lines: string[]) =>
|
|
40
|
+
[
|
|
41
|
+
"import { defineScope } from '@pikku/core/scope'",
|
|
42
|
+
"import { defineSystemRole } from '@pikku/core/role'",
|
|
43
|
+
"import { definePersonas } from '@pikku/core/persona'",
|
|
44
|
+
'defineScope({ catalogue: { scopes: { read: {} } }, admin: {} })',
|
|
45
|
+
'defineSystemRole({',
|
|
46
|
+
" buyer: { scopes: ['catalogue:read'] },",
|
|
47
|
+
" admin: { scopes: ['admin'] },",
|
|
48
|
+
'})',
|
|
49
|
+
...lines,
|
|
50
|
+
].join('\n')
|
|
51
|
+
|
|
52
|
+
describe('addPersonas inspector', () => {
|
|
53
|
+
test('extracts a whole person', async () => {
|
|
54
|
+
const { state, criticals } = await inspectSource(
|
|
55
|
+
withRoles(
|
|
56
|
+
'definePersonas({',
|
|
57
|
+
' susan: {',
|
|
58
|
+
" name: 'Susan',",
|
|
59
|
+
" jobTitle: 'Buys for a small café',",
|
|
60
|
+
" roles: ['buyer'],",
|
|
61
|
+
" personality: 'Hunts cheap deals.',",
|
|
62
|
+
" goals: ['Get the weekly order in under five minutes'],",
|
|
63
|
+
" tags: ['commerce'],",
|
|
64
|
+
" disposition: 'careless',",
|
|
65
|
+
' tuning: { repeatRate: 0.3 },',
|
|
66
|
+
" fixtures: ['seedCatalogue'],",
|
|
67
|
+
' account: {},',
|
|
68
|
+
' },',
|
|
69
|
+
'})'
|
|
70
|
+
)
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
assert.deepEqual(criticals, [])
|
|
74
|
+
assert.equal(state.personas.definitions.length, 1)
|
|
75
|
+
const susan = state.personas.definitions[0]!
|
|
76
|
+
assert.equal(susan.id, 'susan')
|
|
77
|
+
assert.equal(susan.name, 'Susan')
|
|
78
|
+
assert.equal(susan.jobTitle, 'Buys for a small café')
|
|
79
|
+
assert.deepEqual(susan.roles, ['buyer'])
|
|
80
|
+
assert.equal(susan.personality, 'Hunts cheap deals.')
|
|
81
|
+
assert.deepEqual(susan.goals, [
|
|
82
|
+
'Get the weekly order in under five minutes',
|
|
83
|
+
])
|
|
84
|
+
assert.deepEqual(susan.tags, ['commerce'])
|
|
85
|
+
assert.equal(susan.disposition, 'careless')
|
|
86
|
+
assert.deepEqual(susan.tuning, { repeatRate: 0.3 })
|
|
87
|
+
assert.deepEqual(susan.fixtures, ['seedCatalogue'])
|
|
88
|
+
assert.deepEqual(susan.account, {})
|
|
89
|
+
assert.equal(susan.runnable, true)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
test('extracts the environments a persona declares', async () => {
|
|
93
|
+
const { state } = await inspectSource(
|
|
94
|
+
withRoles(
|
|
95
|
+
'definePersonas({',
|
|
96
|
+
' robin: {',
|
|
97
|
+
" name: 'Robin',",
|
|
98
|
+
" disposition: 'accountable',",
|
|
99
|
+
" environments: ['staging', 'prod'],",
|
|
100
|
+
' },',
|
|
101
|
+
'})'
|
|
102
|
+
)
|
|
103
|
+
)
|
|
104
|
+
assert.deepEqual(state.personas.definitions[0]!.environments, [
|
|
105
|
+
'staging',
|
|
106
|
+
'prod',
|
|
107
|
+
])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
// "Everywhere but production" is a fact about the config, and the meta file
|
|
111
|
+
// outlives whichever environments were configured the day it was written —
|
|
112
|
+
// so the absence is preserved rather than resolved into a list here.
|
|
113
|
+
test('leaves environments absent when none are declared', async () => {
|
|
114
|
+
const { state } = await inspectSource(
|
|
115
|
+
withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
|
|
116
|
+
)
|
|
117
|
+
assert.equal(state.personas.definitions[0]!.environments, undefined)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('the declaration key is the id', async () => {
|
|
121
|
+
const { state } = await inspectSource(
|
|
122
|
+
withRoles(
|
|
123
|
+
'definePersonas({',
|
|
124
|
+
" susan: { name: 'Susan' },",
|
|
125
|
+
" mallory: { name: 'Mallory' },",
|
|
126
|
+
'})'
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
assert.deepEqual(
|
|
130
|
+
state.personas.definitions.map((p) => p.id),
|
|
131
|
+
['susan', 'mallory']
|
|
132
|
+
)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('a persona without a name is refused', async () => {
|
|
136
|
+
const { state, criticals } = await inspectSource(
|
|
137
|
+
withRoles('definePersonas({', " susan: { roles: ['buyer'] },", '})')
|
|
138
|
+
)
|
|
139
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
140
|
+
assert.ok(criticals.some((c) => /needs a literal 'name'/.test(c.message)))
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test('an unknown disposition is refused rather than defaulted', async () => {
|
|
144
|
+
const { state, criticals } = await inspectSource(
|
|
145
|
+
withRoles(
|
|
146
|
+
'definePersonas({',
|
|
147
|
+
" susan: { name: 'Susan', disposition: 'grumpy' },",
|
|
148
|
+
'})'
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
152
|
+
assert.ok(
|
|
153
|
+
criticals.some((c) => /unknown disposition 'grumpy'/.test(c.message))
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
// `repeatRate: 18` meaning 18% would silently double every call, and the run
|
|
158
|
+
// would read as a product bug.
|
|
159
|
+
test('a tuning dial outside its range is refused', async () => {
|
|
160
|
+
const { state, criticals } = await inspectSource(
|
|
161
|
+
withRoles(
|
|
162
|
+
'definePersonas({',
|
|
163
|
+
" susan: { name: 'Susan', tuning: { repeatRate: 18 } },",
|
|
164
|
+
'})'
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
assert.equal(state.personas.definitions.length, 0)
|
|
168
|
+
assert.ok(
|
|
169
|
+
criticals.some((c) =>
|
|
170
|
+
/repeatRate must be between 0 and 1/.test(c.message)
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
test('a linked account is extracted alongside the primary one', async () => {
|
|
176
|
+
const { state, criticals } = await inspectSource(
|
|
177
|
+
withRoles(
|
|
178
|
+
'definePersonas({',
|
|
179
|
+
' yasser: {',
|
|
180
|
+
" name: 'Yasser',",
|
|
181
|
+
' account: {},',
|
|
182
|
+
" linkedAccounts: { google: { provider: 'google' } },",
|
|
183
|
+
' },',
|
|
184
|
+
'})'
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
assert.deepEqual(criticals, [])
|
|
188
|
+
const yasser = state.personas.definitions[0]!
|
|
189
|
+
assert.deepEqual(yasser.account, {})
|
|
190
|
+
assert.deepEqual(yasser.linkedAccounts, { google: { provider: 'google' } })
|
|
191
|
+
// Their primary login is email+password, so they can still be driven.
|
|
192
|
+
assert.equal(yasser.runnable, true)
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
// Driving a consent screen needs a human — refused at the CLI rather than
|
|
196
|
+
// failing somewhere inside a browser.
|
|
197
|
+
test('a persona whose own login is a provider is not runnable', async () => {
|
|
198
|
+
const { state } = await inspectSource(
|
|
199
|
+
withRoles(
|
|
200
|
+
'definePersonas({',
|
|
201
|
+
" yasser: { name: 'Yasser', account: { provider: 'google' } },",
|
|
202
|
+
'})'
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
assert.equal(state.personas.definitions[0]!.runnable, false)
|
|
206
|
+
})
|
|
207
|
+
|
|
208
|
+
test('a person who is only ever acted upon says so', async () => {
|
|
209
|
+
const { state } = await inspectSource(
|
|
210
|
+
withRoles(
|
|
211
|
+
'definePersonas({',
|
|
212
|
+
" target: { name: 'Target', account: {}, runnable: false },",
|
|
213
|
+
'})'
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
assert.equal(state.personas.definitions[0]!.runnable, false)
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
// A half-read roles array typechecks, runs, and grants less than the source
|
|
220
|
+
// says — so an unreadable one reads as absent.
|
|
221
|
+
test('a computed roles array is not half-extracted', async () => {
|
|
222
|
+
const { state } = await inspectSource(
|
|
223
|
+
withRoles(
|
|
224
|
+
'const r = "buyer"',
|
|
225
|
+
'definePersonas({',
|
|
226
|
+
" susan: { name: 'Susan', roles: ['buyer', r] },",
|
|
227
|
+
'})'
|
|
228
|
+
)
|
|
229
|
+
)
|
|
230
|
+
assert.deepEqual(state.personas.definitions[0]!.roles, [])
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
describe('validatePersonaRoles', () => {
|
|
235
|
+
test('a declared system role is accepted', async () => {
|
|
236
|
+
const { criticals } = await inspectSource(
|
|
237
|
+
withRoles(
|
|
238
|
+
'definePersonas({',
|
|
239
|
+
" susan: { name: 'Susan', roles: ['buyer', 'admin'] },",
|
|
240
|
+
'})'
|
|
241
|
+
)
|
|
242
|
+
)
|
|
243
|
+
assert.deepEqual(criticals, [])
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
// The failure this prevents: an admin deletes 'invoicing-clerk' from the
|
|
247
|
+
// console, and Susan goes on claiming to test it while 403ing everywhere.
|
|
248
|
+
test('a role that is not declared fails the build', async () => {
|
|
249
|
+
const { criticals } = await inspectSource(
|
|
250
|
+
withRoles(
|
|
251
|
+
'definePersonas({',
|
|
252
|
+
" susan: { name: 'Susan', roles: ['invoicing-clerk'] },",
|
|
253
|
+
'})'
|
|
254
|
+
)
|
|
255
|
+
)
|
|
256
|
+
const critical = criticals.find((c) => /invoicing-clerk/.test(c.message))
|
|
257
|
+
assert.ok(critical, `expected a critical, got ${JSON.stringify(criticals)}`)
|
|
258
|
+
assert.match(critical!.message, /may only name a system role/)
|
|
259
|
+
assert.match(critical!.message, /admin, buyer/)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
test('a persona with no roles at all is fine', async () => {
|
|
263
|
+
const { criticals } = await inspectSource(
|
|
264
|
+
withRoles('definePersonas({', " susan: { name: 'Susan' },", '})')
|
|
265
|
+
)
|
|
266
|
+
assert.deepEqual(criticals, [])
|
|
267
|
+
})
|
|
268
|
+
})
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import * as ts from 'typescript'
|
|
2
|
+
import { DISPOSITIONS } from '@pikku/core/virtual-user'
|
|
3
|
+
import { isRunnablePersona } from '@pikku/core/persona'
|
|
4
|
+
import type {
|
|
5
|
+
PersonaAccountMeta,
|
|
6
|
+
PersonaMeta,
|
|
7
|
+
} from '@pikku/core/persona'
|
|
8
|
+
import { ErrorCode } from '../error-codes.js'
|
|
9
|
+
import type { AddWiring, InspectorLogger } from '../types.js'
|
|
10
|
+
import {
|
|
11
|
+
booleanProperty,
|
|
12
|
+
getProperty,
|
|
13
|
+
numberProperty,
|
|
14
|
+
stringArrayProperty,
|
|
15
|
+
stringProperty,
|
|
16
|
+
unwrapAs,
|
|
17
|
+
} from '../utils/literal-properties.js'
|
|
18
|
+
|
|
19
|
+
type Tuning = NonNullable<PersonaMeta['tuning']>
|
|
20
|
+
|
|
21
|
+
/** A dial and the range outside which it is a mistake rather than a choice. */
|
|
22
|
+
const RATES: [keyof Tuning, number, number][] = [
|
|
23
|
+
['temperature', 0, 2],
|
|
24
|
+
['repeatRate', 0, 1],
|
|
25
|
+
['reReadRate', 0, 1],
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* `{ moves, temperature, repeatRate, … }` — a disposition's dials, overridden.
|
|
30
|
+
*
|
|
31
|
+
* The ranges are checked here rather than at run time because these are the
|
|
32
|
+
* kind of typo that does not throw: `repeatRate: 18` meaning 18% would silently
|
|
33
|
+
* double every call the persona makes, and you would read the run as a product
|
|
34
|
+
* bug.
|
|
35
|
+
*/
|
|
36
|
+
const tuningProperty = (
|
|
37
|
+
config: ts.ObjectLiteralExpression,
|
|
38
|
+
onError: (message: string) => void
|
|
39
|
+
): Tuning | undefined => {
|
|
40
|
+
const value = getProperty(config, 'tuning')
|
|
41
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
42
|
+
return undefined
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const tuning: Tuning = {}
|
|
46
|
+
|
|
47
|
+
const movesValue = getProperty(value, 'moves')
|
|
48
|
+
if (movesValue && ts.isObjectLiteralExpression(movesValue)) {
|
|
49
|
+
const moves: NonNullable<Tuning['moves']> = {}
|
|
50
|
+
for (const move of ['continue', 'suspend', 'resume', 'abandon'] as const) {
|
|
51
|
+
const weight = numberProperty(movesValue, move)
|
|
52
|
+
if (weight === undefined) {
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
55
|
+
if (weight < 0) {
|
|
56
|
+
onError(`tuning.moves.${move} must not be negative (got ${weight}).`)
|
|
57
|
+
continue
|
|
58
|
+
}
|
|
59
|
+
moves[move] = weight
|
|
60
|
+
}
|
|
61
|
+
if (Object.keys(moves).length) {
|
|
62
|
+
tuning.moves = moves
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
for (const [name, min, max] of RATES) {
|
|
67
|
+
const rate = numberProperty(value, name)
|
|
68
|
+
if (rate === undefined) {
|
|
69
|
+
continue
|
|
70
|
+
}
|
|
71
|
+
if (rate < min || rate > max) {
|
|
72
|
+
onError(`tuning.${name} must be between ${min} and ${max} (got ${rate}).`)
|
|
73
|
+
continue
|
|
74
|
+
}
|
|
75
|
+
Object.assign(tuning, { [name]: rate })
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
for (const flag of ['emptyMemory', 'readOnly', 'invertedOracle'] as const) {
|
|
79
|
+
const declared = booleanProperty(value, flag)
|
|
80
|
+
if (declared !== undefined) {
|
|
81
|
+
tuning[flag] = declared
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const instructions = stringProperty(value, 'instructions')
|
|
86
|
+
if (instructions !== undefined) {
|
|
87
|
+
tuning.instructions = instructions
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return Object.keys(tuning).length ? tuning : undefined
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const accountProperty = (
|
|
94
|
+
value: ts.Expression | undefined
|
|
95
|
+
): PersonaAccountMeta | undefined => {
|
|
96
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
97
|
+
return undefined
|
|
98
|
+
}
|
|
99
|
+
const provider = stringProperty(value, 'provider')
|
|
100
|
+
return provider === undefined ? {} : { provider }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const linkedAccountsProperty = (
|
|
104
|
+
config: ts.ObjectLiteralExpression,
|
|
105
|
+
personaId: string,
|
|
106
|
+
logger: InspectorLogger
|
|
107
|
+
): Record<string, PersonaAccountMeta> | undefined => {
|
|
108
|
+
const value = getProperty(config, 'linkedAccounts')
|
|
109
|
+
if (!value || !ts.isObjectLiteralExpression(value)) {
|
|
110
|
+
return undefined
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const accounts: Record<string, PersonaAccountMeta> = {}
|
|
114
|
+
for (const prop of value.properties) {
|
|
115
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
|
|
119
|
+
logger.critical(
|
|
120
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
121
|
+
`Persona '${personaId}' has a linked account whose key is not a literal.`
|
|
122
|
+
)
|
|
123
|
+
continue
|
|
124
|
+
}
|
|
125
|
+
const account = accountProperty(prop.initializer)
|
|
126
|
+
if (!account) {
|
|
127
|
+
logger.critical(
|
|
128
|
+
ErrorCode.INVALID_VALUE,
|
|
129
|
+
`Persona '${personaId}' linked account '${prop.name.text}' must be an object literal.`
|
|
130
|
+
)
|
|
131
|
+
continue
|
|
132
|
+
}
|
|
133
|
+
accounts[prop.name.text] = account
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return Object.keys(accounts).length ? accounts : undefined
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Inspector for `definePersonas()` calls.
|
|
141
|
+
*
|
|
142
|
+
* A persona is entirely declaration — a person, their roles, what they want —
|
|
143
|
+
* so there is no code body and nothing to resolve at runtime. What is read here
|
|
144
|
+
* is the whole thing, and it is generated straight to `personas.gen.json` for
|
|
145
|
+
* the CLI and the console to read without loading the app.
|
|
146
|
+
*
|
|
147
|
+
* `name` is the one field that cannot be defaulted. A persona without one is
|
|
148
|
+
* not a person, and the console has nothing to show.
|
|
149
|
+
*/
|
|
150
|
+
export const addPersonas: AddWiring = (logger, node, _checker, state) => {
|
|
151
|
+
if (!ts.isCallExpression(node)) {
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const expression = node.expression
|
|
156
|
+
if (!ts.isIdentifier(expression) || expression.text !== 'definePersonas') {
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const firstArg = node.arguments[0]
|
|
161
|
+
if (!firstArg) {
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const unwrapped = unwrapAs(firstArg)
|
|
166
|
+
if (!ts.isObjectLiteralExpression(unwrapped)) {
|
|
167
|
+
logger.critical(
|
|
168
|
+
ErrorCode.INVALID_VALUE,
|
|
169
|
+
'definePersonas() needs an inline object — personas are read statically, never evaluated.'
|
|
170
|
+
)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const sourceFile = node.getSourceFile().fileName
|
|
175
|
+
const known = Object.keys(DISPOSITIONS)
|
|
176
|
+
|
|
177
|
+
for (const prop of unwrapped.properties) {
|
|
178
|
+
if (!ts.isPropertyAssignment(prop)) {
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
|
|
183
|
+
logger.critical(
|
|
184
|
+
ErrorCode.NON_LITERAL_WIRE_NAME,
|
|
185
|
+
'A persona is declared with a key that is not a literal.'
|
|
186
|
+
)
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
const id = prop.name.text
|
|
190
|
+
|
|
191
|
+
if (!ts.isObjectLiteralExpression(prop.initializer)) {
|
|
192
|
+
logger.critical(
|
|
193
|
+
ErrorCode.INVALID_VALUE,
|
|
194
|
+
`Persona '${id}' must be an object literal.`
|
|
195
|
+
)
|
|
196
|
+
continue
|
|
197
|
+
}
|
|
198
|
+
const config = prop.initializer
|
|
199
|
+
|
|
200
|
+
const name = stringProperty(config, 'name')
|
|
201
|
+
if (!name) {
|
|
202
|
+
logger.critical(
|
|
203
|
+
ErrorCode.INVALID_VALUE,
|
|
204
|
+
`Persona '${id}' needs a literal 'name' — a persona is a person, and the console has nothing to show without one.`
|
|
205
|
+
)
|
|
206
|
+
continue
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// The generated `definePersonas` types this as a union, so a bad value only
|
|
210
|
+
// reaches here in source that does not compile. Caught anyway: a
|
|
211
|
+
// disposition is the entire behaviour, and defaulting a typo to `realistic`
|
|
212
|
+
// would run a different person than the one that was written.
|
|
213
|
+
const disposition = stringProperty(config, 'disposition')
|
|
214
|
+
if (disposition !== undefined && !known.includes(disposition)) {
|
|
215
|
+
logger.critical(
|
|
216
|
+
ErrorCode.INVALID_VALUE,
|
|
217
|
+
`Persona '${id}' has an unknown disposition '${disposition}'. One of: ${known.join(', ')}`
|
|
218
|
+
)
|
|
219
|
+
continue
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
let tuningFailed = false
|
|
223
|
+
const tuning = tuningProperty(config, (message) => {
|
|
224
|
+
tuningFailed = true
|
|
225
|
+
logger.critical(
|
|
226
|
+
ErrorCode.INVALID_VALUE,
|
|
227
|
+
`Persona '${id}': ${message}`
|
|
228
|
+
)
|
|
229
|
+
})
|
|
230
|
+
if (tuningFailed) {
|
|
231
|
+
continue
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const account = accountProperty(getProperty(config, 'account'))
|
|
235
|
+
const declaredRunnable = booleanProperty(config, 'runnable')
|
|
236
|
+
|
|
237
|
+
const persona: PersonaMeta = {
|
|
238
|
+
id,
|
|
239
|
+
name,
|
|
240
|
+
roles: stringArrayProperty(config, 'roles') ?? [],
|
|
241
|
+
goals: stringArrayProperty(config, 'goals') ?? [],
|
|
242
|
+
tags: stringArrayProperty(config, 'tags') ?? [],
|
|
243
|
+
runnable: isRunnablePersona({
|
|
244
|
+
...(declaredRunnable !== undefined
|
|
245
|
+
? { runnable: declaredRunnable }
|
|
246
|
+
: {}),
|
|
247
|
+
...(account ? { account } : {}),
|
|
248
|
+
}),
|
|
249
|
+
sourceFile,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const jobTitle = stringProperty(config, 'jobTitle')
|
|
253
|
+
if (jobTitle !== undefined) persona.jobTitle = jobTitle
|
|
254
|
+
const description = stringProperty(config, 'description')
|
|
255
|
+
if (description !== undefined) persona.description = description
|
|
256
|
+
const personality = stringProperty(config, 'personality')
|
|
257
|
+
if (personality !== undefined) persona.personality = personality
|
|
258
|
+
if (disposition !== undefined) {
|
|
259
|
+
persona.disposition = disposition as PersonaMeta['disposition']
|
|
260
|
+
}
|
|
261
|
+
if (tuning !== undefined) persona.tuning = tuning
|
|
262
|
+
const fixtures = stringArrayProperty(config, 'fixtures')
|
|
263
|
+
if (fixtures !== undefined) persona.fixtures = fixtures
|
|
264
|
+
// Left absent rather than resolved here: "everywhere but production" is a
|
|
265
|
+
// fact about the config, and the inspector has no business baking today's
|
|
266
|
+
// environment list into a meta file that outlives it.
|
|
267
|
+
const environments = stringArrayProperty(config, 'environments')
|
|
268
|
+
if (environments !== undefined) persona.environments = environments
|
|
269
|
+
if (account !== undefined) persona.account = account
|
|
270
|
+
const linkedAccounts = linkedAccountsProperty(config, id, logger)
|
|
271
|
+
if (linkedAccounts !== undefined) persona.linkedAccounts = linkedAccounts
|
|
272
|
+
|
|
273
|
+
state.personas.files.add(sourceFile)
|
|
274
|
+
state.personas.definitions.push(persona)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
@@ -30,13 +30,13 @@ const STEPS = [
|
|
|
30
30
|
'export const buysAnApple = pikkuScenarioStep({',
|
|
31
31
|
" name: 'buysAnApple',",
|
|
32
32
|
" description: 'buys an apple',",
|
|
33
|
-
' browser:
|
|
34
|
-
'
|
|
33
|
+
' browser: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
|
|
34
|
+
' default: async ({ logger }, data: { qty: number }) => ({ ok: data.qty > 0 }),',
|
|
35
35
|
'})',
|
|
36
36
|
'export const seesAReceipt = pikkuScenarioStep({',
|
|
37
37
|
" name: 'seesAReceipt',",
|
|
38
38
|
" description: 'sees a receipt',",
|
|
39
|
-
'
|
|
39
|
+
' default: async ({ logger }) => ({ ok: true }),',
|
|
40
40
|
'})',
|
|
41
41
|
].join('\n')
|
|
42
42
|
|
|
@@ -209,6 +209,52 @@ describe('pikkuScenarioStep', () => {
|
|
|
209
209
|
}
|
|
210
210
|
})
|
|
211
211
|
|
|
212
|
+
test('a scenario that never asserts is a PKU680 critical', async () => {
|
|
213
|
+
const { criticals, cleanup } = await run([
|
|
214
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
215
|
+
"await scenario.when('buys an apple', 'buysAnApple', { qty: 2 }, { actor: actors.shopper })",
|
|
216
|
+
])
|
|
217
|
+
try {
|
|
218
|
+
assert.ok(
|
|
219
|
+
criticals.find((c) => c.code === 'PKU680'),
|
|
220
|
+
`a flow with no 'then' proves only that nothing threw, and contributes 0/0 to witness coverage, got: ${JSON.stringify(criticals)}`
|
|
221
|
+
)
|
|
222
|
+
} finally {
|
|
223
|
+
await cleanup()
|
|
224
|
+
}
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
test('a scenario with an assertion is not flagged', async () => {
|
|
228
|
+
const { criticals, cleanup } = await run([
|
|
229
|
+
"await scenario.given('buys an apple', 'buysAnApple', { qty: 1 }, { actor: actors.shopper })",
|
|
230
|
+
"await scenario.then('sees a receipt', 'seesAReceipt', {}, { actor: actors.shopper })",
|
|
231
|
+
])
|
|
232
|
+
try {
|
|
233
|
+
assert.equal(
|
|
234
|
+
criticals.filter((c) => c.code === 'PKU680').length,
|
|
235
|
+
0,
|
|
236
|
+
`got: ${JSON.stringify(criticals)}`
|
|
237
|
+
)
|
|
238
|
+
} finally {
|
|
239
|
+
await cleanup()
|
|
240
|
+
}
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
test('a scenario driving only rpc steps is not flagged — it has no step ladder to assert on', async () => {
|
|
244
|
+
const { criticals, cleanup } = await run([
|
|
245
|
+
"await scenario.do('reads the order', 'orderGet', { orderId: 'o1' })",
|
|
246
|
+
])
|
|
247
|
+
try {
|
|
248
|
+
assert.equal(
|
|
249
|
+
criticals.filter((c) => c.code === 'PKU680').length,
|
|
250
|
+
0,
|
|
251
|
+
`the rule is about scenarios written as given/when/then, got: ${JSON.stringify(criticals)}`
|
|
252
|
+
)
|
|
253
|
+
} finally {
|
|
254
|
+
await cleanup()
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
212
258
|
test('a non-browser step without an actor is allowed', async () => {
|
|
213
259
|
const { criticals, cleanup } = await run([
|
|
214
260
|
"await scenario.given('sees a receipt', 'seesAReceipt', {})",
|