@voxgig/sdkgen 4.2.8 → 4.3.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 (33) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/dist/helpers/naming.d.ts +2 -1
  3. package/dist/helpers/naming.js +50 -12
  4. package/dist/helpers/naming.js.map +1 -1
  5. package/dist/sdkgen.d.ts +2 -2
  6. package/dist/sdkgen.js +4 -3
  7. package/dist/sdkgen.js.map +1 -1
  8. package/dist/tsconfig.tsbuildinfo +1 -1
  9. package/package.json +1 -1
  10. package/project/.sdk/tm/go/test/custom_utility_test.go +103 -0
  11. package/project/.sdk/tm/go/test/feature_corpus_test.go +550 -0
  12. package/project/.sdk/tm/go/utility/make_options.go +7 -1
  13. package/project/.sdk/tm/go/utility/register.go +194 -0
  14. package/project/.sdk/tm/java/test/CustomUtilityTest.java +55 -0
  15. package/project/.sdk/tm/java/test/FeatureCorpusTest.java +463 -0
  16. package/project/.sdk/tm/java/utility/MakeOptions.java +11 -2
  17. package/project/.sdk/tm/java/utility/Register.java +91 -0
  18. package/project/.sdk/tm/js/test/feature/Corpus.test.js +285 -0
  19. package/project/.sdk/tm/perl/t/feature_corpus.t +345 -0
  20. package/project/.sdk/tm/perl/utility/make_options.pm +33 -1
  21. package/project/.sdk/tm/php/core/Context.php +3 -0
  22. package/project/.sdk/tm/php/core/Control.php +13 -0
  23. package/project/.sdk/tm/php/core/Error.php +12 -0
  24. package/project/.sdk/tm/php/test/FeatureCorpusTest.php +376 -0
  25. package/project/.sdk/tm/php/utility/MakeOptions.php +30 -1
  26. package/project/.sdk/tm/py/pkg/utility/make_options.py +42 -1
  27. package/project/.sdk/tm/py/test/test_feature_corpus.py +309 -0
  28. package/project/.sdk/tm/rb/test/feature_corpus_test.rb +281 -0
  29. package/project/.sdk/tm/rb/utility/make_options.rb +32 -1
  30. package/project/.sdk/tm/ts/test/feature/Corpus.test.ts +287 -0
  31. package/project/sdkgen-package.json +1 -1
  32. package/src/helpers/naming.ts +54 -12
  33. package/src/sdkgen.ts +2 -1
