@voxgig/sdkgen 0.45.0 → 1.0.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.
Files changed (65) hide show
  1. package/bin/voxgig-sdkgen +1 -1
  2. package/package.json +3 -3
  3. package/project/.sdk/src/cmp/go/Entity_go.ts +2 -2
  4. package/project/.sdk/src/cmp/go/Main_go.ts +8 -4
  5. package/project/.sdk/src/cmp/go/Package_go.ts +2 -2
  6. package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +2 -2
  7. package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +2 -2
  8. package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +2 -2
  9. package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +2 -2
  10. package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +2 -2
  11. package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +2 -2
  12. package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +2 -2
  13. package/project/.sdk/src/cmp/go/TestDirect_go.ts +204 -33
  14. package/project/.sdk/src/cmp/go/TestEntity_go.ts +37 -6
  15. package/project/.sdk/src/cmp/go/Test_go.ts +2 -2
  16. package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +21 -4
  17. package/project/.sdk/src/cmp/lua/Package_lua.ts +9 -2
  18. package/project/.sdk/src/cmp/lua/TestDirect_lua.ts +154 -21
  19. package/project/.sdk/src/cmp/lua/TestEntity_lua.ts +25 -1
  20. package/project/.sdk/src/cmp/lua/fragment/Main.fragment.lua +20 -4
  21. package/project/.sdk/src/cmp/php/Package_php.ts +7 -1
  22. package/project/.sdk/src/cmp/php/TestDirect_php.ts +153 -20
  23. package/project/.sdk/src/cmp/php/TestEntity_php.ts +25 -1
  24. package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +17 -3
  25. package/project/.sdk/src/cmp/py/Package_py.ts +8 -1
  26. package/project/.sdk/src/cmp/py/TestDirect_py.ts +146 -19
  27. package/project/.sdk/src/cmp/py/TestEntity_py.ts +25 -1
  28. package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +19 -4
  29. package/project/.sdk/src/cmp/rb/Package_rb.ts +9 -2
  30. package/project/.sdk/src/cmp/rb/TestDirect_rb.ts +154 -21
  31. package/project/.sdk/src/cmp/rb/TestEntity_rb.ts +25 -1
  32. package/project/.sdk/src/cmp/rb/fragment/Main.fragment.rb +19 -3
  33. package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
  34. package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +145 -22
  35. package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +59 -1
  36. package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +8 -1
  37. package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +8 -2
  38. package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +21 -1
  39. package/project/.sdk/tm/go/feature/test_feature.go +51 -3
  40. package/project/.sdk/tm/go/test/runner_test.go +106 -6
  41. package/project/.sdk/tm/go/test/sdk-test-control.json +19 -0
  42. package/project/.sdk/tm/go/utility/fetcher.go +10 -0
  43. package/project/.sdk/tm/go/utility/make_url.go +12 -0
  44. package/project/.sdk/tm/lua/feature/test_feature.lua +41 -3
  45. package/project/.sdk/tm/lua/test/runner.lua +74 -0
  46. package/project/.sdk/tm/lua/test/sdk-test-control.json +19 -0
  47. package/project/.sdk/tm/lua/utility/fetcher.lua +13 -0
  48. package/project/.sdk/tm/lua/utility/make_url.lua +16 -0
  49. package/project/.sdk/tm/php/feature/TestFeature.php +185 -43
  50. package/project/.sdk/tm/php/test/Runner.php +62 -0
  51. package/project/.sdk/tm/php/test/sdk-test-control.json +19 -0
  52. package/project/.sdk/tm/php/utility/Fetcher.php +132 -9
  53. package/project/.sdk/tm/php/utility/MakeUrl.php +16 -0
  54. package/project/.sdk/tm/py/feature/test_feature.py +35 -3
  55. package/project/.sdk/tm/py/test/runner.py +60 -0
  56. package/project/.sdk/tm/py/test/sdk-test-control.json +19 -0
  57. package/project/.sdk/tm/py/utility/fetcher.py +13 -0
  58. package/project/.sdk/tm/py/utility/make_url.py +13 -0
  59. package/project/.sdk/tm/rb/feature/test_feature.rb +36 -3
  60. package/project/.sdk/tm/rb/test/runner.rb +46 -0
  61. package/project/.sdk/tm/rb/test/sdk-test-control.json +19 -0
  62. package/project/.sdk/tm/rb/utility/fetcher.rb +49 -28
  63. package/project/.sdk/tm/rb/utility/make_url.rb +16 -0
  64. package/project/.sdk/tm/ts/test/sdk-test-control.json +19 -0
  65. package/project/.sdk/tm/ts/test/utility.ts +120 -2
