@voxgig/sdkgen 4.2.5 → 4.2.7

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.
Files changed (71) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/helpers/naming.js +5 -4
  3. package/dist/helpers/naming.js.map +1 -1
  4. package/dist/tsconfig.tsbuildinfo +1 -1
  5. package/package.json +1 -1
  6. package/project/.sdk/model/feature/cost.aon +43 -0
  7. package/project/.sdk/model/feature/feature-index.aon +1 -0
  8. package/project/.sdk/src/cmp/go/utility_go.ts +20 -7
  9. package/project/.sdk/src/cmp/js/fragment/EntityCreateOp.fragment.js +2 -1
  10. package/project/.sdk/src/cmp/js/fragment/EntityListOp.fragment.js +2 -1
  11. package/project/.sdk/src/cmp/js/fragment/EntityLoadOp.fragment.js +2 -1
  12. package/project/.sdk/src/cmp/js/fragment/EntityRemoveOp.fragment.js +2 -1
  13. package/project/.sdk/src/cmp/js/fragment/EntityUpdateOp.fragment.js +2 -1
  14. package/project/.sdk/src/cmp/js/utility_js.ts +20 -7
  15. package/project/.sdk/src/cmp/php/ReadmeExamplesTest_php.ts +144 -12
  16. package/project/.sdk/src/cmp/py/ReadmeExamplesTest_py.ts +98 -3
  17. package/project/.sdk/src/cmp/rb/ReadmeExamplesTest_rb.ts +83 -10
  18. package/project/.sdk/src/cmp/ts/fragment/EntityCreateOp.fragment.ts +2 -1
  19. package/project/.sdk/src/cmp/ts/fragment/EntityListOp.fragment.ts +2 -1
  20. package/project/.sdk/src/cmp/ts/fragment/EntityLoadOp.fragment.ts +2 -1
  21. package/project/.sdk/src/cmp/ts/fragment/EntityRemoveOp.fragment.ts +2 -1
  22. package/project/.sdk/src/cmp/ts/fragment/EntityUpdateOp.fragment.ts +2 -1
  23. package/project/.sdk/src/cmp/ts/utility_ts.ts +20 -7
  24. package/project/.sdk/tm/c/core/sdk.h +1 -0
  25. package/project/.sdk/tm/c/feature/cost.c +549 -0
  26. package/project/.sdk/tm/c/src/feature/cost/.gitkeep +0 -0
  27. package/project/.sdk/tm/clojure/src/feature/cost/.gitkeep +0 -0
  28. package/project/.sdk/tm/cpp/feature/cost.hpp +419 -0
  29. package/project/.sdk/tm/cpp/src/feature/cost/.gitkeep +0 -0
  30. package/project/.sdk/tm/csharp/feature/CostFeature.cs +518 -0
  31. package/project/.sdk/tm/csharp/src/feature/cost/.gitkeep +0 -0
  32. package/project/.sdk/tm/dart/lib/feature/cost/CostFeature.dart +446 -0
  33. package/project/.sdk/tm/dart/src/feature/cost/.gitkeep +0 -0
  34. package/project/.sdk/tm/elixir/lib/projectname/feature/cost.ex +442 -0
  35. package/project/.sdk/tm/elixir/src/feature/cost/.gitkeep +0 -0
  36. package/project/.sdk/tm/go/feature/cost_feature.go +424 -0
  37. package/project/.sdk/tm/go/src/feature/cost/.gitkeep +0 -0
  38. package/project/.sdk/tm/go-cli/src/feature/cost/.gitkeep +0 -0
  39. package/project/.sdk/tm/go-mcp/src/feature/cost/.gitkeep +0 -0
  40. package/project/.sdk/tm/java/feature/CostFeature.java +431 -0
  41. package/project/.sdk/tm/java/src/feature/cost/.gitkeep +0 -0
  42. package/project/.sdk/tm/js/src/feature/cost/CostFeature.js +413 -0
  43. package/project/.sdk/tm/js/test/feature/Corpus.test.js +264 -0
  44. package/project/.sdk/tm/kotlin/feature/CostFeature.kt +430 -0
  45. package/project/.sdk/tm/kotlin/src/feature/cost/.gitkeep +0 -0
  46. package/project/.sdk/tm/lean/src/feature/cost/.gitkeep +0 -0
  47. package/project/.sdk/tm/lua/feature/cost_feature.lua +457 -0
  48. package/project/.sdk/tm/lua/src/feature/cost/.gitkeep +0 -0
  49. package/project/.sdk/tm/ocaml/src/feature/cost/.gitkeep +0 -0
  50. package/project/.sdk/tm/perl/feature/cost_feature.pm +446 -0
  51. package/project/.sdk/tm/perl/src/feature/cost/.gitkeep +0 -0
  52. package/project/.sdk/tm/php/feature/CostFeature.php +439 -0
  53. package/project/.sdk/tm/php/feature/TestFeature.php +31 -1
  54. package/project/.sdk/tm/php/src/feature/cost/.gitkeep +0 -0
  55. package/project/.sdk/tm/py/pkg/feature/cost_feature.py +381 -0
  56. package/project/.sdk/tm/py/src/feature/cost/.gitkeep +0 -0
  57. package/project/.sdk/tm/py-data/src/feature/cost/.gitkeep +0 -0
  58. package/project/.sdk/tm/rb/feature/cost_feature.rb +386 -0
  59. package/project/.sdk/tm/rb/src/feature/cost/.gitkeep +0 -0
  60. package/project/.sdk/tm/rust/feature/cost.rs +467 -0
  61. package/project/.sdk/tm/rust/src/feature/cost/.gitkeep +0 -0
  62. package/project/.sdk/tm/scala/feature/CostFeature.scala +388 -0
  63. package/project/.sdk/tm/scala/src/feature/cost/.gitkeep +0 -0
  64. package/project/.sdk/tm/swift/Sources/ProjectNameSDK/feature/CostFeature.swift +425 -0
  65. package/project/.sdk/tm/swift/src/feature/cost/.gitkeep +0 -0
  66. package/project/.sdk/tm/ts/src/feature/cost/CostFeature.ts +415 -0
  67. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +266 -0
  68. package/project/.sdk/tm/zig/feature/cost.zig +457 -0
  69. package/project/.sdk/tm/zig/src/feature/cost/.gitkeep +0 -0
  70. package/project/sdkgen-package.json +2 -1
  71. package/src/helpers/naming.ts +5 -4
