@voxgig/sdkgen 4.15.0 → 4.17.0

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 (104) hide show
  1. package/README.md +8 -0
  2. package/bin/voxgig-sdkgen +2 -2
  3. package/dist/action/action.js +2 -2
  4. package/dist/action/action.js.map +1 -1
  5. package/dist/action/check.js +9 -8
  6. package/dist/action/check.js.map +1 -1
  7. package/dist/action/dispatch.js +4 -4
  8. package/dist/action/dispatch.js.map +1 -1
  9. package/dist/action/doctor.js +16 -15
  10. package/dist/action/doctor.js.map +1 -1
  11. package/dist/action/edition.d.ts +4 -0
  12. package/dist/action/{docs.js → edition.js} +57 -54
  13. package/dist/action/edition.js.map +1 -0
  14. package/dist/action/kind.d.ts +1 -2
  15. package/dist/action/kind.js +22 -38
  16. package/dist/action/kind.js.map +1 -1
  17. package/dist/action/package.js +17 -16
  18. package/dist/action/package.js.map +1 -1
  19. package/dist/action/resolve.d.ts +3 -1
  20. package/dist/action/resolve.js +29 -11
  21. package/dist/action/resolve.js.map +1 -1
  22. package/dist/admin/status.d.ts +43 -0
  23. package/dist/admin/status.js +161 -0
  24. package/dist/admin/status.js.map +1 -0
  25. package/dist/cmp/Test.js +11 -0
  26. package/dist/cmp/Test.js.map +1 -1
  27. package/dist/helpers/kindCollection.d.ts +1 -0
  28. package/dist/helpers/kindCollection.js +14 -0
  29. package/dist/helpers/kindCollection.js.map +1 -0
  30. package/dist/helpers/modelcheck.d.ts +1 -1
  31. package/dist/helpers/modelcheck.js +13 -3
  32. package/dist/helpers/modelcheck.js.map +1 -1
  33. package/dist/helpers/shipped.js +2 -3
  34. package/dist/helpers/shipped.js.map +1 -1
  35. package/dist/helpers/testPolicy.d.ts +1 -1
  36. package/dist/helpers/testPolicy.js +6 -37
  37. package/dist/helpers/testPolicy.js.map +1 -1
  38. package/dist/sdkgen.d.ts +7 -1
  39. package/dist/sdkgen.js +146 -89
  40. package/dist/sdkgen.js.map +1 -1
  41. package/dist/tsconfig.tsbuildinfo +1 -1
  42. package/dist/utility.js +9 -1
  43. package/dist/utility.js.map +1 -1
  44. package/model/sdkgen.aon +5 -52
  45. package/package.json +1 -1
  46. package/project/.sdk/src/cmp/go/Config_go.ts +4 -4
  47. package/project/.sdk/src/cmp/go/utility_go.ts +10 -2
  48. package/project/.sdk/src/cmp/js/Package_js.ts +5 -1
  49. package/project/.sdk/src/cmp/js/TestDirect_js.ts +11 -7
  50. package/project/.sdk/src/cmp/js/TestEntity_js.ts +16 -2
  51. package/project/.sdk/src/cmp/js/TestLive_js.ts +32 -0
  52. package/project/.sdk/src/cmp/js/Test_js.ts +2 -0
  53. package/project/.sdk/src/cmp/js/fragment/Direct.test.fragment.js +1 -1
  54. package/project/.sdk/src/cmp/js/fragment/Entity.test.fragment.js +4 -2
  55. package/project/.sdk/src/cmp/php/ReadmeExamplesTest_php.ts +30 -9
  56. package/project/.sdk/src/cmp/php/ReadmeHowto_php.ts +8 -3
  57. package/project/.sdk/src/cmp/php/ReadmeQuick_php.ts +5 -4
  58. package/project/.sdk/src/cmp/php/ReadmeTopQuick_php.ts +2 -2
  59. package/project/.sdk/src/cmp/ts/Package_ts.ts +5 -1
  60. package/project/.sdk/src/cmp/ts/ReadmeHowto_ts.ts +5 -0
  61. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +15 -11
  62. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +13 -16
  63. package/project/.sdk/src/cmp/ts/TestLive_ts.ts +32 -0
  64. package/project/.sdk/src/cmp/ts/Test_ts.ts +2 -0
  65. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +1 -0
  66. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +2 -0
  67. package/project/.sdk/tm/csharp/test/feature/secrets/SecretsFeatureTest.cs +9 -2
  68. package/project/.sdk/tm/js/test/live-contract.js +183 -0
  69. package/project/.sdk/tm/js/test/live-entity.js +169 -0
  70. package/project/.sdk/tm/js/test/live-runner.js +129 -0
  71. package/project/.sdk/tm/js/test/live-scenarios.js +142 -0
  72. package/project/.sdk/tm/js/test/utility.js +84 -0
  73. package/project/.sdk/tm/ts/test/live-contract.ts +104 -0
  74. package/project/.sdk/tm/ts/test/live-entity.ts +142 -0
  75. package/project/.sdk/tm/ts/test/live-runner.ts +166 -0
  76. package/project/.sdk/tm/ts/test/live-scenarios.ts +112 -0
  77. package/project/.sdk/tm/ts/test/utility.ts +1 -2
  78. package/project/sdkgen-package.json +1 -1
  79. package/src/action/action.ts +2 -2
  80. package/src/action/check.ts +3 -2
  81. package/src/action/dispatch.ts +4 -4
  82. package/src/action/doctor.ts +10 -9
  83. package/src/action/{docs.ts → edition.ts} +56 -53
  84. package/src/action/kind.ts +12 -33
  85. package/src/action/package.ts +4 -3
  86. package/src/action/resolve.ts +26 -2
  87. package/src/admin/status.ts +124 -0
  88. package/src/cmp/Test.ts +13 -1
  89. package/src/helpers/kindCollection.ts +11 -0
  90. package/src/helpers/modelcheck.ts +10 -3
  91. package/src/helpers/shipped.ts +2 -3
  92. package/src/helpers/testPolicy.ts +7 -45
  93. package/src/sdkgen.ts +189 -93
  94. package/src/utility.ts +8 -2
  95. package/dist/action/docs.d.ts +0 -4
  96. package/dist/action/docs.js.map +0 -1
  97. package/dist/cmp/Docs.d.ts +0 -4
  98. package/dist/cmp/Docs.js +0 -98
  99. package/dist/cmp/Docs.js.map +0 -1
  100. package/dist/cmp/ExternalDocs.d.ts +0 -2
  101. package/dist/cmp/ExternalDocs.js +0 -43
  102. package/dist/cmp/ExternalDocs.js.map +0 -1
  103. package/src/cmp/Docs.ts +0 -125
  104. package/src/cmp/ExternalDocs.ts +0 -54