@@ -5,25 +5,47 @@ declare(strict_types=1);
5
5
 
6
6
  class ProjectNameFetcher
7
7
  {
8
+ // Default User-Agent — many CDNs (notably Cloudflare) reject requests
9
+ // with PHP's default UA (which file_get_contents doesn't even set),
10
+ // returning 403 before the request reaches the origin. Set a Mozilla-
11
+ // shaped UA so the SDK behaves like every other HTTP client by default.
12
+ // Users can override by passing a User-Agent header in fetchdef.
13
+ public const DEFAULT_USER_AGENT = 'Mozilla/5.0 (compatible; ProjectNameSDK/1.0)';
14
+
8
15
  public static function defaultHttpFetch(string $fullurl, array $fetchdef): array
9
16
  {
10
- $method_str = $fetchdef['method'] ?? 'GET';
17
+ $method_str = strtoupper($fetchdef['method'] ?? 'GET');
11
18
  $body_str = $fetchdef['body'] ?? null;
12
19
  $headers = $fetchdef['headers'] ?? [];
13
20
 
14
- $opts = [
15
- 'http' => [
16
- 'method' => strtoupper($method_str),
17
- 'ignore_errors' => true,
18
- ],
19
- ];
20
-
21
21
  $header_lines = [];
22
+ $has_ua = false;
22
23
  foreach ($headers as $k => $v) {
23
24
  if (is_string($v)) {
25
+ if (strcasecmp($k, 'user-agent') === 0) {
26
+ $has_ua = true;
27
+ }
24
28
  $header_lines[] = "{$k}: {$v}";
25
29
  }
26
30
  }
31
+ if (!$has_ua) {
32
+ $header_lines[] = 'User-Agent: ' . self::DEFAULT_USER_AGENT;
33
+ }
34
+
35
+ // Prefer cURL when available — its header capture is reliable across
36
+ // PHP versions, while file_get_contents + custom stream wrappers
37
+ // don't propagate $http_response_header for user-defined wrappers
38
+ // in PHP 8.3+.
39
+ if (function_exists('curl_init')) {
40
+ return self::curlFetch($fullurl, $method_str, $body_str, $header_lines);
41
+ }
42
+
43
+ $opts = [
44
+ 'http' => [
45
+ 'method' => $method_str,
46
+ 'ignore_errors' => true,
47
+ ],
48
+ ];
27
49
 
28
50
  if (is_string($body_str)) {
29
51
  $opts['http']['content'] = $body_str;
@@ -78,6 +100,101 @@ class ProjectNameFetcher
78
100
  ];
79
101
  }
80
102
 
103
+ private static function curlFetch(string $fullurl, string $method, $body_str, array $header_lines): array
104
+ {
105
+ // GET-only guard for live tests — set by pub-live-test. We respect
106
+ // it here at the SDK level since cURL bypasses PHP stream wrappers
107
+ // (so the wrapper-based guard wouldn't see this call).
108
+ if (getenv('VOXGIG_GETONLY_GUARD') === 'TRUE' && $method !== 'GET') {
109
+ fwrite(STDERR, "[GET-ONLY GUARD] blocked {$method} {$fullurl}\n");
110
+ return [
111
+ [
112
+ 'status' => 0,
113
+ 'statusText' => 'Blocked',
114
+ 'headers' => [],
115
+ 'json' => function () { return null; },
116
+ 'body' => '',
117
+ ],
118
+ "GET-only guard blocked {$method} {$fullurl}",
119
+ ];
120
+ }
121
+
122
+ $ch = curl_init($fullurl);
123
+ curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
124
+ curl_setopt($ch, CURLOPT_HEADER, false);
125
+ curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
126
+ curl_setopt($ch, CURLOPT_TIMEOUT, 30);
127
+ curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 10);
128
+ curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
129
+ if (!empty($header_lines)) {
130
+ curl_setopt($ch, CURLOPT_HTTPHEADER, $header_lines);
131
+ }
132
+ if (is_string($body_str)) {
133
+ curl_setopt($ch, CURLOPT_POSTFIELDS, $body_str);
134
+ }
135
+
136
+ $resp_headers = [];
137
+ curl_setopt($ch, CURLOPT_HEADERFUNCTION,
138
+ function ($_ch, $hdr) use (&$resp_headers) {
139
+ $trimmed = trim($hdr);
140
+ if ($trimmed !== '') {
141
+ $resp_headers[] = $trimmed;
142
+ }
143
+ return strlen($hdr);
144
+ });
145
+
146
+ $response_body = curl_exec($ch);
147
+ if ($response_body === false) {
148
+ $err = curl_error($ch) ?: 'curl_exec failed';
149
+ curl_close($ch);
150
+ return [
151
+ [
152
+ 'status' => 0,
153
+ 'statusText' => 'Error',
154
+ 'headers' => [],
155
+ 'json' => function () { return null; },
156
+ 'body' => '',
157
+ ],
158
+ $err,
159
+ ];
160
+ }
161
+ curl_close($ch);
162
+
163
+ $status = 0;
164
+ $status_text = '';
165
+ $resp_kv = [];
166
+ foreach ($resp_headers as $h) {
167
+ if (preg_match('/^HTTP\/\S+\s+(\d+)\s*(.*)$/i', $h, $m)) {
168
+ $status = (int)$m[1];
169
+ $status_text = trim($m[2]);
170
+ } else {
171
+ $parts = explode(':', $h, 2);
172
+ if (count($parts) === 2) {
173
+ $resp_kv[strtolower(trim($parts[0]))] = trim($parts[1]);
174
+ }
175
+ }
176
+ }
177
+
178
+ $json_body = null;
179
+ if ($response_body !== '' && $response_body !== false) {
180
+ $decoded = json_decode($response_body, true);
181
+ if (json_last_error() === JSON_ERROR_NONE) {
182
+ $json_body = $decoded;
183
+ }
184
+ }
185
+
186
+ return [
187
+ [
188
+ 'status' => $status,
189
+ 'statusText' => $status_text,
190
+ 'headers' => $resp_kv,
191
+ 'json' => function () use ($json_body) { return $json_body; },
192
+ 'body' => (string)$response_body,
193
+ ],
194
+ null,
195
+ ];
196
+ }
197
+
81
198
  public static function call(ProjectNameContext $ctx, string $fullurl, array $fetchdef): array