@@ -0,0 +1,285 @@
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
+ // A corpus with no `feature` section is a SKIP, not a failure.
195
+ //
196
+ // Each project carries its OWN materialised copy of .sdk/test/test.json, so
197
+ // a project scaffolded before the section existed legitimately has no cases
198
+ // to run - and a hard assertion here turned that into a red suite in every
199
+ // SDK on the fleet, for a corpus the project had simply not re-pulled yet.
200
+ //
201
+ // The strict check belongs where the corpus is CONTROLLED, not where it is
202
+ // consumed: sdkgen's own end-to-end lane generates against a corpus it
203
+ // supplies and requires the cases to actually run, so a section that goes
204
+ // missing there still fails loudly.
205
+ test('the corpus carries a feature section', (t) => {
206
+ if (null == corpus.feature) {
207
+ return t.skip(
208
+ 'this project\'s test.json has no `feature` section - recompile the ' +
209
+ 'corpus (create-sdkgen .sdk/test/feature/) to run these cases')
210
+ }
211
+ })
212
+
213
+
214
+ // At least one operation, or every case below would skip and the whole
215
+ // suite would report green having run nothing.
216
+ test('this SDK has an operation the corpus can drive', () => {
217
+ ok(0 < ops.length,
218
+ 'no declared operation completed against a plain 200 — the corpus ' +
219
+ 'cannot exercise a feature without one')
220
+ })
221
+
222
+
223
+ for (const name of FEATURES) {
224
+
225
+ test(name, async (t) => {
226
+ const section = corpus.feature?.[name]
227
+ if (null == section) {
228
+ return t.skip(`no corpus section for ${name}`)
229
+ }
230
+
231
+ const probe = makeClient({})
232
+ if (!probe._rootctx.config.hasFeature(name)) {
233
+ return t.skip(`this SDK was generated without the ${name} feature`)
234
+ }
235
+
236
+ const cases = section.basic?.set || []
237
+ ok(0 < cases.length,
238
+ `corpus section feature.${name} ran ZERO cases — a renamed section ` +
239
+ `or an emptied fixture must fail loudly, not pass silently`)
240
+
241
+ let ran = 0
242
+ for (const raw of cases) {
243
+ const need = tokensUsed(raw)
244
+ if (ops.length < need) {
245
+ t.diagnostic(`skip "${raw.name}": needs ${need} operations, this SDK offers ${ops.length}`)
246
+ continue
247
+ }
248
+
249
+ const tokens = {}
250
+ for (let i = 0; i < need; i++) { tokens['#OP' + (i + 1)] = ops[i].key }
251
+
252
+ const kase = resolve(raw, tokens)
253
+ const client = makeClient(kase)
254
+
255
+ for (const step of (kase.op || [])) {
256
+ const ref = byKey[step.op]
257
+ ok(null != ref, `${kase.name}: no operation ${step.op}`)
258
+ try {
259
+ await client[ref.accessor]()[ref.op]({}, step.ctrl || {})
260
+ ok(null == step.err,
261
+ `${kase.name}: ${step.op} was expected to fail, and did not`)
262
+ }
263
+ catch (err) {
264
+ if (null == step.err) { throw err }
265
+ if ('string' === typeof step.err) {
266
+ deepStrictEqual(err.code, step.err, `${kase.name}: wrong error code`)
267
+ }
268
+ }
269
+ }
270
+
271
+ subset(client[`_${name}`], kase.out, `${kase.name}: _${name}`)
272
+ ran++
273
+ }
274
+
275
+ ok(0 < ran, `every feature.${name} case was skipped`)
276
+ // Say how many ran. A partial run is legitimate (an SDK with one
277
+ // operation skips the cases needing two) but it should be visible
278
+ // rather than inferred from a green tick - and it is the one line
279
+ // sdkgen's end-to-end lane reads, in the same wording, from every
280
+ // language's runner.
281
+ t.diagnostic(`feature.${name}: ran ${ran} of ${cases.length} ` +
282
+ `case(s) against ${ops.length} operation(s)`)
283
+ })
284
+ }
285
+ })
@@ -0,0 +1,345 @@
1
+ #!perl
2
+ # ProjectName SDK feature corpus test
3
+ #
4
+ # Feature behaviour, driven by the SHARED corpus.
5
+ #
6
+ # The same route t/primary_utility.t takes for the utilities: language-neutral
7
+ # cases in .sdk/test/test.json, executed against THIS generated SDK. The
8
+ # feature is the ordinary package, built by the generated config, installed by
9
+ # the generated constructor, and driven by a real entity operation. Not a
10
+ # miniature of the pipeline, which can only be as right as the miniature.
11
+ #
12
+ # Everything in a case is data. The one piece perl writes for itself is
13
+ # turning scripted responses into a fetcher, through the documented
14
+ # `utility.fetcher` override.
15
+
16
+ use strict;
17
+ use warnings;
18
+ use Test::More;
19
+ use FindBin;
20
+ use lib "$FindBin::Bin/../lib";
21
+ use Cwd ();
22
+ use Scalar::Util ();
23
+
24
+ use ProjectNameSDK;
25
+
26
+ my $TEST_JSON = Cwd::abs_path("$FindBin::Bin/../../.sdk/test/test.json");
27
+
28
+ unless (defined $TEST_JSON && -e $TEST_JSON) {
29
+ plan skip_all => 'test.json corpus not found';
30
+ }
31
+
32
+ # Features with a corpus section. A name here with no section is a skip, not
33
+ # a failure: an SDK generated without the feature has nothing to run.
34
+ my @FEATURE_CORPUS_NAMES = ('cost');
35
+
36
+ # The standard operation names, in the order the runner prefers them.
37
+ my @FEATURE_CORPUS_OPS = qw(load list create update remove);
38
+
39
+ my $CORPUS = do {
40
+ open my $fh, '<', $TEST_JSON or die "cannot read $TEST_JSON: $!";
41
+ local $/;
42
+ my $raw = <$fh>;
43
+ close $fh;
44
+ Voxgig::Struct::parse_json($raw);
45
+ };
46
+
47
+ # A corpus with no `feature` section is a SKIP, not a failure. Each
48
+ # project carries its OWN materialised copy of .sdk/test/test.json, so a
49
+ # project scaffolded before the section existed legitimately has no cases
50
+ # to run - and a hard assertion here turned that into a red suite in every
51
+ # SDK on the fleet, for a corpus the project had simply not re-pulled yet.
52
+ # The strict check belongs where the corpus is CONTROLLED: sdkgen's own
53
+ # end-to-end lane supplies one and requires the cases to actually run.
54
+ unless (defined $CORPUS->{feature}) {
55
+ plan skip_all => "this project's test.json has no `feature` section - "
56
+ . 'recompile the corpus (create-sdkgen .sdk/test/feature/) to run these cases';
57
+ }
58
+ ok(defined $CORPUS->{feature}, 'the corpus carries a feature section');
59
+
60
+
61
+ # A scripted transport built from a case's `res` list. Responses are consumed
62
+ # in order and the last one repeats, so a case that does not care how many
63
+ # attempts happen need only declare one.
64
+ #
65
+ # Returns the shape the real fetcher returns: a (response, err) PAIR, with the
66
+ # parsed body behind a `json` coderef and `body` as the raw string. A script
67
+ # that only set `body` would look like an empty result, which reads as a
68
+ # feature defect rather than a mis-shaped script.
69
+ sub scripted_fetcher {
70
+ my ($res) = @_;
71
+ my $n = -1;
72
+ return sub {
73
+ my ($ctx, $fullurl, $fetchdef) = @_;
74
+ $n++;
75
+ my $spec = {};
76
+ if (ref $res eq 'ARRAY' && @$res) {
77
+ my $i = $n >= scalar(@$res) ? scalar(@$res) - 1 : $n;
78
+ $spec = $res->[$i] || {};
79
+ }
80
+
81
+ if (($spec->{throw} || 0) eq '1' || (defined $spec->{throw} && $spec->{throw})) {
82
+ return (undef, 'scripted transport failure');
83
+ }
84
+
85
+ my $status = defined $spec->{status} ? int($spec->{status}) : 200;
86
+ my $body = defined $spec->{body} ? $spec->{body} : {};
87
+
88
+ return ({
89
+ 'status' => $status,
90
+ 'statusText' => ($status < 400 ? 'OK' : 'ERR'),
91
+ 'headers' => { %{ $spec->{headers} || {} } },
92
+ 'json' => sub { $body },
93
+ 'body' => Voxgig::Struct::stringify($body),
94
+ }, undef);
95
+ };
96
+ }
97
+
98
+
99
+ # Build a client the way a caller would.
100
+ #
101
+ # The plain constructor, not the test-mode one: the `test` feature is
102
+ # transport: 'base' and REPLACES the transport, so a client in test mode
103
+ # would shadow the script.
104
+ sub build_client {
105
+ my ($kase) = @_;
106
+ my $opts = { 'utility' => { 'fetcher' => scripted_fetcher($kase->{res}) } };
107
+ $opts->{feature} = $kase->{feature} if defined $kase->{feature};
108
+ return ProjectNameSDK->new($opts);
109
+ }
110
+
111
+
112
+ # Every operation this SDK declares, in a stable order.
113
+ #
114
+ # The corpus cannot name an entity - it is shared by SDKs with none in common
115
+ # - so the runner finds them here. An entity accessor is a capitalised client
116
+ # method whose result answers get_name.
117
+ sub candidates {
118
+ my ($client) = @_;
119
+ my %found;
120
+
121
+ my $pkg = ref $client;
122
+ no strict 'refs';
123
+ for my $sym (sort keys %{"${pkg}::"}) {
124
+ next unless $sym =~ /^[A-Z]/;
125
+ next unless defined &{"${pkg}::${sym}"};
126
+ my $ent = eval { $client->$sym() };
127
+ next unless defined $ent && Scalar::Util::blessed($ent);
128
+ next unless $ent->can('get_name');
129
+ my $entname = eval { $ent->get_name };
130
+ next unless defined $entname && length $entname;
131
+ $found{$entname} = [$sym, $ent];
132
+ }
133
+ use strict 'refs';
134
+
135
+ my @out;
136
+ for my $entname (sort keys %found) {
137
+ my ($accessor, $ent) = @{ $found{$entname} };
138
+ for my $opname (@FEATURE_CORPUS_OPS) {
139
+ next unless $ent->can($opname);
140
+ push @out, {
141
+ key => "$entname.$opname",
142
+ accessor => $accessor,
143
+ op => $opname,
144
+ };
145
+ }
146
+ }
147
+ return @out;
148
+ }
149
+
150
+
151
+ sub invoke {
152
+ my ($client, $op, $ctrl) = @_;
153
+ my $acc = $op->{accessor};
154
+ my $fn = $op->{op};
155
+ my $ent = $client->$acc();
156
+ return $ent->$fn({}, $ctrl);
157
+ }
158
+
159
+
160
+ # Pick operations by DRIVING them: an op is usable when it completes against a
161
+ # plain 200 with no feature active. Declared operations are not all callable
162
+ # with no arguments, and a case failing for that reason would read as a
163
+ # feature defect.
164
+ sub usable_ops {
165
+ my ($want) = @_;
166
+ my @picked;
167
+ for my $cand (candidates(build_client({}))) {
168
+ my $ok = eval { invoke(build_client({}), $cand, {}); 1 };
169
+ next unless $ok;
170
+ push @picked, $cand;
171
+ last if scalar(@picked) >= $want;
172
+ }
173
+ return @picked;
174
+ }
175
+
176
+
177
+ # Replace #OPn throughout a case, keys included.
178
+ sub resolve {
179
+ my ($node, $tokens) = @_;
180
+ if (ref $node eq 'ARRAY') {
181
+ return [ map { resolve($_, $tokens) } @$node ];
182
+ }
183
+ if (ref $node eq 'HASH') {
184
+ my %out;
185
+ for my $k (keys %$node) {
186
+ $out{ resolve($k, $tokens) } = resolve($node->{$k}, $tokens);
187
+ }
188
+ return \%out;
189
+ }
190
+ if (!ref $node && defined $node) {
191
+ my $out = $node;
192
+ for my $tok (keys %$tokens) {
193
+ my $q = quotemeta $tok;
194
+ $out =~ s/$q/$tokens->{$tok}/g;
195
+ }
196
+ return $out;
197
+ }
198
+ return $node;
199
+ }
200
+
201
+
202
+ # The highest #OPn a case mentions.
203
+ sub tokens_used {
204
+ my ($kase) = @_;
205
+ my $json = Voxgig::Struct::stringify($kase);
206
+ my $max = 0;
207
+ while ($json =~ /#OP(\d+)/g) {
208
+ $max = $1 if $1 > $max;
209
+ }
210
+ return $max;
211
+ }
212
+
213
+
214
+ sub member {
215
+ my ($actual, $key) = @_;
216
+ return (undef, 0) unless defined $actual;
217
+ if (ref $actual eq 'HASH' || Scalar::Util::blessed($actual)) {
218
+ return ($actual->{$key}, 1) if exists $actual->{$key};
219
+ }
220
+ return (undef, 0);
221
+ }
222
+
223
+
224
+ # Assert that `actual` contains `expect`, recursively. Cases assert only the
225
+ # fields they are about, so a full deep comparison would force every case to
226
+ # restate the whole record.
227
+ sub subset {
228
+ my ($actual, $expect, $path) = @_;
229
+
230
+ if (ref $expect eq 'HASH') {
231
+ for my $k (sort keys %$expect) {
232
+ my ($got, $found) = member($actual, $k);
233
+ ok($found, "$path.$k exists") or next;
234
+ subset($got, $expect->{$k}, "$path.$k");
235
+ }
236
+ return;
237
+ }
238
+
239
+ # JSON true/false parse to a blessed Voxgig::Struct::Bool, and perl's own
240
+ # booleans are a bare 1 or ''. Compare truth, not spelling: stringified,
241
+ # those two are 'true' and '1' and would never match.
242
+ if (Voxgig::Struct::is_jbool($expect)) {
243
+ is(($actual ? 1 : 0), ($expect ? 1 : 0), "$path is $expect");
244
+ return;
245
+ }
246
+
247
+ if (!ref $expect && defined $expect && Scalar::Util::looks_like_number($expect)) {
248
+ ok(!ref $actual && defined $actual && Scalar::Util::looks_like_number($actual),
249
+ "$path is a number") or return;
250
+ # Money is float arithmetic; compare with a tolerance far below any amount
251
+ # a case states.
252
+ ok(abs($actual - $expect) < 1e-9, "$path == $expect (got "
253
+ . (defined $actual ? $actual : 'undef') . ')');
254
+ return;
255
+ }
256
+
257
+ is($actual, $expect, $path);
258
+ }
259
+
260
+
261
+ sub record {
262
+ my ($client, $name) = @_;
263
+ return $client->{"_$name"};
264
+ }
265
+
266
+
267
+ my @ops = usable_ops(2);
268
+
269
+ # At least one operation, or every case would skip and this file would report
270
+ # green having run nothing.
271
+ ok(scalar(@ops) > 0,
272
+ 'this SDK has an operation the corpus can drive') or do {
273
+ done_testing();
274
+ exit 0;
275
+ };
276
+
277
+ for my $name (@FEATURE_CORPUS_NAMES) {
278
+ my $section = ($CORPUS->{feature} || {})->{$name};
279
+ next unless defined $section;
280
+
281
+ my $cases = (($section->{basic} || {})->{set}) || [];
282
+ ok(scalar(@$cases) > 0,
283
+ "corpus section feature.$name has cases (an emptied fixture must fail loudly)")
284
+ or next;
285
+
286
+ # Probed by ACTIVATING it: the feature defaults to inactive, so an idle
287
+ # client never builds it and its absence says nothing.
288
+ my $probe = build_client({ feature => [ { name => $name, active => 1 } ] });
289
+ next unless defined record($probe, $name);
290
+
291
+ my %by_key = map { $_->{key} => $_ } @ops;
292
+
293
+ my $ran = 0;
294
+ for my $raw (@$cases) {
295
+ my $need = tokens_used($raw);
296
+ next if $need > scalar(@ops);
297
+
298
+ my %tokens;
299
+ for my $i (0 .. $need - 1) {
300
+ $tokens{ '#OP' . ($i + 1) } = $ops[$i]{key};
301
+ }
302
+ my $kase = resolve($raw, \%tokens);
303
+
304
+ my $client = build_client($kase);
305
+ my $label = $kase->{name} || '';
306
+
307
+ for my $step (@{ $kase->{op} || [] }) {
308
+ my $op = $by_key{ $step->{op} };
309
+ ok(defined $op, "$label: operation $step->{op} is known") or next;
310
+ my $ctrl = $step->{ctrl} || {};
311
+ my $wanterr = $step->{err};
312
+
313
+ my $ok = eval { invoke($client, $op, $ctrl); 1 };
314
+ my $err = $@;
315
+
316
+ if (!defined $wanterr) {
317
+ ok($ok, "$label: $step->{op} succeeded")
318
+ or diag("failed unexpectedly: $err");
319
+ next;
320
+ }
321
+
322
+ ok(!$ok, "$label: $step->{op} failed as expected") or next;
323
+
324
+ if (!ref $wanterr) {
325
+ # The CODE, not the message: make_error prefixes and humanises the
326
+ # text, so matching it would pass on any error mentioning the word.
327
+ my $code = (Scalar::Util::blessed($err) && $err->can('code'))
328
+ ? $err->code : undef;
329
+ is($code, $wanterr, "$label: error code");
330
+ }
331
+ }
332
+
333
+ subset(record($client, $name), $kase->{out}, "$label: _$name");
334
+ $ran++;
335
+ }
336
+
337
+ ok($ran > 0, "at least one feature.$name case ran");
338
+ # Say how many ran. A partial run is legitimate (an SDK with one operation
339
+ # skips the cases needing two) but it should be visible rather than
340
+ # inferred from a green tick.
341
+ diag(sprintf('feature.%s: ran %d of %d case(s) against %d operation(s)',
342
+ $name, $ran, scalar(@$cases), scalar(@ops)));
343
+ }
344
+
345
+ done_testing();
@@ -19,10 +19,42 @@ $REGISTRY{make_options} = sub {
19
19
  my ($ctx) = @_;
20
20
  my $options = $ctx->{options} || {};
21
21
 
22
+ # Merge custom utility overrides.
23
+ #
24
+ # A key naming a real utility member REPLACES it; anything else is attached
25
+ # as a custom extra. This mirrors ts, where the utility is an open object
26
+ # and one setprop does both.
27
+ #
28
+ # Without the replace half this was a no-op: every entry went to
29
+ # `{utility}{custom}`, which nothing reads, so a caller passing
30
+ # `utility => { fetcher => $my_transport }` - the documented way to script
31
+ # the transport, and the seam the shared feature corpus runs on - was
32
+ # silently ignored while ts and js honoured it.
33
+ #
34
+ # Option keys are camelCase, as ts spells them; members here are
35
+ # snake_case. Converting rather than listing keeps the mapping to one rule,
36
+ # so a utility added later is overridable without touching this. The
37
+ # registrar has already populated every member, so `exists` is the test for
38
+ # "is this a real one" - once the key is known to be a PUBLIC name.
22
39
  my $custom_utils = ProjectNameHelpers::gp($options, 'utility');
23
40
  if (Voxgig::Struct::ismap($custom_utils) && $ctx->{utility}) {
41
+ my $utility = $ctx->{utility};
24
42
  for my $k (keys %$custom_utils) {
25
- $ctx->{utility}{custom}{$k} = $custom_utils->{$k};
43
+ # Public utility names are camelCase and carry no underscore, so an
44
+ # underscore means the caller named something of their own - possibly
45
+ # the INTERNAL spelling of a real member. `make_error` must stay an
46
+ # extension in `custom`; replacing the pipeline function with it (ts,
47
+ # js and go all keep it) would break the error path on the next
48
+ # request, silently.
49
+ my $public_name = ($k !~ /_/);
50
+ my $member = $k;
51
+ $member =~ s/([A-Z])/'_' . lc($1)/ge;
52
+ if ($public_name && 'custom' ne $member && exists $utility->{$member}) {
53
+ $utility->{$member} = $custom_utils->{$k};
54
+ }
55
+ else {
56
+ $utility->{custom}{$k} = $custom_utils->{$k};
57
+ }
26
58
  }
27
59
  }
28
60
 
@@ -52,6 +52,9 @@ class ProjectNameContext
52
52
  if (isset($ctrl_raw['explain']) && is_array($ctrl_raw['explain'])) {
53
53
  $this->ctrl->explain = $ctrl_raw['explain'];
54
54
  }
55
+ if (array_key_exists('actor', $ctrl_raw)) {
56
+ $this->ctrl->actor = $ctrl_raw['actor'];
57
+ }
55
58
  } elseif ($basectx !== null && $basectx->ctrl !== null) {
56
59
  $this->ctrl = $basectx->ctrl;
57
60
  }