package/model/sdkgen.aon CHANGED
@@ -28,21 +28,10 @@ main: kit: config: repr: *'auto' | 'literal' | 'data'
28
28
  main: kit: test: {
29
29
 
30
30
  live: {
31
- # Whether a live run ASSERTS or merely observes.
32
- #
33
- # false (the default, and the right answer for a fleet SDK generated
34
- # against an arbitrary third-party API): a non-2xx in live mode is an
35
- # early return, not a failure. Synthetic IDs 4xx constantly and list
36
- # response shapes vary wildly, so asserting would mean permanent red.
37
- #
38
- # true: live assertions match the offline ones — a non-2xx FAILS, and a
39
- # missing idmap entry fails rather than skipping. Set this when the
40
- # project OWNS the server it tests against; otherwise a live suite passes
41
- # with nothing listening on the port, which is what it did.
42
- #
43
- # Overridable per target:
44
- # main: kit: target: go: test: live: strict: true
45
- strict: *false | boolean
31
+ # Assert live request outcomes, while continuing independent work.
32
+ # Explicit false retains the legacy exploratory direct-test policy.
33
+ # Per-target overrides remain available for migration.
34
+ strict: *true | boolean
46
35
 
47
36
  # The base URL a live run points at. '' derives it from the spec's first
48
37
  # declared server, but ONLY when that is a loopback address.
@@ -585,40 +574,4 @@ main: kit: option: &: {
585
574
 
586
575
 
587
576
 
588
- # DOCS ITEMS the third kind. See docs/design/sdkgen-packages.md §20.
589
- #
590
- # A docs item is a generation target whose destination is a DOCUMENTATION
591
- # SYSTEM rather than a language: a static site, a developer-portal catalogue,
592
- # a hosted service's config. sdkgen ships the kind; the items live in
593
- # packages, so this spread declares only what EVERY destination needs and
594
- # leaves the rest to the item's own model file (an unknown key unifies fine).
595
- #
596
- # Deliberately NOT copied from the target spread: `ext`, `comment.line` and
597
- # `module.name` are required there and have no honest value for a site
598
- # emitting .md, .yml, .css and .svg at once. `title` is defaulted rather than
599
- # required, so an author is never forced to invent one to make the model
600
- # compile — the `module.name` trap of §10.1.
601
- main: kit: docs: &: {
602
- name: key()
603
- active: *true | boolean
604
- title: *'' | string
605
-
606
- # Provenance — see the note on `main: kit: target: &:` above. Written by
607
- # `docs add`, read by doctor, `package list` and `package update`.
608
- base: *'' | string
609
- origname: *'' | string
610
- package: *'' | string
611
-
612
- # Where the item generates. '' is in-tree, at `<sdk-repo>/<name>/`;
613
- # anything else is its own generate() pass rooted there, exactly as for a
614
- # target (see explanation/out-of-tree-targets). Out of tree is the normal
615
- # case for a docs item: a documentation site usually has its own repo, and
616
- # in-tree it would collide with the SDK repo's own README and workflows.
617
- output: {
618
- path: *'' | string
619
- repo: *'' | string
620
- create: *true | boolean
621
- adopt: *false | boolean
622
- sdkrel: *'' | string
623
- }
624
- }
577
+ # Documentation editions extend main.kit.doc through @voxgig/docgen/model/docgen.aon.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@voxgig/sdkgen",
3
- "version": "4.15.0",
3
+ "version": "4.17.0",
4
4
  "main": "dist/sdkgen.js",
5
5
  "type": "commonjs",
6
6
  "engines": {
@@ -30,6 +30,7 @@ import {
30
30
 
31
31
  import {
32
32
  formatGoMap,
33
+ formatGoString,
33
34
  goFeatureName,
34
35
  } from './utility_go'
35
36
 
@@ -133,9 +134,8 @@ const Config = cmp(async function Config(props: any) {
133
134
  // 2.1x smaller. MakeConfig still returns the same map, so nothing
134
135
  // downstream can tell which representation it got.
135
136
  //
136
- // JSON.stringify output is a valid Go interpreted string literal: JSON
137
- // escapes are a subset of Go's, and Go source is UTF-8 so non-ASCII needs
138
- // no escaping. A raw (backtick) literal could NOT be used - the model
137
+ // JSON escapes work in Go interpreted strings; formatGoString also
138
+ // escapes BOM characters that Go forbids literally inside source files. A raw (backtick) literal could NOT be used - the model
139
139
  // contains backticks in values like `$STRING`.
140
140
  if (asData) {
141
141
  Content(`package core
@@ -148,7 +148,7 @@ ${pluginImportBlock})
148
148
 
149
149
  // The API model, emitted as data rather than as a composite literal: see
150
150
  // sdkgen rung L1. Parsed by MakeConfig, and parsed once by SharedConfig.
151
- const configJSON = ${JSON.stringify(configJson)}
151
+ const configJSON = ${formatGoString(configJson)}
152
152
 
153
153
  // json.Unmarshal decodes EVERY JSON number as float64, but the literal
154
154
  // representation emits an integer token as an untyped constant that lands in
@@ -127,7 +127,7 @@ function formatGoMap(obj: any, indent: number = 0): string {
127
127
  return 'map[string]any{}'
128
128
  }
129
129
  const items = entries
130
- .map(([k, v]) => `${padInner}"${k}": ${formatGoValue(v, indent + 1)}`)
130
+ .map(([k, v]) => `${padInner}${formatGoString(k)}: ${formatGoValue(v, indent + 1)}`)
131
131
  .join(',\n')
132
132
  return `map[string]any{\n${items},\n${pad}}`
133
133
  }
@@ -136,12 +136,19 @@ function formatGoMap(obj: any, indent: number = 0): string {
136
136
  }
137
137
 
138
138
 
139
+ // Go rejects a literal BOM anywhere after the start of a source file,
140
+ // including inside strings. Preserve its value through an escaped literal.
141
+ function formatGoString(value: string): string {
142
+ return JSON.stringify(value).replace(/\uFEFF/g, '\\ufeff')
143
+ }
144
+
145
+
139
146
  function formatGoValue(val: any, indent: number = 0): string {
140
147
  if (val === null || val === undefined) {
141
148
  return 'nil'
142
149
  }
143
150
  if (typeof val === 'string') {
144
- return `"${val.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`
151
+ return formatGoString(val)
145
152
  }
146
153
  if (typeof val === 'number') {
147
154
  if (Number.isInteger(val)) {
@@ -251,6 +258,7 @@ export {
251
258
  clean,
252
259
  exampleValue,
253
260
  formatGoMap,
261
+ formatGoString,
254
262
  formatGoValue,
255
263
  goVarName,
256
264
  projectPath,
@@ -13,7 +13,7 @@ import {
13
13
  PUBLISHER_URL,
14
14
  packageVersion,
15
15
  authorInfo,
16
- targetFeatures,
16
+ targetFeatures, envName,
17
17
  } from '@voxgig/sdkgen'
18
18
 
19
19
 
@@ -81,6 +81,10 @@ const Package = cmp(async function Package(props: any) {
81
81
  // directly (no build step), so that is the whole package.
82
82
  files: ['src'],
83
83
  scripts: {
84
+ ...(Object.values(model.main.kit.entity || {}).some((e: any) => Object.values(e.op || {}).some((o: any) => (o.points || []).some((p: any) => p.contract && JSON.parse(p.contract.json).live))) ? {
85
+ 'test:live': `${envName(model)}_TEST_LIVE=TRUE node --test test/live.test.js`,
86
+ } : {}),
87
+
84
88
  'test': 'node --test \'test/**/*.test.js\'',
85
89
  'test-some': 'node --experimental-test-isolation=none ' +
86
90
  '--test-name-pattern=\"$TEST_PATTERN\" --test \'test/**/*.test.js\'',
@@ -96,6 +96,7 @@ const TestDirect = cmp(function TestDirect(props: any) {
96
96
 
97
97
  Slot({ name: 'directSetup' }, () => {
98
98
  Content(`
99
+ function liveScenariosActive() { return ${Object.values(model.main.kit.entity || {}).some((e: any) => Object.values(e.op || {}).some((o: any) => (o.points || []).some((p: any) => p.contract && JSON.parse(p.contract.json).live)))} && process.env.${PROJECTNAME}_TEST_LIVE === 'TRUE' }
99
100
  function directSetup(mockres) {
100
101
  const calls = []
101
102
 
@@ -220,7 +221,7 @@ ${listParamLines}
220
221
  assert(listResult.ok === true)
221
222
  const listData = listResult.data
222
223
  if (!Array.isArray(listData) || listData.length === 0) {
223
- return // skip: no entities to load in live mode
224
+ throw new Error('Live load blocked: discovery returned no usable entities')
224
225
  }
225
226
  params.id = listData[0].id
226
227
  ${ancestorParamLines}
@@ -234,7 +235,8 @@ ${loadParams.map((p: any, i: number) => ` ${jsProp('params', p.name)} = 'di
234
235
  }
235
236
 
236
237
  Content(`
237
- test('direct-load-${entity.name}', async () => {
238
+ test('direct-load-${entity.name}', async (t) => {
239
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
238
240
  const setup = directSetup({ id: 'direct01' })
239
241
  const { client, calls } = setup
240
242
 
@@ -248,7 +250,7 @@ ${liveParamsBlock}
248
250
  })
249
251
 
250
252
  assert(result.ok === true)
251
- assert(result.status === 200)
253
+ assert(setup.live ? result.status >= 200 && result.status < 300 : result.status === 200)
252
254
  assert(null != result.data)
253
255
 
254
256
  if (!setup.live) {
@@ -308,7 +310,8 @@ ${mockLines}
308
310
  }
309
311
 
310
312
  Content(`
311
- test('direct-list-${entity.name}', async () => {
313
+ test('direct-list-${entity.name}', async (t) => {
314
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
312
315
  const setup = directSetup([{ id: 'direct01' }, { id: 'direct02' }])
313
316
  const { client, calls } = setup
314
317
 
@@ -320,7 +323,7 @@ ${paramsBlock}
320
323
  })
321
324
 
322
325
  assert(result.ok === true)
323
- assert(result.status === 200)
326
+ assert(setup.live ? result.status >= 200 && result.status < 300 : result.status === 200)
324
327
  assert(Array.isArray(result.data))
325
328
 
326
329
  if (!setup.live) {
@@ -359,7 +362,8 @@ function generateDirectGraphqlJs(
359
362
  ' assert(calls[0].init.body.includes(\'direct0' + (i + 1) + '\'))\n').join('')
360
363
 
361
364
  Content(`
362
- test('direct-${opname}-${entity.name}', async () => {
365
+ test('direct-${opname}-${entity.name}', async (t) => {
366
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
363
367
  const setup = directSetup()
364
368
  const { client, calls } = setup
365
369
 
@@ -373,7 +377,7 @@ ${mockVarLines || ' // no variables'}
373
377
  const result = await client.graphql(${JSON.stringify(doc)}, variables)
374
378
 
375
379
  assert(result.ok === true)
376
- assert(result.status === 200)
380
+ assert(setup.live ? result.status >= 200 && result.status < 300 : result.status === 200)
377
381
  assert(null != result.data)
378
382
 
379
383
  if (!setup.live) {
@@ -171,7 +171,14 @@ function basicSetup(extra) {
171
171
 
172
172
  idmap = env['${PROJENVNAME}_TEST_${ENTENVNAME}_ENTID']
173
173
 
174
- if ('TRUE' === env.${PROJENVNAME}_TEST_LIVE) {
174
+ const live = 'TRUE' === env.${PROJENVNAME}_TEST_LIVE
175
+ const transport = createLiveTransport()
176
+ if (live) {
177
+ const rawIds = process.env['${PROJENVNAME}_TEST_${ENTENVNAME}_ENTID']
178
+ idmap = rawIds && rawIds.trim() ? JSON.parse(rawIds) : {}
179
+ if (!idmap || Array.isArray(idmap) || typeof idmap !== 'object') {
180
+ throw new Error('Live ENTID must be a JSON object')
181
+ }
175
182
  client = new ${model.Name}SDK(merge([
176
183
  // FIRST, so the generated fields below win: sdk-test-control.json's
177
184
  // test.client.options adds to the live client, it does not redirect it.
@@ -182,7 +189,8 @@ function basicSetup(extra) {
182
189
  // the last entry is undefined, and basicSetup is normally called with no
183
190
  // argument at all - so a bare 'extra' silently discarded the apikey and
184
191
  // server values above and handed the SDK undefined.
185
- extra || {}
192
+ extra || {},
193
+ { system: { fetch: transport.fetch } }
186
194
  ]))
187
195
  }
188
196
 
@@ -194,6 +202,8 @@ function basicSetup(extra) {
194
202
  struct,
195
203
  data: entityData,
196
204
  explain: 'TRUE' === env.${PROJENVNAME}_TEST_EXPLAIN,
205
+ live,
206
+ transport,
197
207
  now: Date.now(),
198
208
  }
199
209
 
@@ -209,7 +219,11 @@ function basicSetup(extra) {
209
219
  )
210
220
 
211
221
  Content(`
222
+ ${Object.values(model.main.kit.entity || {}).some((e: any) => Object.values(e.op || {}).some((o: any) => (o.points || []).some((p: any) => p.contract && JSON.parse(p.contract.json).live))) ? `if (process.env.${PROJENVNAME}_TEST_LIVE === 'TRUE') { t.skip('Covered by live operation scenarios'); return }` : ''}
212
223
  const setup = basicSetup()
224
+ if (setup.live) {
225
+ return runLiveEntity(setup, ${JSON.stringify(entity)}, ${JSON.stringify(basicflow)}, '${nom(entity, 'Name')}')
226
+ }
213
227
  const client = setup.client
214
228
  const struct = setup.struct
215
229
 
@@ -0,0 +1,32 @@
1
+ import { cmp, File, Content, entityCollection, envName, serverVariables, serverVarEnv } from '@voxgig/sdkgen'
2
+ import { nom } from '@voxgig/apidef'
3
+
4
+ const TestLive = cmp(function TestLive(props: any) {
5
+ const model = props.ctx$.model
6
+ const plan: any[] = []
7
+ for (const entity of Object.values(entityCollection(model)) as any[]) {
8
+ if (entity.active === false) continue
9
+ for (const [op, operation] of Object.entries(entity.op || {}) as any[]) {
10
+ for (const point of operation.points || []) {
11
+ const facts = point.contract ? JSON.parse(point.contract.json) : {}
12
+ if (model.main.kit.info?.auth === false) facts.security = []
13
+ const same = operation.points.filter((p: any) => JSON.stringify(p.select || {}) === JSON.stringify(point.select || {}))
14
+ plan.push({ entity: entity.name, accessor: nom(entity, 'Name'), op,
15
+ id: point.contract?.id || point.method + ' ' + point.orig, contractVersion: point.contract?.version, kind: point.kind, graphql: point.graphql, path: point.orig, method: point.method,
16
+ action: point.select?.$action, rename: point.rename, args: point.args, facts, reachable: same.length === 1 })
17
+ }
18
+ }
19
+ }
20
+ if (!plan.some(p => p.facts.live)) return
21
+ const server = serverVariables(model).map((v: any) => JSON.stringify(v.name) + ': process.env[' + JSON.stringify(serverVarEnv(envName(model), v.name)) + '] ?? ' + JSON.stringify(v.dflt)).join(', ')
22
+ File({ name: 'live.test.js' }, () => Content(`const { test } = require('node:test')
23
+ const { SDK } = require('..')
24
+ const { runLiveScenarios } = require('./live-scenarios')
25
+ const { loadEnvLocal } = require('./utility')
26
+ loadEnvLocal(__dirname + '/../.env.local')
27
+ test('live operation coverage', { skip: process.env.${envName(model)}_TEST_LIVE !== 'TRUE' }, async () => {
28
+ await runLiveScenarios(SDK, ${JSON.stringify(plan, null, 2)}, '${envName(model)}', { server: { ${server} }, secret: process.env.${envName(model)}_SECRET })
29
+ })
30
+ `))
31
+ })
32
+ export { TestLive }
@@ -7,6 +7,7 @@ import { cmp, each, Folder, entityCollection,
7
7
  TestControl } from '@voxgig/sdkgen'
8
8
 
9
9
 
10
+ import { TestLive } from './TestLive_js'
10
11
  import { TestDirect } from './TestDirect_js'
11
12
  import { TestEntity } from './TestEntity_js'
12
13
 
@@ -19,6 +20,7 @@ const Test = cmp(function Test(props: any) {
19
20
 
20
21
  // Write-once: a project's edited control file survives regeneration.
21
22
  TestControl({ target, dir: 'test' })
23
+ TestLive({ target })
22
24
 
23
25
  Folder({ name: 'entity' }, () => {
24
26
  const entity = each(entityCollection(model))
@@ -1,6 +1,6 @@
1
1
 
2
2
  const envlocal = __dirname + '/../../../.env.local'
3
- require('dotenv').config({ quiet: true, path: [envlocal] })
3
+ require('../../utility').loadEnvLocal(envlocal)
4
4
 
5
5
  const { test, describe, afterEach } = require('node:test')
6
6
  const assert = require('node:assert')
@@ -1,12 +1,14 @@
1
1
 
2
2
  const envlocal = __dirname + '/../../../.env.local'
3
- require('dotenv').config({ quiet: true, path: [envlocal] })
3
+ require('../../utility').loadEnvLocal(envlocal)
4
4
 
5
5
  const Path = require('node:path')
6
6
  const Fs = require('node:fs')
7
7
 
8
8
  const { test, describe, afterEach } = require('node:test')
9
9
  const assert = require('node:assert')
10
+ const { createLiveTransport } = require('../../live-runner')
11
+ const { runLiveEntity } = require('../../live-entity')
10
12
 
11
13
 
12
14
  const { ProjectNameSDK, BaseFeature, stdutil, config } = require('../../..')
@@ -36,7 +38,7 @@ describe('EntityNameEntity', async () => {
36
38
  })
37
39
 
38
40
 
39
- test('basic', async () => {
41
+ test('basic', async (t) => {
40
42
  // <[SLOT:basic]>
41
43
  })
42
44
  })
@@ -119,7 +119,7 @@ ${entityLines}
119
119
  // That only holds if the run pass can SEE a parse failure — and without
120
120
  // these two patterns it could not, so a syntax error in a runnable example
121
121
  // would have passed both gates.
122
- private const FATAL = '/(Call to undefined method|Call to undefined function|Call to a member function|ArgumentCountError|Too few arguments|Undefined constant|Uncaught TypeError|ParseError|Parse error)/';
122
+ private const FATAL = '/(Call to undefined method|Call to undefined function|Call to a member function|ArgumentCountError|Too few arguments|Undefined constant|Uncaught TypeError|ParseError|Parse error|Allowed memory size|Cannot use object of type)/';
123
123
 
124
124
  // The three documentation sources this gate covers.
125
125
  private function docs(): array
@@ -374,20 +374,14 @@ ${entityLines}
374
374
 
375
375
  $driver = $dir . '/_driver.php';
376
376
  file_put_contents($driver, $this->batchDriver($paths));
377
- $out = [];
378
- $rc = 0;
379
- exec('php ' . escapeshellarg($driver) . ' 2>&1', $out, $rc);
380
- $text = implode("\\n", $out);
377
+ [$text, $rc] = $this->runOutput('php ' . escapeshellarg($driver));
381
378
 
382
379
  foreach ($runnable as $i => $blk) {
383
380
  $seg = $this->batchSegment($text, $i);
384
381
  if ($seg === null) {
385
382
  // No END marker: the batch died inside or before this
386
383
  // snippet. Re-run it alone so the verdict is isolated.
387
- $solo = [];
388
- $src = 0;
389
- exec('php ' . escapeshellarg($paths[$i]) . ' 2>&1', $solo, $src);
390
- $seg = implode("\\n", $solo);
384
+ [$seg, $src] = $this->runOutput('php ' . escapeshellarg($paths[$i]));
391
385
  $rc = $src;
392
386
  }
393
387
  if (preg_match(self::FATAL, $seg) === 1) {
@@ -410,6 +404,33 @@ ${entityLines}
410
404
  * in flight, so the harness can tell "this one died" from "the batch
411
405
  * stopped before reaching it", and re-run only what it must.
412
406
  */
407
+ private function runOutput(string $command): array
408
+ {
409
+ // A printed entity can include the client graph. Scan the stream;
410
+ // retaining all output (and a second joined copy) exhausts PHP's heap.
411
+ $process = proc_open($command, [1 => ['pipe', 'w'], 2 => ['redirect', 1]], $pipes);
412
+ if (!is_resource($process)) throw new \RuntimeException('Could not run README example');
413
+ $summary = '';
414
+ $tail = '';
415
+ $reported = false;
416
+ while (($chunk = fgets($pipes[1], 8192)) !== false) {
417
+ if (preg_match('/@@VOX(BEGIN|END) [0-9]+/', $chunk, $marker)) {
418
+ $summary .= "\\n" . $marker[0] . "\\n";
419
+ if ($marker[1] === 'BEGIN') $reported = false;
420
+ $tail = '';
421
+ }
422
+ $window = $tail . $chunk;
423
+ if (!$reported && preg_match(self::FATAL, $window, $error, PREG_OFFSET_CAPTURE)) {
424
+ $summary .= "\\n" . substr($window, max(0, $error[0][1] - 100), 512) . "\\n";
425
+ $reported = true;
426
+ }
427
+ // Keep enough overlap to catch an error phrase split by fgets.
428
+ $tail = substr($window, -256);
429
+ }
430
+ fclose($pipes[1]);
431
+ return [$summary, proc_close($process)];
432
+ }
433
+
413
434
  private function batchDriver(array $paths): string
414
435
  {
415
436
  // NOWDOC, not heredoc. The driver is PHP source that must survive
@@ -21,7 +21,12 @@ const ReadmeHowto = cmp(function ReadmeHowto(props: any) {
21
21
  // Pick an entity with a real op (prefer a read op) — never fabricate a
22
22
  // `load` on an op-less entity like Cloudsmith's `Abort`. primaryOp is null
23
23
  // only when NO entity exposes any op (a direct()-only SDK).
24
- const { entity: exampleEntity, primaryOp } = pickExampleEntity(entity)
24
+ // Prefer records that the offline store can seed and look up by ID.
25
+ // An ID-less singleton has no key for the mock transport to resolve.
26
+ const seedable = Object.fromEntries(Object.entries(entity).filter(
27
+ ([, value]: any) => entityIdField(value) && Object.keys(value.op || {}).length))
28
+ const { entity: exampleEntity, primaryOp } = pickExampleEntity(
29
+ Object.keys(seedable).length ? seedable : entity)
25
30
  const eName = exampleEntity ? nom(exampleEntity, 'Name') : 'Entity'
26
31
  // Model-driven id key: null when the entity has no id-like field.
27
32
  const idF = exampleEntity ? entityIdField(exampleEntity) : null
@@ -51,10 +56,10 @@ const ReadmeHowto = cmp(function ReadmeHowto(props: any) {
51
56
  // The op-driven test-mode line, shown only when the SDK has an entity op.
52
57
  // A direct()-only SDK (no ops anywhere) shows a direct() call instead.
53
58
  const testModeExample = primaryOp
54
- ? `// Entity ops return the ENTITY (throws on error);
59
+ ? `// ${primaryOp === 'list' ? 'list() returns entity instances' : 'Entity ops return the ENTITY'} (throws on error);
55
60
  // call data_get() for the mock record.
56
61
  $${eName.toLowerCase()} = $client->${phpEntityAccessor(eName)}()->${primaryOp}(${testCallArg});
57
- print_r($${eName.toLowerCase()});`
62
+ print_r(${primaryOp === 'list' ? `array_map(fn($item) => $item->data_get(), $${eName.toLowerCase()})` : `$${eName.toLowerCase()}->data_get()`});`
58
63
  : `$result = $client->direct(["path" => "/api/resource", "method" => "GET"]);
59
64
  print_r($result);`
60
65
 
@@ -81,9 +81,10 @@ $client = ${ctor};
81
81
 
82
82
  \`\`\`php
83
83
  try {
84
- // list() returns an array of ${eName} records — iterate directly.
84
+ // list() returns entity instances; data_get() reads each record.
85
85
  $${eName.toLowerCase()}s = $client->${phpEntityAccessor(eName)}()->list();
86
- foreach ($${eName.toLowerCase()}s as $item) {
86
+ foreach ($${eName.toLowerCase()}s as $record) {
87
+ $item = $record->data_get();
87
88
  echo ${itemPrint} . "\\n";
88
89
  }
89
90
  } catch (\\Throwable $err) {
@@ -122,7 +123,7 @@ ${neName} is nested under ${parentName}, so provide the \`${parentParam}\`.
122
123
  try {
123
124
  // load() returns the ENTITY — call data_get() for the ${neName} record (throws on error).
124
125
  $${neVar} = $client->${phpEntityAccessor(neName)}()->load([${neMatch.join(', ')}]);
125
- print_r($${neVar});
126
+ print_r($${neVar}->data_get());
126
127
  } catch (\\Throwable $err) {
127
128
  echo "Error: " . $err->getMessage();
128
129
  }
@@ -150,7 +151,7 @@ try {
150
151
  try {
151
152
  // load() returns the ENTITY — call data_get() for the ${eName} record (throws on error).
152
153
  $${eName.toLowerCase()} = $client->${phpEntityAccessor(eName)}()->load(${loadArg});
153
- print_r($${eName.toLowerCase()});
154
+ print_r($${eName.toLowerCase()}->data_get());
154
155
  } catch (\\Throwable $err) {
155
156
  echo "Error: " . $err->getMessage();
156
157
  }
@@ -52,7 +52,7 @@ $client = ${ctor};
52
52
  if (opnames.includes('list')) {
53
53
  Content(`// List all ${eName.toLowerCase()}s (returns an array; throws on error)
54
54
  $${eName.toLowerCase()}s = $client->${phpEntityAccessor(eName)}()->list();
55
- print_r($${eName.toLowerCase()}s);
55
+ print_r(array_map(fn($item) => $item->data_get(), $${eName.toLowerCase()}s));
56
56
  `)
57
57
  hasCall = true
58
58
  }
@@ -73,7 +73,7 @@ print_r($${eName.toLowerCase()}s);
73
73
  Content(`
74
74
  // Load a specific ${eName.toLowerCase()} (returns the ENTITY; call data_get() for the record; throws on error)
75
75
  $${eName.toLowerCase()} = $client->${phpEntityAccessor(eName)}()->load(${loadArg});
76
- print_r($${eName.toLowerCase()});
76
+ print_r($${eName.toLowerCase()}->data_get());
77
77
  `)
78
78
  hasCall = true
79
79
  }
@@ -13,7 +13,7 @@ import {
13
13
  PUBLISHER_URL,
14
14
  packageVersion,
15
15
  authorInfo,
16
- targetFeatures,
16
+ targetFeatures, envName,
17
17
  } from '@voxgig/sdkgen'
18
18
 
19
19
 
@@ -83,6 +83,10 @@ const Package = cmp(async function Package(props: any) {
83
83
  // are always included by npm and need no entry.
84
84
  files: ['dist', 'src'],
85
85
  scripts: {
86
+ ...(Object.values(model.main.kit.entity || {}).some((e: any) => Object.values(e.op || {}).some((o: any) => (o.points || []).some((p: any) => p.contract && JSON.parse(p.contract.json).live))) ? {
87
+ 'test:live': `npm run build && ${envName(model)}_TEST_LIVE=TRUE node --test dist-test/live.test.js`,
88
+ } : {}),
89
+
86
90
  // `test` and `test-coverage` run the COMPILED suite in dist-test/, which
87
91
  // a fresh clone does not have — the glob then matches nothing and the
88
92
  // run reports "tests 0, pass 0, fail 0" and exits 0. A green suite that
@@ -190,6 +190,11 @@ Then run:
190
190
  cd ts && npm test
191
191
  \`\`\`
192
192
 
193
+ Live entity tests continue independent operations after errors and attempt
194
+ supported cleanup. Their final result reports failures and missing prerequisites
195
+ after the remaining work completes. The model and test inputs determine which
196
+ API operations the generated scenarios cover.
197
+
193
198
  `)
194
199
 
195
200
  })
@@ -104,6 +104,7 @@ const TestDirect = cmp(function TestDirect(props: any) {
104
104
 
105
105
  Slot({ name: 'directSetup' }, () => {
106
106
  Content(`
107
+ function liveScenariosActive() { return ${Object.values(model.main.kit.entity || {}).some((e: any) => Object.values(e.op || {}).some((o: any) => (o.points || []).some((p: any) => p.contract && JSON.parse(p.contract.json).live)))} && process.env.${PROJECTNAME}_TEST_LIVE === 'TRUE' }
107
108
  function directSetup(mockres?: any) {
108
109
  const calls: any[] = []
109
110
 
@@ -115,10 +116,11 @@ function directSetup(mockres?: any) {
115
116
  const live = 'TRUE' === env.${PROJECTNAME}_TEST_LIVE
116
117
 
117
118
  if (live) {
119
+ const transport = createLiveTransport()
118
120
  // Merged so the generated fields win: sdk-test-control.json's
119
121
  // test.client.options adds to the live client, it does not redirect it.
120
122
  const client = new ${nom(model.const, 'Name')}SDK(
121
- Object.assign({}, liveClientOptions(), {${apikeyLiveField}${serverLiveField}
123
+ Object.assign({}, liveClientOptions(), { system: { fetch: transport.fetch },${apikeyLiveField}${serverLiveField}
122
124
  }))
123
125
 
124
126
  let idmap: any = env['${entidEnvVar}']
@@ -126,7 +128,7 @@ function directSetup(mockres?: any) {
126
128
  idmap = JSON.parse(idmap)
127
129
  }
128
130
 
129
- return { client, calls, live, idmap }
131
+ return { client, calls, live, idmap, transport }
130
132
  }
131
133
 
132
134
  const mockFetch = async (url: string, init: any) => {
@@ -246,7 +248,7 @@ ${varAsserts}`
246
248
  // "compare the live server against the mock's script" - a suite that
247
249
  // could not pass against any real API, including this project's own.
248
250
  assert(result.ok === true,
249
- 'live request failed: ' + result.status + ' ' + JSON.stringify(result.data))
251
+ 'Live request failed: HTTP ' + result.status)
250
252
  assert(result.status >= 200 && result.status < 300)
251
253
  assert(null != result.data)
252
254
  } else {
@@ -263,6 +265,7 @@ ${offlineChecks} }`
263
265
 
264
266
  Content(`
265
267
  test('direct-${opname}-${entity.name}', async (t: any) => {
268
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
266
269
  const setup = directSetup()
267
270
  if (maybeSkipControl(t, 'direct', 'direct-${opname}-${entity.name}', setup.live)) return
268
271
  ${skipMissingLine} const { client, calls } = setup
@@ -418,16 +421,15 @@ ${liveQueryPrefix} const listResult: any = await client.direct({
418
421
  ${listParamLines}
419
422
  },
420
423
  })
421
- if (!listResult.ok) {
422
- return // skip: list call failed (likely synthetic IDs against live API)
423
- }
424
+ assert(listResult.ok && listResult.status >= 200 && listResult.status < 300,
425
+ 'Live list discovery failed')
424
426
  const listArr = unwrapListData(listResult.data)
425
427
  if (null == listArr || listArr.length === 0) {
426
- return // skip: no entities to load in live mode
428
+ throw new Error('Live load blocked: discovery returned no entities')
427
429
  }
428
430
  const candidateId = ${jsOptProp('listArr[0]', idParamName)} ?? listArr[0]?.id
429
431
  if (null == candidateId) {
430
- return // skip: list response shape does not expose load identifier
432
+ throw new Error('Live load blocked: discovery returned no usable identity')
431
433
  }
432
434
  ${jsProp('params', idParamName)} = candidateId
433
435
  ${ancestorParamLines}
@@ -479,7 +481,7 @@ ${paramAsserts}`
479
481
  // "compare the live server against the mock's script" - a suite that
480
482
  // could not pass against any real API, including this project's own.
481
483
  assert(result.ok === true,
482
- 'live request failed: ' + result.status + ' ' + JSON.stringify(result.data))
484
+ 'Live request failed: HTTP ' + result.status)
483
485
  assert(result.status >= 200 && result.status < 300)
484
486
  assert(null != result.data)
485
487
  } else {
@@ -496,6 +498,7 @@ ${offlineChecks} }`
496
498
 
497
499
  Content(`
498
500
  test('direct-load-${entity.name}', async (t: any) => {
501
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
499
502
  const setup = directSetup({ id: 'direct01' })
500
503
  if (maybeSkipControl(t, 'direct', 'direct-load-${entity.name}', setup.live)) return
501
504
  ${skipMissingLine} const { client, calls } = setup
@@ -607,9 +610,9 @@ ${paramAsserts}`
607
610
  // "compare the live server against the mock's script" - a suite that
608
611
  // could not pass against any real API, including this project's own.
609
612
  assert(result.ok === true,
610
- 'live request failed: ' + result.status + ' ' + JSON.stringify(result.data))
613
+ 'Live request failed: HTTP ' + result.status)
611
614
  assert(result.status >= 200 && result.status < 300)
612
- assert(null != result.data)
615
+ assert(Array.isArray(unwrapListData(result.data)), 'Expected live list response')
613
616
  } else {
614
617
  ${offlineChecks} }` :
615
618
  ` if (setup.live) {
@@ -628,6 +631,7 @@ ${offlineChecks} }`
628
631
 
629
632
  Content(`
630
633
  test('direct-list-${entity.name}', async (t: any) => {
634
+ if (liveScenariosActive()) { t.skip('Covered by live operation scenarios'); return }
631
635
  const setup = directSetup([{ id: 'direct01' }, { id: 'direct02' }])
632
636
  if (maybeSkipControl(t, 'direct', 'direct-list-${entity.name}', setup.live)) return
633
637
  ${skipMissingLine} const { client, calls } = setup