82
199
  {
83
200
  if ($ctx->client->mode !== 'live') {
@@ -93,7 +210,13 @@ class ProjectNameFetcher
93
210
 
94
211
  $sys_fetch = \Voxgig\Struct\Struct::getpath($options, 'system.fetch');
95
212
 
96
- if ($sys_fetch === null) {
213
+ // Treat null OR an empty stdClass/array as "no fetcher provided" and
214
+ // fall through to the default HTTP fetcher. The options builder
215
+ // sometimes materializes `system.fetch` as an empty stdClass even
216
+ // when the user didn't set one — that's a placeholder, not a value.
217
+ $is_empty_obj = ($sys_fetch instanceof \stdClass) && empty(get_object_vars($sys_fetch));
218
+ $is_empty_arr = is_array($sys_fetch) && empty($sys_fetch);
219
+ if ($sys_fetch === null || $is_empty_obj || $is_empty_arr) {
97
220
  return self::defaultHttpFetch($fullurl, $fetchdef);
98
221
  }
99
222
  if (is_callable($sys_fetch)) {
@@ -35,6 +35,22 @@ class ProjectNameMakeUrl
35
35
  }
36
36
  }
37
37
 
38
+ // Append query string from spec.query.
39
+ $qsep = '?';
40
+ $query_items = \Voxgig\Struct\Struct::items($spec->query ?? null);
41
+ if ($query_items) {
42
+ foreach ($query_items as $item) {
43
+ $key = $item[0];
44
+ $val = $item[1];
45
+ if ($val !== null && is_string($key)) {
46
+ $val_str = is_string($val) ? $val : (string)$val;
47
+ $url .= $qsep . \Voxgig\Struct\Struct::escurl($key) . '=' . \Voxgig\Struct\Struct::escurl($val_str);
48
+ $qsep = '&';
49
+ $resmatch[$key] = $val;
50
+ }
51
+ }
52
+ }
53
+
38
54
  $result->resmatch = $resmatch;
39
55
  return [$url, null];
40
56
  }
@@ -52,8 +52,21 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
52
52
  if not isinstance(entmap, dict):
53
53
  entmap = {}
54
54
 
55
+ # For single-entity ops (load, remove) with an empty explicit
56
+ # match, fall back to the id the entity client already knows from a
57
+ # prior create/load (in fctx.match / fctx.data). Mirrors the TS
58
+ # mock where param() resolves the id from that accumulated state.
59
+ def _resolve_match(explicit):
60
+ if isinstance(explicit, dict) and len(explicit) > 0:
61
+ return explicit
62
+ for src in (getattr(fctx, "match", None), getattr(fctx, "data", None)):
63
+ v = vs.getprop(src, "id") if src is not None else None
64
+ if v is not None and v != "__UNDEFINED__":
65
+ return {"id": v}
66
+ return {}
67
+
55
68
  if op.name == "load":
56
- args = test_self.build_args(fctx, op, fctx.reqmatch)
69
+ args = test_self.build_args(fctx, op, _resolve_match(fctx.reqmatch))
57
70
  found = vs.select(entmap, args)
58
71
  ent = vs.getelem(found, 0)
59
72
  if ent is None:
@@ -74,9 +87,28 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
74
87
  return respond(200, out)
75
88
 
76
89
  elif op.name == "update":
77
- args = test_self.build_args(fctx, op, fctx.reqdata)
90
+ # Match the existing entity by id only (or its alias). reqdata
91
+ # also contains the new field values, which would otherwise
92
+ # cause select to filter out the entity we want to update.
93
+ # Falls back to first entity when no match found, mirroring
94
+ # the TS mock.
95
+ update_match = {}
96
+ if isinstance(fctx.reqdata, dict):
97
+ if "id" in fctx.reqdata:
98
+ update_match["id"] = fctx.reqdata["id"]
99
+ alias_map = getattr(op, "alias_map", None)
100
+ if alias_map is not None:
101
+ alias_id = vs.getprop(alias_map, "id")
102
+ if alias_id is not None and alias_id in fctx.reqdata:
103
+ update_match[alias_id] = fctx.reqdata[alias_id]
104
+ args = test_self.build_args(fctx, op, update_match)
78
105
  found = vs.select(entmap, args)
79
106
  ent = vs.getelem(found, 0)
107
+ if ent is None and isinstance(entmap, dict):
108
+ for e in entmap.values():
109
+ if isinstance(e, dict):
110
+ ent = e
111
+ break
80
112
  if ent is None:
81
113
  return respond(404, None, {"statusText": "Not found"})
82
114
  if isinstance(ent, dict):
@@ -89,7 +121,7 @@ class ProjectNameTestFeature(ProjectNameBaseFeature):
89
121
  return respond(200, out)
90
122
 
91
123
  elif op.name == "remove":
92
- args = test_self.build_args(fctx, op, fctx.reqmatch)
124
+ args = test_self.build_args(fctx, op, _resolve_match(fctx.reqmatch))
93
125
  found = vs.select(entmap, args)
94
126
  ent = vs.getelem(found, 0)
95
127
  if ent is None:
@@ -62,6 +62,54 @@ class ProjectNameTestRunner:
62
62
 
63
63
  return m
64
64
 
65
+ _test_control = None
66
+
67
+ @staticmethod
68
+ def load_test_control():
69
+ """Load sdk-test-control.json from this test dir; cache after first read.
70
+ Returns a dict with the empty-skip default if the file is missing or invalid
71
+ so tests never crash on a bad config.
72
+ """
73
+ if ProjectNameTestRunner._test_control is not None:
74
+ return ProjectNameTestRunner._test_control
75
+ ctrl_path = os.path.join(os.path.dirname(__file__), "sdk-test-control.json")
76
+ try:
77
+ with open(ctrl_path, "r") as f:
78
+ ProjectNameTestRunner._test_control = json.load(f)
79
+ except (FileNotFoundError, IOError, ValueError):
80
+ ProjectNameTestRunner._test_control = {
81
+ "version": 1,
82
+ "test": {"skip": {
83
+ "live": {"direct": [], "entityOp": []},
84
+ "unit": {"direct": [], "entityOp": []},
85
+ }},
86
+ }
87
+ return ProjectNameTestRunner._test_control
88
+
89
+ @staticmethod
90
+ def is_control_skipped(kind, name, mode):
91
+ """Check sdk-test-control.json for a skip entry. Returns (skip, reason)."""
92
+ ctrl = ProjectNameTestRunner.load_test_control()
93
+ skip = ctrl.get("test", {}).get("skip", {}).get(mode, {}) or {}
94
+ items = skip.get(kind, []) or []
95
+ for item in items:
96
+ if kind == "direct" and item.get("test") == name:
97
+ return True, item.get("reason")
98
+ if kind == "entityOp":
99
+ key = (item.get("entity") or "") + "." + (item.get("op") or "")
100
+ if key == name:
101
+ return True, item.get("reason")
102
+ return False, None
103
+
104
+ @staticmethod
105
+ def live_delay_ms():
106
+ """Per-test live pacing delay (ms); default 500."""
107
+ ctrl = ProjectNameTestRunner.load_test_control()
108
+ v = ctrl.get("test", {}).get("live", {}).get("delayMs")
109
+ if isinstance(v, int) and v >= 0:
110
+ return v
111
+ return 500
112
+
65
113
  @staticmethod
66
114
  def entity_list_to_data(lst):
67
115
  out = []
@@ -88,3 +136,15 @@ def env_override(m):
88
136
 
89
137
  def entity_list_to_data(lst):
90
138
  return ProjectNameTestRunner.entity_list_to_data(lst)
139
+
140
+
141
+ def is_control_skipped(kind, name, mode):
142
+ return ProjectNameTestRunner.is_control_skipped(kind, name, mode)
143
+
144
+
145
+ def load_test_control():
146
+ return ProjectNameTestRunner.load_test_control()
147
+
148
+
149
+ def live_delay_ms():
150
+ return ProjectNameTestRunner.live_delay_ms()
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by py test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }
@@ -5,6 +5,14 @@ import json
5
5
  from utility.voxgig_struct import voxgig_struct as vs