@@ -0,0 +1,413 @@
1
+
2
+
3
+ const { BaseFeature } = require('../base/BaseFeature')
4
+
5
+
6
+ // Cost tracking and spend budget. Uses BOTH seams, which is the point of
7
+ // the feature: money is spent per HTTP ATTEMPT (a retried call is charged
8
+ // again, because the upstream API charges it again), but it is owed by an
9
+ // OPERATION. So the transport wrap prices each attempt, and PreDone
10
+ // attributes the running total to `<entity>.<op>` and to the caller
11
+ // (`ctrl.actor`, the same actor the audit feature records).
12
+ //
13
+ // The price of an attempt comes from the first source that answers:
14
+ // a response header (`header` x `perUnit`), the rate table (`rates`, keyed
15
+ // '<entity>.<op>' / '<op>' / '*'), then the flat `unit`. A body figure
16
+ // (`path` x `perUnit`, e.g. 'usage.total_tokens') is read at PreDone
17
+ // instead, from the already-parsed result: the response body is a one-shot
18
+ // stream, and consuming it at the transport seam would leave the pipeline
19
+ // with nothing. A body figure describes the whole call, so it REPLACES the
20
+ // per-attempt estimate rather than adding to it.
21
+ //
22
+ // `budget` caps total spend. With `onBudget: 'deny'` a further operation is
23
+ // refused at PrePoint, before an endpoint is resolved and before anything
24
+ // reaches the network.
25
+ //
26
+ // ORDER MATTERS. Cost must sit INSIDE the cache, or a response served from
27
+ // cache is charged for money that was never spent. The default (map) order
28
+ // puts cache innermost and cost outside it, so activate them in array form
29
+ // with cost first: [{ name: 'cost' }, { name: 'cache' }].
30
+ class CostFeature extends BaseFeature {
31
+ version = '0.0.1'
32
+ name = 'cost'
33
+ active = true
34
+
35
+ _client
36
+ _options = {}
37
+ _pending = new WeakMap()
38
+ _seq = 0
39
+
40
+
41
+ init(ctx, options) {
42
+ this._client = ctx.client
43
+ this._options = options || {}
44
+ this.active = options.active
45
+ this._pending = new WeakMap()
46
+ this._seq = 0
47
+
48
+ const limit = this._limit()
49
+ const client = this._client
50
+ if (null == client._cost) {
51
+ client._cost = {
52
+ currency: this._options.currency || 'USD',
53
+ total: { calls: 0, attempts: 0, amount: 0, reported: 0, estimated: 0 },
54
+ ops: {},
55
+ actors: {},
56
+ budget: { limit, spent: 0, remaining: limit, exceeded: false },
57
+ last: undefined,
58
+ }
59
+ }
60
+
61
+ if (!this.active) {
62
+ return
63
+ }
64
+
65
+ const self = this
66
+ const utility = ctx.utility
67
+ const inner = utility.fetcher
68
+
69
+ utility.fetcher = async function (ctx2, url, fetchdef) {
70
+ return self._charge(ctx2, url, fetchdef, inner)
71
+ }
72
+ }
73
+
74
+
75
+ // Budget gate. Runs before endpoint resolution, so a refused call costs
76
+ // nothing at all.
77
+ PrePoint(ctx) {
78
+ if (!this.active) {
79
+ return
80
+ }
81
+
82
+ // Mark the context as running through the pipeline, so _charge knows a
83
+ // PreDone is coming and does not commit the spend itself.
84
+ let pending = this._pending.get(ctx)
85
+ if (null == pending) {
86
+ pending = this._newPending()
87
+ this._pending.set(ctx, pending)
88
+ }
89
+ pending.piped = true
90
+
91
+ const limit = this._limit()
92
+ if (0 >= limit) {
93
+ return
94
+ }
95
+
96
+ const client = this._client
97
+ const cost = client._cost
98
+ if (cost.total.amount < limit) {
99
+ return
100
+ }
101
+
102
+ cost.budget.exceeded = true
103
+
104
+ if ('deny' !== this._options.onBudget) {
105
+ return
106
+ }
107
+
108
+ const err = ctx.error('cost_budget',
109
+ 'Cost budget of ' + limit + ' ' + cost.currency + ' is spent (' +
110
+ cost.total.amount + ' ' + cost.currency + ' used)')
111
+ // Short-circuit endpoint resolution; the pipeline surfaces this error.
112
+ ctx.out.point = err
113
+ return err
114
+ }
115
+
116
+
117
+ async _charge(ctx, url, fetchdef, inner) {
118
+ let res
119
+ let threw = false
120
+
121
+ // A rejecting transport still costs an attempt. Without this, a run of
122
+ // connection-level failures under `retry` (which catches the throw and
123
+ // tries again) would be charged nothing at all, and an onBudget: 'deny'
124
+ // ceiling could never stop it.
125
+ try {
126
+ res = await inner(ctx, url, fetchdef)
127
+ }
128
+ catch (err) {
129
+ threw = true
130
+ res = err
131
+ }
132
+
133
+ const priced = this._price(ctx, res)
134
+ const client = this._client
135
+ const cost = client._cost
136
+
137
+ let pending = this._pending.get(ctx)
138
+ if (null == pending) {
139
+ pending = this._newPending()
140
+ this._pending.set(ctx, pending)
141
+ }
142
+
143
+ pending.attempts++
144
+
145
+ // Accumulated here, committed once at PreDone. Adding each attempt to
146
+ // the running total and then subtracting it again when a body figure
147
+ // supersedes it loses precision to catastrophic cancellation
148
+ // (5 + (0.01 - 5) is not 0.01 in binary floating point).
149
+ //
150
+ // Reported and estimated are kept apart per ATTEMPT, not per operation:
151
+ // a 503 priced from the rate table followed by a 200 carrying the cost
152
+ // header is part estimate, part reported, and collapsing both into the
153
+ // final attempt's category would corrupt the split.
154
+ pending.amount += priced.amount
155
+ pending[('header' === priced.source || 'body' === priced.source) ?
156
+ 'reported' : 'estimated'] += priced.amount
157
+ pending.source = priced.source
158
+
159
+ cost.total.attempts++
160
+
161
+ // direct() and graphql() call the transport through _rawRequest, which
162
+ // dispatches no pipeline hooks at all — no PrePoint to gate on, and no
163
+ // PreDone to commit. Their spend is committed here instead, or it would
164
+ // never be counted and could run past an onBudget: 'deny' ceiling
165
+ // indefinitely. `piped` is set by PrePoint, so its absence is the signal.
166
+ if (!pending.piped) {
167
+ this._commit(ctx, pending, '_', 'direct')
168
+ this._pending.delete(ctx)
169
+ }
170
+
171
+ if (threw) {
172
+ throw res
173
+ }
174
+
175
+ return res
176
+ }
177
+
178
+
179
+ _newPending() {
180
+ return { attempts: 0, amount: 0, reported: 0, estimated: 0, source: 'none', piped: false }
181
+ }
182
+
183
+
184
+ // Attribute the operation's spend once the call is finished.
185
+ PreDone(ctx) {
186
+ this._finish(ctx, true)
187
+ }
188
+
189
+
190
+ // A failed operation still spent the money. When the pipeline throws,
191
+ // PreDone never runs, so without this the attempts are counted and the
192
+ // spend is not — and a budget could never see the cost of a call that
193
+ // failed. Committing is once-per-operation either way: whichever hook
194
+ // fires first consumes the pending entry.
195
+ PreUnexpected(ctx) {
196
+ this._finish(ctx, false)
197
+ }
198
+
199
+
200
+ _finish(ctx, done) {
201
+ if (!this.active) {
202
+ return
203
+ }
204
+ const pending = this._pending.get(ctx)
205
+ if (null == pending) {
206
+ return
207
+ }
208
+ this._pending.delete(ctx)
209
+
210
+ // A FAILED operation that made no attempt never reached the network:
211
+ // PrePoint creates the pending entry to mark the context as piped, and
212
+ // then the budget gate refuses the call (rbac, or an unresolvable
213
+ // endpoint, short-circuits just as early). Committing it would count a
214
+ // call that never happened and file a zero-amount record as `last`.
215
+ //
216
+ // A SUCCEEDED operation that made no attempt is the opposite case: it was
217
+ // served from the cache. That is a real call, and the fact that it cost
218
+ // nothing is the whole point of ordering cost inside the cache.
219
+ if (!done && 0 === pending.attempts) {
220
+ return
221
+ }
222
+
223
+ const entity = (ctx.op && ctx.op.entity) || '_'
224
+ const opname = (ctx.op && ctx.op.name) || '_'
225
+
226
+ this._commit(ctx, pending, entity, opname)
227
+ }
228
+
229
+
230
+ // Commit one operation's spend: totals, budget, per-op and per-actor
231
+ // attribution, and the record. Shared by PreDone and the raw-request path
232
+ // in _charge, which has no PreDone to reach.
233
+ _commit(ctx, pending, entity, opname) {
234
+ const client = this._client
235
+ const cost = client._cost
236
+
237
+ let amount = pending.amount
238
+ let reported = pending.reported
239
+ let estimated = pending.estimated
240
+ let source = pending.source
241
+
242
+ // A body figure prices the whole call, so it replaces the per-attempt
243
+ // estimate rather than adding to it — and, being server-stated, the
244
+ // whole amount counts as reported.
245
+ const body = this._body(ctx)
246
+ if (null != body) {
247
+ amount = body
248
+ reported = body
249
+ estimated = 0
250
+ source = 'body'
251
+ }
252
+
253
+ this._spend(cost, amount, reported, estimated)
254
+ const actor = (ctx.ctrl && ctx.ctrl.actor) || this._options.actor || 'anonymous'
255
+
256
+ cost.total.calls++
257
+ this._bump(cost.ops, entity + '.' + opname, amount)
258
+ this._bump(cost.actors, actor, amount)
259
+
260
+ this._seq++
261
+ const record = {
262
+ seq: this._seq,
263
+ entity,
264
+ op: opname,
265
+ actor,
266
+ amount,
267
+ currency: cost.currency,
268
+ source,
269
+ attempts: pending.attempts,
270
+ }
271
+ cost.last = record
272
+
273
+ const sink = this._options.sink
274
+ if ('function' === typeof sink) {
275
+ try { sink(record) } catch (_e) { }
276
+ }
277
+ }
278
+
279
+
280
+ // Price one attempt: a reported header figure, else the rate table, else
281
+ // the flat unit.
282
+ _price(ctx, res) {
283
+ const header = this._options.header
284
+ if ('string' === typeof header && '' !== header) {
285
+ const v = this._header(res, header)
286
+ if (null != v) {
287
+ return { amount: v * this._perUnit(), source: 'header' }
288
+ }
289
+ }
290
+
291
+ const rate = this._rate(ctx)
292
+ if (null != rate) {
293
+ return { amount: rate, source: 'table' }
294
+ }
295
+
296
+ const unit = this._options.unit
297
+ if ('number' === typeof unit && 0 !== unit) {
298
+ return { amount: unit, source: 'unit' }
299
+ }
300
+
301
+ return { amount: 0, source: 'none' }
302
+ }
303
+
304
+
305
+ // The rate table uses the same lookup grammar as rbac's rules:
306
+ // '<entity>.<op>', then '<op>', then '*'.
307
+ _rate(ctx) {
308
+ const rates = this._options.rates || {}
309
+ const entity = (ctx.entity && ctx.entity.name) || (ctx.op && ctx.op.entity) || ''
310
+ const opname = (ctx.op && ctx.op.name) || ''
311
+
312
+ if ('number' === typeof rates[entity + '.' + opname]) {
313
+ return rates[entity + '.' + opname]
314
+ }
315
+ if ('number' === typeof rates[opname]) {
316
+ return rates[opname]
317
+ }
318
+ if ('number' === typeof rates['*']) {
319
+ return rates['*']
320
+ }
321
+ return null
322
+ }
323
+
324
+
325
+ // A usage figure from the parsed result body, priced by perUnit. Read
326
+ // here, not at the transport seam, because the body is one-shot.
327
+ _body(ctx) {
328
+ const path = this._options.path
329
+ if ('string' !== typeof path || '' === path) {
330
+ return null
331
+ }
332
+ const result = ctx.result
333
+ if (null == result || null == result.body || 'object' !== typeof result.body) {
334
+ return null
335
+ }
336
+ const v = ctx.utility.struct.getpath(result.body, path)
337
+ const n = Number(v)
338
+ if (null == v || isNaN(n)) {
339
+ return null
340
+ }
341
+ return n * this._perUnit()
342
+ }
343
+
344
+
345
+ _spend(cost, amount, reported, estimated) {
346
+ cost.total.amount += amount
347
+ cost.total.reported += reported
348
+ cost.total.estimated += estimated
349
+
350
+ const limit = cost.budget.limit
351
+ cost.budget.spent = cost.total.amount
352
+ cost.budget.remaining = 0 < limit ? Math.max(0, limit - cost.total.amount) : 0
353
+ if (0 < limit && cost.total.amount >= limit) {
354
+ cost.budget.exceeded = true
355
+ }
356
+ }
357
+
358
+
359
+ _bump(bucket, key, amount) {
360
+ let b = bucket[key]
361
+ if (null == b) {
362
+ b = bucket[key] = { calls: 0, amount: 0 }
363
+ }
364
+ b.calls++
365
+ b.amount += amount
366
+ }
367
+
368
+
369
+ _header(res, name) {
370
+ if (null == res || null == res.headers) {
371
+ return null
372
+ }
373
+ let v
374
+ if ('function' === typeof res.headers.get) {
375
+ v = res.headers.get(name.toLowerCase())
376
+ }
377
+ else {
378
+ // A plain header map from a custom system.fetch keeps conventional
379
+ // casing ('X-Request-Cost'), and HTTP header names are
380
+ // case-insensitive, so scan rather than index. The go, perl and php
381
+ // ports already do this.
382
+ const lower = name.toLowerCase()
383
+ for (const k of Object.keys(res.headers)) {
384
+ if (k.toLowerCase() === lower) {
385
+ v = res.headers[k]
386
+ break
387
+ }
388
+ }
389
+ }
390
+ if (null == v) {
391
+ return null
392
+ }
393
+ const n = Number(v)
394
+ return isNaN(n) ? null : n
395
+ }
396
+
397
+
398
+ _perUnit() {
399
+ const p = this._options.perUnit
400
+ return 'number' === typeof p ? p : 0
401
+ }
402
+
403
+
404
+ _limit() {
405
+ const b = this._options.budget
406
+ return 'number' === typeof b ? b : 0
407
+ }
408
+ }
409
+
410
+
411
+ module.exports = {
412
+ CostFeature
413
+ }
@@ -0,0 +1,264 @@
1
+
2
+ // Feature behaviour, driven by the SHARED corpus.
3
+ //
4
+ // This is the route PrimaryUtility.test.ts already takes for the utilities:
5
+ // language-neutral cases in .sdk/test/test.json, executed against the REAL
6
+ // generated SDK. Features here are ordinary classes in ordinary compiled
7
+ // source, unit-tested the ordinary way — no transpiled templates, and no
8
+ // miniature of the pipeline standing in for the pipeline (which is what
9
+ // harness.ts does, and why its assertions can only be as right as the
10
+ // miniature is). A feature is built through the generated config, wrapped
11
+ // into a client built by the generated constructor, and driven by a real
12
+ // entity operation. What is asserted is what ships.
13
+ //
14
+ // Everything in a case is data: features are activated by name, options are
15
+ // plain JSON, the transport is scripted by `res`, and the assertion is a
16
+ // subset of the client's own record. Turning `res` into a fetcher is the one
17
+ // piece each language writes for itself.
18
+ //
19
+ // The ts twin of this file is the reference; keep the two in step.
20
+
21
+ const { test, describe, before } = require('node:test')
22
+ const { ok, deepStrictEqual } = require('node:assert')
23
+
24
+ const { readFileSync } = require('node:fs')
25
+ const { join } = require('node:path')
26
+
27
+ const { SDK, TEST_JSON_FILE } = require('../utility/index')
28
+
29
+
30
+ // Features with a corpus section. A name here with no section is a skip, not
31
+ // a failure: an SDK generated without the feature has nothing to run.
32
+ const FEATURES = ['cost']
33
+
34
+
35
+ // One operation this SDK can actually perform is described by
36
+ // { key, accessor, entity, op }: `key` is '<entity>.<op>', how features
37
+ // attribute spend, and `accessor` is the client method returning the entity.
38
+
39
+
40
+ // A scripted transport built from a case's `res` list. Responses are consumed
41
+ // in order and the last one repeats, so a case that does not care how many
42
+ // attempts happen need only declare one.
43
+ function scriptedFetcher(res) {
44
+ let n = -1
45
+ return async function (_ctx, _url, _fetchdef) {
46
+ n++
47
+ const spec = res[n < res.length ? n : res.length - 1] || {}
48
+
49
+ if (true === spec.throw) {
50
+ throw new Error('scripted transport failure')
51
+ }
52
+
53
+ const headers = spec.headers || {}
54
+ const status = null == spec.status ? 200 : spec.status
55
+
56
+ return {
57
+ status,
58
+ statusText: status < 400 ? 'OK' : 'ERR',
59
+ body: 'not-used',
60
+ json: async () => (undefined === spec.body ? {} : spec.body),
61
+ headers: {
62
+ get(key) {
63
+ const lower = String(key).toLowerCase()
64
+ for (const k of Object.keys(headers)) {
65
+ if (k.toLowerCase() === lower) { return headers[k] }
66
+ }
67
+ return undefined
68
+ },
69
+ forEach(cb) { Object.keys(headers).forEach((k) => cb(headers[k], k, this)) },
70
+ },
71
+ }
72
+ }
73
+ }
74
+
75
+
76
+ function makeClient(kase) {
77
+ return new SDK({
78
+ feature: kase.feature,
79
+ utility: { fetcher: scriptedFetcher(kase.res || [{ status: 200, body: {} }]) },
80
+ })
81
+ }
82
+
83
+
84
+ // Every operation this SDK declares, in a stable order.
85
+ //
86
+ // The corpus cannot name an entity — it is shared by SDKs that have none in
87
+ // common — so the runner finds them here. The generated client exposes one
88
+ // capitalised, zero-argument accessor per entity, and the entity it returns
89
+ // carries the same `name` the config is keyed by; that pairing is what turns
90
+ // a config entry back into a callable method.
91
+ function candidates(client) {
92
+ const entities = client._rootctx.config.entity || {}
93
+
94
+ const accessor = {}
95
+ for (const m of Object.getOwnPropertyNames(Object.getPrototypeOf(client))) {
96
+ if (!/^[A-Z]/.test(m) || 'function' !== typeof client[m]) { continue }
97
+ let inst
98
+ try { inst = client[m]() }
99
+ catch (e) { continue }
100
+ if (null != inst && 'string' === typeof inst.name && null != entities[inst.name]) {
101
+ accessor[inst.name] = m
102
+ }
103
+ }
104
+
105
+ const out = []
106
+ for (const entity of Object.keys(entities).sort()) {
107
+ if (null == accessor[entity]) { continue }
108
+ for (const op of Object.keys(entities[entity].op || {}).sort()) {
109
+ out.push({ key: entity + '.' + op, accessor: accessor[entity], entity, op })
110
+ }
111
+ }
112
+ return out
113
+ }
114
+
115
+
116
+ // Pick operations the corpus can drive, by DRIVING them: an op is usable when
117
+ // it completes against a plain 200 with no feature active. Declared ops are
118
+ // not all callable with no arguments (a required path parameter, a body), and
119
+ // a case that failed for that reason would look like a feature defect.
120
+ async function usableOps(want) {
121
+ const picked = []
122
+ for (const cand of candidates(makeClient({}))) {
123
+ const client = makeClient({})
124
+ try {
125
+ await client[cand.accessor]()[cand.op]({}, {})
126
+ }
127
+ catch (e) { continue }
128
+ picked.push(cand)
129
+ if (want <= picked.length) { break }
130
+ }
131
+ return picked
132
+ }
133
+
134
+
135
+ // Replace #OP1/#OP2 throughout a case, keys included.
136
+ function resolve(node, tokens) {
137
+ if ('string' === typeof node) {
138
+ let s = node
139
+ for (const t of Object.keys(tokens)) { s = s.split(t).join(tokens[t]) }
140
+ return s
141
+ }
142
+ if (Array.isArray(node)) {
143
+ return node.map((n) => resolve(n, tokens))
144
+ }
145
+ if (null != node && 'object' === typeof node) {
146
+ const out = {}
147
+ for (const k of Object.keys(node)) {
148
+ out[resolve(k, tokens)] = resolve(node[k], tokens)
149
+ }
150
+ return out
151
+ }
152
+ return node
153
+ }
154
+
155
+
156
+ // Which #OPn tokens a case uses. A case wanting more operations than this SDK
157
+ // has is skipped rather than failed.
158
+ function tokensUsed(kase) {
159
+ const m = JSON.stringify(kase).match(/#OP(\d+)/g) || []
160
+ return m.reduce((max, t) => Math.max(max, Number(t.slice(3))), 0)
161
+ }
162
+
163
+
164
+ // Assert that `actual` contains `expect`, recursively. Cases assert only the
165
+ // fields they are about, so a full deepStrictEqual would force every case to
166
+ // restate the whole record.
167
+ function subset(actual, expect, path) {
168
+ if (null != expect && 'object' === typeof expect && !Array.isArray(expect)) {
169
+ for (const k of Object.keys(expect)) {
170
+ ok(null != actual, `${path}.${k}: nothing at ${path}`)
171
+ subset(actual[k], expect[k], `${path}.${k}`)
172
+ }
173
+ return
174
+ }
175
+ deepStrictEqual(actual, expect, path)
176
+ }
177
+
178
+
179
+ describe('FeatureCorpus', () => {
180
+
181
+ let corpus
182
+ let ops = []
183
+ let byKey = {}
184
+
185
+
186
+ before(async () => {
187
+ corpus = JSON.parse(readFileSync(join(__dirname, '..', TEST_JSON_FILE), 'utf8'))
188
+ ops = await usableOps(2)
189
+ byKey = {}
190
+ for (const o of ops) { byKey[o.key] = o }
191
+ })
192
+
193
+
194
+ test('the corpus carries a feature section', () => {
195
+ ok(null != corpus.feature,
196
+ 'no `feature` section in test.json — recompile the corpus')
197
+ })
198
+
199
+
200
+ // At least one operation, or every case below would skip and the whole
201
+ // suite would report green having run nothing.
202
+ test('this SDK has an operation the corpus can drive', () => {
203
+ ok(0 < ops.length,
204
+ 'no declared operation completed against a plain 200 — the corpus ' +
205
+ 'cannot exercise a feature without one')
206
+ })
207
+
208
+
209
+ for (const name of FEATURES) {
210
+
211
+ test(name, async (t) => {
212
+ const section = corpus.feature?.[name]
213
+ if (null == section) {
214
+ return t.skip(`no corpus section for ${name}`)
215
+ }
216
+
217
+ const probe = makeClient({})
218
+ if (!probe._rootctx.config.hasFeature(name)) {
219
+ return t.skip(`this SDK was generated without the ${name} feature`)
220
+ }
221
+
222
+ const cases = section.basic?.set || []
223
+ ok(0 < cases.length,
224
+ `corpus section feature.${name} ran ZERO cases — a renamed section ` +
225
+ `or an emptied fixture must fail loudly, not pass silently`)
226
+
227
+ let ran = 0
228
+ for (const raw of cases) {
229
+ const need = tokensUsed(raw)
230
+ if (ops.length < need) {
231
+ t.diagnostic(`skip "${raw.name}": needs ${need} operations, this SDK offers ${ops.length}`)
232
+ continue
233
+ }
234
+
235
+ const tokens = {}
236
+ for (let i = 0; i < need; i++) { tokens['#OP' + (i + 1)] = ops[i].key }
237
+
238
+ const kase = resolve(raw, tokens)
239
+ const client = makeClient(kase)
240
+
241
+ for (const step of (kase.op || [])) {
242
+ const ref = byKey[step.op]
243
+ ok(null != ref, `${kase.name}: no operation ${step.op}`)
244
+ try {
245
+ await client[ref.accessor]()[ref.op]({}, step.ctrl || {})
246
+ ok(null == step.err,
247
+ `${kase.name}: ${step.op} was expected to fail, and did not`)
248
+ }
249
+ catch (err) {
250
+ if (null == step.err) { throw err }
251
+ if ('string' === typeof step.err) {
252
+ deepStrictEqual(err.code, step.err, `${kase.name}: wrong error code`)
253
+ }
254
+ }
255
+ }
256
+
257
+ subset(client[`_${name}`], kase.out, `${kase.name}: _${name}`)
258
+ ran++
259
+ }
260
+
261
+ ok(0 < ran, `every feature.${name} case was skipped`)
262
+ })
263
+ }
264
+ })