@voxgig/sdkgen 4.16.0 → 4.17.1
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/README.md +8 -0
- package/bin/voxgig-sdkgen +2 -2
- package/dist/action/action.js +2 -2
- package/dist/action/action.js.map +1 -1
- package/dist/action/check.js +9 -8
- package/dist/action/check.js.map +1 -1
- package/dist/action/dispatch.js +4 -4
- package/dist/action/dispatch.js.map +1 -1
- package/dist/action/doctor.js +16 -15
- package/dist/action/doctor.js.map +1 -1
- package/dist/action/edition.d.ts +4 -0
- package/dist/action/{docs.js → edition.js} +57 -54
- package/dist/action/edition.js.map +1 -0
- package/dist/action/kind.d.ts +1 -2
- package/dist/action/kind.js +22 -38
- package/dist/action/kind.js.map +1 -1
- package/dist/action/package.js +17 -16
- package/dist/action/package.js.map +1 -1
- package/dist/action/resolve.d.ts +3 -1
- package/dist/action/resolve.js +29 -11
- package/dist/action/resolve.js.map +1 -1
- package/dist/admin/status.d.ts +43 -0
- package/dist/admin/status.js +161 -0
- package/dist/admin/status.js.map +1 -0
- package/dist/cmp/Test.js +11 -0
- package/dist/cmp/Test.js.map +1 -1
- package/dist/helpers/kindCollection.d.ts +1 -0
- package/dist/helpers/kindCollection.js +14 -0
- package/dist/helpers/kindCollection.js.map +1 -0
- package/dist/helpers/modelcheck.d.ts +1 -1
- package/dist/helpers/modelcheck.js +13 -3
- package/dist/helpers/modelcheck.js.map +1 -1
- package/dist/helpers/shipped.js +2 -3
- package/dist/helpers/shipped.js.map +1 -1
- package/dist/helpers/testPolicy.d.ts +1 -1
- package/dist/helpers/testPolicy.js +6 -37
- package/dist/helpers/testPolicy.js.map +1 -1
- package/dist/sdkgen.d.ts +1 -1
- package/dist/sdkgen.js +22 -89
- package/dist/sdkgen.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/dist/utility.js +9 -1
- package/dist/utility.js.map +1 -1
- package/model/sdkgen.aon +5 -52
- package/package.json +1 -1
- package/project/.sdk/src/cmp/go/Config_go.ts +4 -4
- package/project/.sdk/src/cmp/go/utility_go.ts +10 -2
- package/project/.sdk/src/cmp/js/Package_js.ts +5 -1
- package/project/.sdk/src/cmp/js/TestDirect_js.ts +11 -7
- package/project/.sdk/src/cmp/js/TestEntity_js.ts +16 -2
- package/project/.sdk/src/cmp/js/TestLive_js.ts +32 -0
- package/project/.sdk/src/cmp/js/Test_js.ts +2 -0
- package/project/.sdk/src/cmp/js/fragment/Direct.test.fragment.js +1 -1
- package/project/.sdk/src/cmp/js/fragment/Entity.test.fragment.js +4 -2
- package/project/.sdk/src/cmp/php/ReadmeExamplesTest_php.ts +30 -9
- package/project/.sdk/src/cmp/php/ReadmeHowto_php.ts +8 -3
- package/project/.sdk/src/cmp/php/ReadmeQuick_php.ts +5 -4
- package/project/.sdk/src/cmp/php/ReadmeTopQuick_php.ts +2 -2
- package/project/.sdk/src/cmp/ts/Package_ts.ts +5 -1
- package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +5 -0
- package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +15 -11
- package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +13 -16
- package/project/.sdk/src/cmp/ts/TestLive_ts.ts +32 -0
- package/project/.sdk/src/cmp/ts/Test_ts.ts +2 -0
- package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +1 -0
- package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +2 -0
- package/project/.sdk/tm/c/Makefile +16 -11
- package/project/.sdk/tm/c/feature/secrets/sekreto/json.c +3 -3
- package/project/.sdk/tm/csharp/test/feature/secrets/SecretsFeatureTest.cs +9 -2
- package/project/.sdk/tm/js/test/live-contract.js +183 -0
- package/project/.sdk/tm/js/test/live-entity.js +169 -0
- package/project/.sdk/tm/js/test/live-runner.js +129 -0
- package/project/.sdk/tm/js/test/live-scenarios.js +142 -0
- package/project/.sdk/tm/js/test/utility.js +84 -0
- package/project/.sdk/tm/php/test/feature/secrets/SecretsTest.php +22 -20
- package/project/.sdk/tm/ts/test/live-contract.ts +104 -0
- package/project/.sdk/tm/ts/test/live-entity.ts +142 -0
- package/project/.sdk/tm/ts/test/live-runner.ts +166 -0
- package/project/.sdk/tm/ts/test/live-scenarios.ts +112 -0
- package/project/.sdk/tm/ts/test/utility.ts +1 -2
- package/project/sdkgen-package.json +1 -1
- package/src/action/action.ts +2 -2
- package/src/action/check.ts +3 -2
- package/src/action/dispatch.ts +4 -4
- package/src/action/doctor.ts +10 -9
- package/src/action/{docs.ts → edition.ts} +56 -53
- package/src/action/kind.ts +12 -33
- package/src/action/package.ts +4 -3
- package/src/action/resolve.ts +26 -2
- package/src/admin/status.ts +124 -0
- package/src/cmp/Test.ts +13 -1
- package/src/helpers/kindCollection.ts +11 -0
- package/src/helpers/modelcheck.ts +10 -3
- package/src/helpers/shipped.ts +2 -3
- package/src/helpers/testPolicy.ts +7 -45
- package/src/sdkgen.ts +15 -94
- package/src/utility.ts +8 -2
- package/dist/action/docs.d.ts +0 -4
- package/dist/action/docs.js.map +0 -1
- package/dist/cmp/Docs.d.ts +0 -4
- package/dist/cmp/Docs.js +0 -98
- package/dist/cmp/Docs.js.map +0 -1
- package/dist/cmp/ExternalDocs.d.ts +0 -2
- package/dist/cmp/ExternalDocs.js +0 -43
- package/dist/cmp/ExternalDocs.js.map +0 -1
- package/src/cmp/Docs.ts +0 -125
- package/src/cmp/ExternalDocs.ts +0 -54
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { requestContract, synthesizeInput } from './live-contract'
|
|
3
|
+
import { runLiveSteps, assertLiveReport, LiveBlocked } from './live-runner'
|
|
4
|
+
import { isControlSkipped, liveDelayMs } from './utility'
|
|
5
|
+
|
|
6
|
+
// The offline flow keeps its deterministic fixture assertions. Live flows
|
|
7
|
+
// resolve real prerequisites per operation and collect failures until done.
|
|
8
|
+
async function runLiveEntity(setup: any, entity: any, flow: any, accessor: string) {
|
|
9
|
+
const { client, transport } = setup
|
|
10
|
+
const steps: any[] = flow.step || []
|
|
11
|
+
const created = new Map<string, any>()
|
|
12
|
+
const listed: any[] = []
|
|
13
|
+
const marks = new Map<string, { name: string, value: any }>()
|
|
14
|
+
const idField = entity.id?.field || 'id'
|
|
15
|
+
const copy = (value: any) => JSON.parse(JSON.stringify(value ?? {}))
|
|
16
|
+
const hasCreate = steps.some(step => step.op === 'create')
|
|
17
|
+
const report = await runLiveSteps(steps.map((step, index) => {
|
|
18
|
+
const ref = step.input?.ref || entity.name + '_ref01'
|
|
19
|
+
const op = step.op
|
|
20
|
+
const excluded = isControlSkipped('entityOp', entity.name + '.' + op, 'live')
|
|
21
|
+
return {
|
|
22
|
+
id: entity.name + '.' + op + '.' + index,
|
|
23
|
+
cleanup: op === 'remove' || (step.valid || []).some((v: any) => v.apply === 'ItemNotExists'),
|
|
24
|
+
excluded: excluded.skip ? excluded.reason || 'Excluded by test control' : undefined,
|
|
25
|
+
run: async (context: any) => {
|
|
26
|
+
transport.enter(context)
|
|
27
|
+
const points = entity.op?.[op]?.points || []
|
|
28
|
+
if (!points.length) throw new LiveBlocked('No modelled operation point')
|
|
29
|
+
const record = created.get(ref)
|
|
30
|
+
// A failed create must not turn a later update/remove into a write
|
|
31
|
+
// against an arbitrary record found by a list operation.
|
|
32
|
+
if ((op === 'update' || op === 'remove') && hasCreate && !record) {
|
|
33
|
+
throw new LiveBlocked('Create did not provide a usable resource')
|
|
34
|
+
}
|
|
35
|
+
if (op === 'remove' && !record) throw new LiveBlocked('No resource created by this run')
|
|
36
|
+
if (op === 'remove' && !entity.id) throw new LiveBlocked('No modelled resource identity for cleanup')
|
|
37
|
+
if (record && ['update', 'remove'].includes(op) && entity.id &&
|
|
38
|
+
null == (record[idField] ?? record.id)) {
|
|
39
|
+
throw new LiveBlocked('Created resource has no usable identity')
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let input: any = op === 'create'
|
|
43
|
+
? copy(setup.data.new?.[entity.name]?.[ref]) : {}
|
|
44
|
+
for (const [name, binding] of Object.entries({ ...step.match, ...step.data })) {
|
|
45
|
+
const value = setup.idmap[binding as string] ?? setup.idmap[name]
|
|
46
|
+
if (undefined !== value) input[name] = value
|
|
47
|
+
}
|
|
48
|
+
const loaded = record || (op === 'load' ? listed[0] : undefined)
|
|
49
|
+
if (loaded && ['load', 'update', 'remove'].includes(op)) {
|
|
50
|
+
const id = loaded[idField] ?? loaded.id
|
|
51
|
+
if (undefined !== id) input.id = id
|
|
52
|
+
}
|
|
53
|
+
// A nested route needing a parent id must not hide an available
|
|
54
|
+
// parameter-free route for the same operation. Use the first viable
|
|
55
|
+
// candidate; the SDK still performs its normal route selection.
|
|
56
|
+
let resolved: any
|
|
57
|
+
let selected: any
|
|
58
|
+
const missing = new Set<string>()
|
|
59
|
+
for (const point of points) {
|
|
60
|
+
if (point.select?.$action !== input.$action) continue
|
|
61
|
+
const candidate = { ...input }
|
|
62
|
+
let viable = true
|
|
63
|
+
const params = point.args?.params || []
|
|
64
|
+
const query = (point.args?.query || []).filter((arg: any) => arg.reqd)
|
|
65
|
+
for (const arg of [...params, ...query]) {
|
|
66
|
+
if (undefined !== candidate[arg.name] && null !== candidate[arg.name]) continue
|
|
67
|
+
const key = arg.name === 'id' ? entity.name + '01' : arg.name.replace(/_id$/, '') + '01'
|
|
68
|
+
const value = setup.idmap[key] ?? setup.idmap[arg.name] ?? loaded?.[arg.name] ?? arg.example
|
|
69
|
+
if (undefined !== value && null !== value) candidate[arg.name] = value
|
|
70
|
+
else if (arg.reqd !== false) { viable = false; missing.add(arg.name) }
|
|
71
|
+
}
|
|
72
|
+
if (viable) { resolved = candidate; selected = point; break }
|
|
73
|
+
}
|
|
74
|
+
if (!resolved) throw new LiveBlocked('No usable route; missing arguments: ' + [...missing].join(', '))
|
|
75
|
+
input = resolved
|
|
76
|
+
if (op === 'create' && selected.contract) {
|
|
77
|
+
const facts = JSON.parse(selected.contract.json)
|
|
78
|
+
const request = requestContract(facts)
|
|
79
|
+
if (request.schema) input = { ...synthesizeInput(request.schema, facts.live?.input ?? request.example) }
|
|
80
|
+
else if (facts.protocol === 'http') input = {}
|
|
81
|
+
for (const arg of selected.args?.params || []) if (resolved[arg.name] !== undefined) input[arg.name] = resolved[arg.name]
|
|
82
|
+
}
|
|
83
|
+
let intendedMark: { name: string, value: any } | undefined
|
|
84
|
+
if (op === 'update') {
|
|
85
|
+
for (const spec of step.spec || []) {
|
|
86
|
+
if (spec.apply === 'TextFieldMark' && step.input?.textfield) {
|
|
87
|
+
const mark = { name: step.input.textfield, value: spec.def.mark + '_' + setup.now }
|
|
88
|
+
input[mark.name] = mark.value
|
|
89
|
+
intendedMark = mark
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// A fresh entity prevents state left by a failed operation from
|
|
95
|
+
// changing the request for the next independent operation.
|
|
96
|
+
const result = await client[accessor]()[op](input)
|
|
97
|
+
if (intendedMark) marks.set(ref, intendedMark)
|
|
98
|
+
if (op === 'list') {
|
|
99
|
+
assert(Array.isArray(result), 'Expected a list of entity instances')
|
|
100
|
+
const data = result.map((item: any) => {
|
|
101
|
+
assert.equal(typeof item?.data, 'function')
|
|
102
|
+
return item.data()
|
|
103
|
+
})
|
|
104
|
+
listed.splice(0, listed.length, ...data)
|
|
105
|
+
context.publish(data)
|
|
106
|
+
for (const validation of step.valid || []) {
|
|
107
|
+
const previous = created.get(validation.def?.ref)
|
|
108
|
+
const id = previous?.[idField] ?? previous?.id
|
|
109
|
+
if (undefined === id) continue
|
|
110
|
+
const found = data.some((item: any) => (item?.[idField] ?? item?.id) === id)
|
|
111
|
+
if (validation.apply === 'ItemExists') assert(found, 'Created resource missing from list')
|
|
112
|
+
if (validation.apply === 'ItemNotExists') assert(!found, 'Removed resource still in list')
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
assert.equal(typeof result?.data, 'function', 'Expected an entity instance')
|
|
117
|
+
const data = result.data()
|
|
118
|
+
assert(null != data, 'Expected operation data')
|
|
119
|
+
// Publish before assertion checks: cleanup still needs the id
|
|
120
|
+
// when the server created a resource with incorrect field values.
|
|
121
|
+
if (op === 'create') created.set(ref, data)
|
|
122
|
+
context.publish(data)
|
|
123
|
+
if (op === 'create' && entity.id) assert(null != (data[idField] ?? data.id), 'Missing created identity')
|
|
124
|
+
if (['load', 'update'].includes(op) && input.id != null && entity.id) {
|
|
125
|
+
assert.equal(data[idField] ?? data.id, input.id, 'Response identity mismatch')
|
|
126
|
+
}
|
|
127
|
+
const mark = marks.get(ref)
|
|
128
|
+
if (mark && ['update', 'load'].includes(op)) {
|
|
129
|
+
assert.equal(data[mark.name], mark.value, 'Updated field mismatch')
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
}
|
|
134
|
+
}), {
|
|
135
|
+
delayMs: liveDelayMs(),
|
|
136
|
+
report: result => console.log('LIVE STEP ' + JSON.stringify(result)),
|
|
137
|
+
})
|
|
138
|
+
console.log('LIVE SUMMARY ' + JSON.stringify({ entity: entity.name, ...report }))
|
|
139
|
+
assertLiveReport(report)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { runLiveEntity }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Execute every independent live step before deciding the suite's result.
|
|
2
|
+
// Values never appear in reports: they can contain credentials or API data.
|
|
3
|
+
|
|
4
|
+
type LiveState = 'passed' | 'failed' | 'blocked' | 'excluded'
|
|
5
|
+
|
|
6
|
+
type LiveResult = {
|
|
7
|
+
id: string
|
|
8
|
+
state: LiveState
|
|
9
|
+
attempted: boolean
|
|
10
|
+
role?: string
|
|
11
|
+
retention?: string
|
|
12
|
+
reason?: string
|
|
13
|
+
requests?: { method: string, path: string, status?: number }[]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type LiveContext = {
|
|
17
|
+
values: Map<string, any>
|
|
18
|
+
publish(value: any): void
|
|
19
|
+
attempted(): void
|
|
20
|
+
requests: { method: string, path: string, status?: number }[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type LiveStep = {
|
|
24
|
+
id: string
|
|
25
|
+
needs?: string[]
|
|
26
|
+
cleanup?: boolean
|
|
27
|
+
role?: string
|
|
28
|
+
retention?: string
|
|
29
|
+
excluded?: string
|
|
30
|
+
run(ctx: LiveContext): Promise<void>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class LiveBlocked extends Error {}
|
|
34
|
+
|
|
35
|
+
type LiveReport = {
|
|
36
|
+
planned: number
|
|
37
|
+
attempted: number
|
|
38
|
+
passed: number
|
|
39
|
+
failed: number
|
|
40
|
+
blocked: number
|
|
41
|
+
excluded: number
|
|
42
|
+
results: LiveResult[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function runLiveSteps(
|
|
46
|
+
steps: LiveStep[],
|
|
47
|
+
options: { delayMs?: number, report?: (result: LiveResult) => void } = {},
|
|
48
|
+
): Promise<LiveReport> {
|
|
49
|
+
const values = new Map<string, any>()
|
|
50
|
+
const results: LiveResult[] = []
|
|
51
|
+
const ids = new Set<string>()
|
|
52
|
+
for (const step of steps) {
|
|
53
|
+
if (!step.id || ids.has(step.id)) throw new Error('Duplicate or empty live step id')
|
|
54
|
+
ids.add(step.id)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const record = (result: LiveResult) => {
|
|
58
|
+
const step = steps.find(step => step.id === result.id)
|
|
59
|
+
if (step?.role) result.role = step.role
|
|
60
|
+
if (step?.retention && result.attempted) result.retention = step.retention
|
|
61
|
+
results.push(result)
|
|
62
|
+
options.report?.(result)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Cleanup runs after ordinary work, even if an assertion failed after a
|
|
66
|
+
// resource was created. A step publishes usable data before assertions.
|
|
67
|
+
for (const cleanup of [false, true]) {
|
|
68
|
+
const pending = steps.filter(step => !!step.cleanup === cleanup)
|
|
69
|
+
while (pending.length) {
|
|
70
|
+
let progressed = false
|
|
71
|
+
for (let i = 0; i < pending.length;) {
|
|
72
|
+
const step = pending[i]
|
|
73
|
+
const missing = (step.needs || []).filter(id => !values.has(id) ||
|
|
74
|
+
(!cleanup && results.some(result => result.id === id && result.state !== 'passed')))
|
|
75
|
+
if (!step.excluded && missing.some(id => pending.some(other => other.id === id))) {
|
|
76
|
+
i++
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
pending.splice(i, 1)
|
|
80
|
+
progressed = true
|
|
81
|
+
if (step.excluded) {
|
|
82
|
+
record({ id: step.id, state: 'excluded', attempted: false, reason: step.excluded })
|
|
83
|
+
continue
|
|
84
|
+
}
|
|
85
|
+
if (missing.length) {
|
|
86
|
+
record({ id: step.id, state: 'blocked', attempted: false,
|
|
87
|
+
reason: 'Missing output: ' + missing.join(', ') })
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
let attempted = false
|
|
91
|
+
const requests: LiveContext['requests'] = []
|
|
92
|
+
try {
|
|
93
|
+
await step.run({
|
|
94
|
+
values,
|
|
95
|
+
publish: value => { if (undefined !== value) values.set(step.id, value) },
|
|
96
|
+
attempted: () => { attempted = true },
|
|
97
|
+
requests,
|
|
98
|
+
})
|
|
99
|
+
if (!attempted) throw new LiveBlocked('No request attempted')
|
|
100
|
+
record({ id: step.id, state: 'passed', attempted, requests })
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
// Ordinary SDK errors can contain request headers, bodies, or an
|
|
104
|
+
// issued token. Report the step's failure without serializing it.
|
|
105
|
+
record({ id: step.id, state: error instanceof LiveBlocked ? 'blocked' : 'failed',
|
|
106
|
+
attempted, requests,
|
|
107
|
+
reason: error instanceof LiveBlocked ? error.message : 'Request or assertion failed' })
|
|
108
|
+
}
|
|
109
|
+
if (attempted && (options.delayMs || 0) > 0) {
|
|
110
|
+
await new Promise(resolve => setTimeout(resolve, options.delayMs))
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (!progressed) {
|
|
114
|
+
for (const step of pending.splice(0)) {
|
|
115
|
+
record({ id: step.id, state: 'blocked', attempted: false,
|
|
116
|
+
reason: 'Cyclic or unavailable dependency' })
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
planned: steps.length,
|
|
124
|
+
attempted: results.filter(result => result.attempted).length,
|
|
125
|
+
passed: results.filter(result => result.state === 'passed').length,
|
|
126
|
+
failed: results.filter(result => result.state === 'failed').length,
|
|
127
|
+
blocked: results.filter(result => result.state === 'blocked').length,
|
|
128
|
+
excluded: results.filter(result => result.state === 'excluded').length,
|
|
129
|
+
results,
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function assertLiveReport(report: LiveReport): void {
|
|
134
|
+
if (report.failed || report.blocked || !report.attempted) {
|
|
135
|
+
throw new Error('Live coverage incomplete: ' + JSON.stringify(report))
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function createLiveTransport(timeoutMs = 30000) {
|
|
140
|
+
let context: LiveContext | undefined
|
|
141
|
+
const requests: LiveContext['requests'] = []
|
|
142
|
+
const nativeFetch = globalThis.fetch
|
|
143
|
+
return {
|
|
144
|
+
requests,
|
|
145
|
+
enter(next: LiveContext) { context = next },
|
|
146
|
+
fetch: async (input: any, init?: any) => {
|
|
147
|
+
const url = new URL(String(input))
|
|
148
|
+
const request: LiveContext['requests'][number] = {
|
|
149
|
+
method: init?.method || 'GET', path: url.origin + url.pathname,
|
|
150
|
+
}
|
|
151
|
+
requests.push(request)
|
|
152
|
+
context?.requests.push(request)
|
|
153
|
+
context?.attempted()
|
|
154
|
+
if (!context) console.log('LIVE REQUEST ' + JSON.stringify(request))
|
|
155
|
+
const timeout = AbortSignal.timeout(timeoutMs)
|
|
156
|
+
const signal = init?.signal ? AbortSignal.any([init.signal, timeout]) : timeout
|
|
157
|
+
const response = await nativeFetch(input, { ...init, signal })
|
|
158
|
+
request.status = response.status
|
|
159
|
+
if (!context) console.log('LIVE RESPONSE ' + JSON.stringify(request))
|
|
160
|
+
return response
|
|
161
|
+
},
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export { runLiveSteps, assertLiveReport, LiveBlocked, createLiveTransport }
|
|
166
|
+
export type { LiveStep, LiveContext, LiveReport, LiveResult }
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { LiveBlocked, runLiveSteps, assertLiveReport, createLiveTransport } from './live-runner'
|
|
3
|
+
import { requestContract, synthesizeInput, validateContract } from './live-contract'
|
|
4
|
+
import { liveClientOptions, liveDelayMs, isControlSkipped } from './utility'
|
|
5
|
+
|
|
6
|
+
const at = (value: any, path?: string): any => (path || '').split('.').filter(Boolean).reduce((v, k) => v?.[k], value)
|
|
7
|
+
export function resolveRecipe(recipe: any, values: Map<string, any>): any {
|
|
8
|
+
if (Array.isArray(recipe)) return recipe.map(v => resolveRecipe(v, values))
|
|
9
|
+
if (recipe && typeof recipe === 'object') {
|
|
10
|
+
if (recipe.from) {
|
|
11
|
+
if (!values.has(recipe.from)) throw new LiveBlocked('Missing recipe output: ' + recipe.from)
|
|
12
|
+
let value = values.get(recipe.from)
|
|
13
|
+
if (recipe.where) {
|
|
14
|
+
if (!Array.isArray(value)) throw new LiveBlocked('Discovery output is not a list')
|
|
15
|
+
value = value.find((item: any) => Object.entries(recipe.where).every(([k, v]) => at(item, k) === v) &&
|
|
16
|
+
(!recipe.related || (values.get(recipe.related.from) || []).some((other: any) =>
|
|
17
|
+
at(item, recipe.related.local) === at(other, recipe.related.foreign) &&
|
|
18
|
+
Object.entries(recipe.related.where || {}).every(([k, v]) => at(other, k) === v))))
|
|
19
|
+
}
|
|
20
|
+
value = at(value, recipe.path)
|
|
21
|
+
if (value === undefined || value === null) throw new LiveBlocked('Discovery found no compatible value')
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
return Object.fromEntries(Object.entries(recipe).map(([k, v]) => [k, resolveRecipe(v, values)]))
|
|
25
|
+
}
|
|
26
|
+
return recipe
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function recipeNeeds(value: any): string[] {
|
|
30
|
+
if (!value || typeof value !== 'object') return []
|
|
31
|
+
return [...new Set([...(typeof value.from === 'string' ? [value.from] : []), ...Object.values(value).flatMap(recipeNeeds)])]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function runLiveScenarios(SDK: any, plan: any[], envPrefix: string, liveDefaults: any = {}) {
|
|
35
|
+
const transport = createLiveTransport()
|
|
36
|
+
const steps = plan.map(point => {
|
|
37
|
+
const hint = point.facts.live || {}
|
|
38
|
+
const control = isControlSkipped('entityOp', point.entity + '.' + point.op, 'live')
|
|
39
|
+
return { role: hint.auth || (point.facts.security?.length === 0 || point.facts.securitySource === 'unspecified' ? 'public' : 'account'), retention: hint.retention, id: hint.id || point.id, needs: [...new Set([...(hint.needs || []), ...recipeNeeds(hint.input), ...recipeNeeds(hint.credential), ...recipeNeeds(hint.assert)])] as string[],
|
|
40
|
+
cleanup: !!hint.cleanup,
|
|
41
|
+
excluded: control.skip ? control.reason || 'Excluded by test control' : hint.excluded,
|
|
42
|
+
run: async (ctx: any) => {
|
|
43
|
+
transport.enter(ctx)
|
|
44
|
+
if (point.contractVersion && point.contractVersion !== 1) throw new LiveBlocked('Unsupported operation contract version')
|
|
45
|
+
if (point.op === 'remove' || hint.cleanup) {
|
|
46
|
+
const owned = recipeNeeds(hint.input).some(id => plan.some(source =>
|
|
47
|
+
(source.facts.live?.id || source.id) === id && source.entity === point.entity && source.op === 'create'))
|
|
48
|
+
if (!owned) throw new LiveBlocked('Cleanup input is not bound to a resource created by this run')
|
|
49
|
+
}
|
|
50
|
+
if (!point.reachable) throw new LiveBlocked('Point selector is indistinguishable; add a guide action')
|
|
51
|
+
const request = requestContract(point.facts)
|
|
52
|
+
const explicit = hint.input === undefined ? request.example : resolveRecipe(hint.input, ctx.values)
|
|
53
|
+
let input = request.schema ? synthesizeInput(request.schema, explicit) : explicit ?? {}
|
|
54
|
+
for (const kind of ['params', 'query', 'header', 'cookie']) for (const arg of point.args?.[kind] || []) {
|
|
55
|
+
if (arg.reqd && input[arg.name] === undefined) {
|
|
56
|
+
if (arg.example === undefined) throw new LiveBlocked('Missing required argument: ' + arg.name)
|
|
57
|
+
input[arg.name] = arg.example
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
const role = hint.auth || (point.facts.security?.length === 0 || point.facts.securitySource === 'unspecified' ? 'public' : 'account')
|
|
61
|
+
let apikey = role === 'public' ? null : role === 'issued' ? resolveRecipe(hint.credential, ctx.values) : process.env[envPrefix + '_APIKEY']
|
|
62
|
+
if (role === 'issued' && (typeof apikey !== 'string' || !apikey)) throw new LiveBlocked('Issued credential unavailable')
|
|
63
|
+
let wire: any
|
|
64
|
+
let wireSchema: any
|
|
65
|
+
const options = liveClientOptions()
|
|
66
|
+
const client = new SDK({ ...options, ...liveDefaults, apikey,
|
|
67
|
+
feature: { ...options.feature, test: { active: false }, retry: { active: false },
|
|
68
|
+
secrets: { ...options.feature?.secrets, active: role === 'account' && (!apikey || options.feature?.secrets?.active === true) } },
|
|
69
|
+
system: { ...options.system, fetch: async (url: any, init: any) => {
|
|
70
|
+
const headers = new Headers(init?.headers)
|
|
71
|
+
if (role === 'public') assert(!headers.has('authorization'), 'Public request carries authentication')
|
|
72
|
+
else if (!headers.get('authorization')) throw new LiveBlocked('Credential unavailable for ' + role + ' request')
|
|
73
|
+
const response = await transport.fetch(url, { ...init, redirect: 'error' })
|
|
74
|
+
const expectedPath = point.path.replace(/\{([^}]+)\}/g, (_: string, name: string) => encodeURIComponent(input[point.rename?.param?.[name] || name] ?? input[name]))
|
|
75
|
+
if (point.kind === 'graphql') {
|
|
76
|
+
const payload = JSON.parse(init.body)
|
|
77
|
+
assert.equal(payload.query, point.graphql.doc, 'Entity selected wrong GraphQL operation')
|
|
78
|
+
} else assert.equal(new URL(String(url)).pathname, expectedPath, 'Entity selected wrong route')
|
|
79
|
+
assert.equal(init?.method || 'GET', point.method)
|
|
80
|
+
assert(response.status >= 200 && response.status < 300, 'Unsuccessful HTTP response')
|
|
81
|
+
if (point.kind === 'graphql') wire = await response.clone().json()
|
|
82
|
+
const def = point.facts.responses?.[response.status] || point.facts.responses?.[String(response.status)[0] + 'XX'] || point.facts.responses?.default
|
|
83
|
+
if (point.facts.responses && !def) throw new Error('Undeclared response status')
|
|
84
|
+
if (def) {
|
|
85
|
+
const schema = def.content?.['application/json']?.schema ?? def.schema
|
|
86
|
+
if (schema && response.status !== 204) { wireSchema = schema; wire = await response.clone().json() }
|
|
87
|
+
}
|
|
88
|
+
return response
|
|
89
|
+
} } })
|
|
90
|
+
if (point.action) input = { ...input, $action: point.action }
|
|
91
|
+
assert(JSON.stringify(input).length <= 1024 * 1024, 'Request exceeds test payload limit')
|
|
92
|
+
const result = await client[point.accessor]()[point.op](input)
|
|
93
|
+
const data = Array.isArray(result) ? result.map(item => { assert.equal(typeof item?.data, 'function'); return item.data() }) : (assert.equal(typeof result?.data, 'function'), result.data())
|
|
94
|
+
ctx.publish(data)
|
|
95
|
+
if (wireSchema) validateContract(wireSchema, wire, 'response')
|
|
96
|
+
if (Array.isArray(wire?.errors) && wire.errors.length) throw new Error('GraphQL operation reported errors')
|
|
97
|
+
if (wire?.success === false) throw new Error('API reported unsuccessful operation')
|
|
98
|
+
const checks = resolveRecipe(hint.assert || {}, ctx.values)
|
|
99
|
+
for (const [path, expected] of Object.entries(checks.equal || {})) assert.deepEqual(at(data, path), expected)
|
|
100
|
+
for (const path of checks.nonempty || []) assert(at(data, path)?.length > 0, 'Empty required output')
|
|
101
|
+
if (checks.vectors) {
|
|
102
|
+
const vectors = at(data, checks.vectors.path)
|
|
103
|
+
assert(Array.isArray(vectors) && vectors.length === checks.vectors.count)
|
|
104
|
+
for (const vector of vectors) assert(Array.isArray(vector) && vector.length === checks.vectors.dimension && vector.every((n: any) => typeof n === 'number' && Number.isFinite(n)), 'Invalid vector shape')
|
|
105
|
+
}
|
|
106
|
+
} }
|
|
107
|
+
})
|
|
108
|
+
const report = await runLiveSteps(steps, { delayMs: liveDelayMs(), report: result => console.log('LIVE STEP ' + JSON.stringify(result)) })
|
|
109
|
+
console.log('LIVE SUMMARY ' + JSON.stringify(report))
|
|
110
|
+
assertLiveReport(report)
|
|
111
|
+
return report
|
|
112
|
+
}
|
|
@@ -161,8 +161,7 @@ function skipIfMissingIds(t: any, setup: any, requiredKeys: string[]): boolean {
|
|
|
161
161
|
if (!setup.live) return false
|
|
162
162
|
const missing = requiredKeys.filter(k => null == setup.idmap?.[k])
|
|
163
163
|
if (missing.length > 0) {
|
|
164
|
-
|
|
165
|
-
return true
|
|
164
|
+
throw new Error(`Live test blocked: needs ${missing.join(', ')} via *_ENTID env var`)
|
|
166
165
|
}
|
|
167
166
|
return false
|
|
168
167
|
}
|
package/src/action/action.ts
CHANGED
|
@@ -102,7 +102,7 @@ function parseAddNames(args: any[]): string[] {
|
|
|
102
102
|
// `seed` IS THE UPGRADE PATH. A project scaffolded before a kind existed has
|
|
103
103
|
// no `model/<kind>/<kind>-index.aon` — every project alive today is in
|
|
104
104
|
// exactly that position for `docs` — and reading it unguarded made the FIRST
|
|
105
|
-
// `
|
|
105
|
+
// `edition add` in any existing project fail on ENOENT before it wrote anything.
|
|
106
106
|
//
|
|
107
107
|
// Seeded per call rather than defaulted for every kind: a missing
|
|
108
108
|
// `target-index.aon` in a scaffolded project is a broken project, and
|
|
@@ -136,7 +136,7 @@ function loadContent(
|
|
|
136
136
|
// indexes of the kinds that existed then. So a project scaffolded before a
|
|
137
137
|
// kind existed — which is every project alive today, for `docs` — never
|
|
138
138
|
// includes its index, and the item's model file is an orphan: it is on disk,
|
|
139
|
-
// `<kind>-index.aon` includes it, and NOTHING includes that. `main.kit.
|
|
139
|
+
// `<kind>-index.aon` includes it, and NOTHING includes that. `main.kit.doc.edition`
|
|
140
140
|
// is then absent from the compiled model, so `package list`, `package update`
|
|
141
141
|
// and `doctor` cannot see the item at all.
|
|
142
142
|
//
|
package/src/action/check.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
1
2
|
// `package check` — the AUTHOR-side battery. Design §14.
|
|
2
3
|
//
|
|
3
4
|
// WHAT IT IS FOR
|
|
@@ -308,11 +309,11 @@ function checkDefinition(
|
|
|
308
309
|
// `main: kit: target: go:` installs an item the consumer's model never
|
|
309
310
|
// sees — the exact mistake a package author makes copying a bundled
|
|
310
311
|
// target as a starting point.
|
|
311
|
-
const declared = strict.model
|
|
312
|
+
const declared = kindCollection(strict.model, kind)?.[name]
|
|
312
313
|
|
|
313
314
|
if (null == declared || 'object' !== typeof declared) {
|
|
314
315
|
found.push(at('error', 'model-key-missing',
|
|
315
|
-
'declares no `main: kit: ' + kind + ': ' + name + ':` block — the file ' +
|
|
316
|
+
'declares no `main: kit: ' + (kind === 'edition' ? 'doc: edition' : kind) + ': ' + name + ':` block — the file ' +
|
|
316
317
|
'is installed and included under its own name, so nothing it declares ' +
|
|
317
318
|
'under another name is reachable'))
|
|
318
319
|
}
|
package/src/action/dispatch.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// WHAT `voxgig-sdkgen <action> …` CAN BE.
|
|
2
2
|
//
|
|
3
3
|
// Built FROM THE KIND REGISTRY rather than hand-listed, so registering a kind
|
|
4
|
-
// is the only edit a new kind needs — `
|
|
4
|
+
// is the only edit a new kind needs — `edition add …` costs no dispatch code
|
|
5
5
|
// (docs/design/sdkgen-packages.md §9). The `package` and `doctor` verbs are
|
|
6
6
|
// not kinds and are added beside it.
|
|
7
7
|
//
|
|
@@ -25,7 +25,7 @@ import { KINDS } from './kind'
|
|
|
25
25
|
|
|
26
26
|
import { action_target, target_add } from './target'
|
|
27
27
|
import { action_feature, feature_add } from './feature'
|
|
28
|
-
import {
|
|
28
|
+
import { action_edition, edition_add } from './edition'
|
|
29
29
|
import { action_doctor } from './doctor'
|
|
30
30
|
import { action_package, registerAdder } from './package'
|
|
31
31
|
|
|
@@ -41,7 +41,7 @@ const KIND_ACTIONS: Record<string, ActionFunc> = Object.assign(
|
|
|
41
41
|
Object.create(null), {
|
|
42
42
|
target: action_target,
|
|
43
43
|
feature: action_feature,
|
|
44
|
-
|
|
44
|
+
edition: action_edition,
|
|
45
45
|
})
|
|
46
46
|
|
|
47
47
|
|
|
@@ -49,7 +49,7 @@ const KIND_ACTIONS: Record<string, ActionFunc> = Object.assign(
|
|
|
49
49
|
// over. Registered rather than imported by `package.ts`, for the cycle above.
|
|
50
50
|
registerAdder('target', target_add)
|
|
51
51
|
registerAdder('feature', feature_add)
|
|
52
|
-
registerAdder('
|
|
52
|
+
registerAdder('edition', edition_add)
|
|
53
53
|
|
|
54
54
|
|
|
55
55
|
function actionMap(): Record<string, ActionFunc> {
|
package/src/action/doctor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { kindCollection } from '../helpers/kindCollection'
|
|
1
2
|
// `voxgig-sdkgen doctor` — does this project's `.sdk/` still match the
|
|
2
3
|
// scaffold?
|
|
3
4
|
//
|
|
@@ -276,7 +277,7 @@ async function doctor(
|
|
|
276
277
|
|
|
277
278
|
const counts: Record<string, number> = {}
|
|
278
279
|
for (const kind of kinds) {
|
|
279
|
-
counts[kind] = Object.keys((model
|
|
280
|
+
counts[kind] = Object.keys(kindCollection(model, kind) ?? {}).length
|
|
280
281
|
}
|
|
281
282
|
|
|
282
283
|
log.info({ point: 'doctor-start', targets: counts.target ?? 0, ...counts })
|
|
@@ -303,7 +304,7 @@ async function doctor(
|
|
|
303
304
|
}
|
|
304
305
|
|
|
305
306
|
for (const kind of kinds) {
|
|
306
|
-
const items = Object.keys((model
|
|
307
|
+
const items = Object.keys(kindCollection(model, kind) ?? {}).sort()
|
|
307
308
|
|
|
308
309
|
for (const name of items) {
|
|
309
310
|
if (null != scope && !scope(kind, name)) {
|
|
@@ -321,8 +322,8 @@ async function doctor(
|
|
|
321
322
|
checkTarget(actx, source, report)
|
|
322
323
|
}
|
|
323
324
|
|
|
324
|
-
if ('
|
|
325
|
-
|
|
325
|
+
if ('edition' === kind) {
|
|
326
|
+
checkEdition(actx, source, report)
|
|
326
327
|
}
|
|
327
328
|
|
|
328
329
|
// Only an ACTIVE feature has source copied out; what an inactive one
|
|
@@ -433,7 +434,7 @@ function checkWiring(actx: ActionContext, report: DoctorReport) {
|
|
|
433
434
|
function resolveDeclared(
|
|
434
435
|
kind: string, name: string, actx: ActionContext,
|
|
435
436
|
): Source | undefined {
|
|
436
|
-
const declared: any = (actx.model
|
|
437
|
+
const declared: any = kindCollection(actx.model, kind)?.[name]
|
|
437
438
|
const ref = recordedRef(declared, name) || name
|
|
438
439
|
|
|
439
440
|
try {
|
|
@@ -659,8 +660,8 @@ function compareTrees(
|
|
|
659
660
|
// rather than being spelled a second time.
|
|
660
661
|
//
|
|
661
662
|
// The optional template tree is skipped when the SOURCE does not ship one:
|
|
662
|
-
// `
|
|
663
|
-
function
|
|
663
|
+
// `edition add` did not copy it, so the project is right not to have it.
|
|
664
|
+
function checkEdition(
|
|
664
665
|
actx: ActionContext, resolved: Source, report: DoctorReport,
|
|
665
666
|
) {
|
|
666
667
|
const fs = actx.fs()
|
|
@@ -669,8 +670,8 @@ function checkDocs(
|
|
|
669
670
|
const origname = resolved.origname
|
|
670
671
|
const aliased = name !== origname
|
|
671
672
|
|
|
672
|
-
const dest = kindTrees('
|
|
673
|
-
const from = kindTrees('
|
|
673
|
+
const dest = kindTrees('edition', name)
|
|
674
|
+
const from = kindTrees('edition', origname)
|
|
674
675
|
|
|
675
676
|
const trees: TreeCompare[] = dest.flatMap((tree: TreeDef, i: number) => {
|
|
676
677
|
const scaffold = Path.join(resolved.folder, ...from[i].path.split('/'))
|