6
6
 
7
7
 
8
+ # Default User-Agent — many CDNs (notably Cloudflare) reject requests with
9
+ # Python's default urllib UA ("Python-urllib/3.x"), returning 403 before
10
+ # the request even reaches the origin. Set a Mozilla-shaped UA so the SDK
11
+ # behaves like every other HTTP client by default. Users can still override
12
+ # by passing a User-Agent header in fetchdef.
13
+ _DEFAULT_USER_AGENT = "Mozilla/5.0 (compatible; ProjectNameSDK/1.0)"
14
+
15
+
8
16
  def _default_http_fetch(fullurl, fetchdef):
9
17
  import urllib.request
10
18
  import urllib.error
@@ -19,8 +27,13 @@ def _default_http_fetch(fullurl, fetchdef):
19
27
  data = body_str.encode("utf-8") if body_str is not None else None
20
28
 
21
29
  req = urllib.request.Request(fullurl, data=data, method=method)
30
+ has_ua = False
22
31
  for k, v in headers.items():
32
+ if k.lower() == "user-agent":
33
+ has_ua = True
23
34
  req.add_header(k, v)
35
+ if not has_ua:
36
+ req.add_header("User-Agent", _DEFAULT_USER_AGENT)
24
37
 
25
38
  try:
26
39
  resp = urllib.request.urlopen(req)
@@ -29,6 +29,19 @@ def make_url_util(ctx):
29
29
  url = url.replace("{" + key + "}", encoded)
30
30
  resmatch[key] = val
31
31
 
32
+ # Append query string from spec.query.
33
+ qsep = "?"
34
+ query_items = vs.items(getattr(spec, "query", None))
35
+ if query_items is not None:
36
+ for item in query_items:
37
+ key = item[0]
38
+ val = item[1]
39
+ if val is not None and isinstance(key, str):
40
+ val_str = val if isinstance(val, str) else str(val)
41
+ url += qsep + vs.escurl(key) + "=" + vs.escurl(val_str)
42
+ qsep = "&"
43
+ resmatch[key] = val
44
+
32
45
  result.resmatch = resmatch
33
46
 
34
47
  return url, None
@@ -48,8 +48,22 @@ class ProjectNameTestFeature < ProjectNameBaseFeature
48
48
  entmap = VoxgigStruct.getprop(entity, op.entity)
49
49
  entmap = {} unless entmap.is_a?(Hash)
50
50
 
51
+ # For single-entity ops (load, remove) with an empty explicit match, fall
52
+ # back to the id the entity client already knows from a prior create/load
53
+ # (in fctx.match / fctx.data). Mirrors the TS mock where param() resolves
54
+ # the id from that accumulated state.
55
+ resolve_match = lambda do |explicit|
56
+ return explicit if explicit.is_a?(Hash) && !explicit.empty?
57
+ [fctx.match, fctx.data].each do |src|
58
+ next if src.nil?
59
+ v = VoxgigStruct.getprop(src, "id")
60
+ return { "id" => v } if !v.nil? && v != "__UNDEFINED__"
61
+ end
62
+ {}
63
+ end
64
+
51
65
  if op.name == "load"
52
- args = test_self.build_args(fctx, op, fctx.reqmatch)
66
+ args = test_self.build_args(fctx, op, resolve_match.call(fctx.reqmatch))
53
67
  found = VoxgigStruct.select(entmap, args)
54
68
  ent = VoxgigStruct.getelem(found, 0)
55
69
  return respond.call(404, nil, { "statusText" => "Not found" }) unless ent
@@ -68,9 +82,28 @@ class ProjectNameTestFeature < ProjectNameBaseFeature
68
82
  respond.call(200, out, nil)
69
83
 
70
84
  elsif op.name == "update"
71
- args = test_self.build_args(fctx, op, fctx.reqdata)
85
+ # Match the existing entity by id only (or its alias). reqdata also
86
+ # contains the new field values, which would otherwise cause select
87
+ # to filter out the entity we want to update. Falls back to first
88
+ # entity when no id present and no match found, mirroring the TS
89
+ # mock's empirical behavior where param(undef) collapses to "no
90
+ # constraint" and select returns all.
91
+ update_match = {}
92
+ if fctx.reqdata.is_a?(Hash)
93
+ update_match["id"] = fctx.reqdata["id"] if fctx.reqdata.key?("id")
94
+ if op.alias_map
95
+ alias_id = VoxgigStruct.getprop(op.alias_map, "id")
96
+ if alias_id && fctx.reqdata.key?(alias_id)
97
+ update_match[alias_id] = fctx.reqdata[alias_id]
98
+ end
99
+ end
100
+ end
101
+ args = test_self.build_args(fctx, op, update_match)
72
102
  found = VoxgigStruct.select(entmap, args)
73
103
  ent = VoxgigStruct.getelem(found, 0)
104
+ if ent.nil? && entmap.is_a?(Hash) && !entmap.empty?
105
+ ent = entmap.values.find { |e| e.is_a?(Hash) }
106
+ end
74
107
  return respond.call(404, nil, { "statusText" => "Not found" }) unless ent
75
108
  if ent.is_a?(Hash) && fctx.reqdata
76
109
  fctx.reqdata.each { |k, v| ent[k] = v }
@@ -80,7 +113,7 @@ class ProjectNameTestFeature < ProjectNameBaseFeature
80
113
  respond.call(200, out, nil)
81
114
 
82
115
  elsif op.name == "remove"
83
- args = test_self.build_args(fctx, op, fctx.reqmatch)
116
+ args = test_self.build_args(fctx, op, resolve_match.call(fctx.reqmatch))
84
117
  found = VoxgigStruct.select(entmap, args)
85
118
  ent = VoxgigStruct.getelem(found, 0)
86
119
  return respond.call(404, nil, { "statusText" => "Not found" }) unless ent
@@ -62,6 +62,52 @@ module ProjectNameTestRunner
62
62
  end
63
63
  out
64
64
  end
65
+
66
+ @test_control = nil
67
+
68
+ # Load sdk-test-control.json from this test dir; cache. Returns the
69
+ # empty-skip default if the file is missing or invalid.
70
+ def self.load_test_control
71
+ return @test_control unless @test_control.nil?
72
+ ctrl_path = File.join(File.dirname(__FILE__), 'sdk-test-control.json')
73
+ @test_control = begin
74
+ JSON.parse(File.read(ctrl_path))
75
+ rescue StandardError
76
+ {
77
+ 'version' => 1,
78
+ 'test' => { 'skip' => {
79
+ 'live' => { 'direct' => [], 'entityOp' => [] },
80
+ 'unit' => { 'direct' => [], 'entityOp' => [] },
81
+ }},
82
+ }
83
+ end
84
+ @test_control
85
+ end
86
+
87
+ # Check sdk-test-control.json for a skip entry. Returns [skip, reason].
88
+ def self.is_control_skipped(kind, name, mode)
89
+ ctrl = load_test_control
90
+ skip = (ctrl.dig('test', 'skip', mode) || {})
91
+ items = skip[kind] || []
92
+ items.each do |item|
93
+ if kind == 'direct' && item['test'] == name
94
+ return [true, item['reason']]
95
+ end
96
+ if kind == 'entityOp'
97
+ key = "#{item['entity']}.#{item['op']}"
98
+ return [true, item['reason']] if key == name
99
+ end
100
+ end
101
+ [false, nil]
102
+ end
103
+
104
+ # Per-test live pacing delay (ms); default 500.
105
+ def self.live_delay_ms
106
+ ctrl = load_test_control
107
+ v = ctrl.dig('test', 'live', 'delayMs')
108
+ return v if v.is_a?(Integer) && v >= 0
109
+ 500
110
+ end
65
111
  end
66
112
 
67
113
  # Module-level aliases for test convenience.
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by rb test runner.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }
@@ -10,39 +10,60 @@ module ProjectNameUtilities
10
10
  body_str = fetchdef["body"]
11
11
  headers = fetchdef["headers"] || {}
12
12
 
13
- uri = URI.parse(fullurl)
14
- http = Net::HTTP.new(uri.host, uri.port)
15
- http.use_ssl = (uri.scheme == "https")
13
+ begin
14
+ uri = URI.parse(fullurl)
15
+ http = Net::HTTP.new(uri.host, uri.port)
16
+ http.use_ssl = (uri.scheme == "https")
16
17
 
17
- klass = case method_str.upcase
18
- when "POST" then Net::HTTP::Post
19
- when "PUT" then Net::HTTP::Put
20
- when "DELETE" then Net::HTTP::Delete
21
- when "PATCH" then Net::HTTP::Patch
22
- else Net::HTTP::Get
23
- end
18
+ klass = case method_str.upcase
19
+ when "POST" then Net::HTTP::Post
20
+ when "PUT" then Net::HTTP::Put
21
+ when "DELETE" then Net::HTTP::Delete
22
+ when "PATCH" then Net::HTTP::Patch
23
+ else Net::HTTP::Get
24
+ end
24
25
 
25
- request = klass.new(uri)
26
- headers.each { |k, v| request[k] = v.to_s if v.is_a?(String) }
27
- request.body = body_str if body_str.is_a?(String)
26
+ request = klass.new(uri)
27
+ has_ua = false
28
+ headers.each do |k, v|
29
+ next unless v.is_a?(String)
30
+ has_ua = true if k.to_s.downcase == 'user-agent'
31
+ request[k] = v.to_s
32
+ end
33
+ # Default User-Agent — Net::HTTP sets "Ruby" which some CDNs block.
34
+ # Use a Mozilla-shaped UA unless the caller already set one.
35
+ request['User-Agent'] = 'Mozilla/5.0 (compatible; ProjectNameSDK/1.0)' unless has_ua
36
+ request.body = body_str if body_str.is_a?(String)
28
37
 
29
- resp = http.request(request)
30
- resp_headers = {}
31
- resp.each_header { |k, v| resp_headers[k.downcase] = v }
38
+ resp = http.request(request)
39
+ resp_headers = {}
40
+ resp.each_header { |k, v| resp_headers[k.downcase] = v }
32
41
 
33
- json_body = nil
34
- begin
35
- json_body = JSON.parse(resp.body) if resp.body && !resp.body.empty?
36
- rescue JSON::ParserError
37
- end
42
+ json_body = nil
43
+ begin
44
+ json_body = JSON.parse(resp.body) if resp.body && !resp.body.empty?
45
+ rescue JSON::ParserError
46
+ end
38
47
 
39
- return {
40
- "status" => resp.code.to_i,
41
- "statusText" => resp.message,
42
- "headers" => resp_headers,
43
- "json" => -> { json_body },
44
- "body" => resp.body,
45
- }, nil
48
+ return {
49
+ "status" => resp.code.to_i,
50
+ "statusText" => resp.message,
51
+ "headers" => resp_headers,
52
+ "json" => -> { json_body },
53
+ "body" => resp.body,
54
+ }, nil
55
+ rescue StandardError => e
56
+ # Network-level failures (DNS, TCP, TLS, timeouts) — return a synthesized
57
+ # response with status 0 so callers can branch on result.ok like any
58
+ # other failed request, instead of seeing an unhandled exception.
59
+ return {
60
+ "status" => 0,
61
+ "statusText" => "#{e.class}: #{e.message}",
62
+ "headers" => {},
63
+ "json" => -> { nil },
64
+ "body" => nil,
65
+ }, nil
66
+ end
46
67
  }
47
68
 
48
69
  Fetcher = ->(ctx, fullurl, fetchdef) {
@@ -26,6 +26,22 @@ module ProjectNameUtilities
26
26
  end
27
27
  end
28
28
 
29
+ # Append query string from spec.query.
30
+ qsep = "?"
31
+ query_items = VoxgigStruct.items(spec.respond_to?(:query) ? spec.query : nil)
32
+ if query_items
33
+ query_items.each do |item|
34
+ key = item[0]
35
+ val = item[1]
36
+ if val && key.is_a?(String)
37
+ val_str = val.is_a?(String) ? val : val.to_s
38
+ url += qsep + VoxgigStruct.escurl(key) + "=" + VoxgigStruct.escurl(val_str)
39
+ qsep = "&"
40
+ resmatch[key] = val
41
+ end
42
+ end
43
+ end
44
+
29
45
  result.resmatch = resmatch
30
46
  return url, nil
31
47
  }
@@ -0,0 +1,19 @@
1
+ {
2
+ "version": 1,
3
+ "_doc": "Per-SDK test control. Lists tests/operations to skip in unit and live modes; tunes per-test pacing. Edit by hand. Loaded by ts/test/utility.ts.",
4
+ "test": {
5
+ "skip": {
6
+ "live": {
7
+ "direct": [],
8
+ "entityOp": []
9
+ },
10
+ "unit": {
11
+ "direct": [],
12
+ "entityOp": []
13
+ }
14
+ },
15
+ "live": {
16
+ "delayMs": 500
17
+ }
18
+ }
19
+ }