@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.
- package/bin/voxgig-sdkgen +1 -1
- package/package.json +3 -3
- package/project/.sdk/src/cmp/go/Entity_go.ts +2 -2
- package/project/.sdk/src/cmp/go/Main_go.ts +8 -4
- package/project/.sdk/src/cmp/go/Package_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeExplanation_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeHowto_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeInstall_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeModel_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeQuick_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeTopQuick_go.ts +2 -2
- package/project/.sdk/src/cmp/go/ReadmeTopTest_go.ts +2 -2
- package/project/.sdk/src/cmp/go/TestDirect_go.ts +204 -33
- package/project/.sdk/src/cmp/go/TestEntity_go.ts +37 -6
- package/project/.sdk/src/cmp/go/Test_go.ts +2 -2
- package/project/.sdk/src/cmp/go/fragment/Main.fragment.go +21 -4
- package/project/.sdk/src/cmp/lua/Package_lua.ts +9 -2
- package/project/.sdk/src/cmp/lua/TestDirect_lua.ts +154 -21
- package/project/.sdk/src/cmp/lua/TestEntity_lua.ts +25 -1
- package/project/.sdk/src/cmp/lua/fragment/Main.fragment.lua +20 -4
- package/project/.sdk/src/cmp/php/Package_php.ts +7 -1
- package/project/.sdk/src/cmp/php/TestDirect_php.ts +153 -20
- package/project/.sdk/src/cmp/php/TestEntity_php.ts +25 -1
- package/project/.sdk/src/cmp/php/fragment/Main.fragment.php +17 -3
- package/project/.sdk/src/cmp/py/Package_py.ts +8 -1
- package/project/.sdk/src/cmp/py/TestDirect_py.ts +146 -19
- package/project/.sdk/src/cmp/py/TestEntity_py.ts +25 -1
- package/project/.sdk/src/cmp/py/fragment/Main.fragment.py +19 -4
- package/project/.sdk/src/cmp/rb/Package_rb.ts +9 -2
- package/project/.sdk/src/cmp/rb/TestDirect_rb.ts +154 -21
- package/project/.sdk/src/cmp/rb/TestEntity_rb.ts +25 -1
- package/project/.sdk/src/cmp/rb/fragment/Main.fragment.rb +19 -3
- package/project/.sdk/src/cmp/ts/Package_ts.ts +1 -1
- package/project/.sdk/src/cmp/ts/TestDirect_ts.ts +145 -22
- package/project/.sdk/src/cmp/ts/TestEntity_ts.ts +59 -1
- package/project/.sdk/src/cmp/ts/fragment/Direct.test.fragment.ts +8 -1
- package/project/.sdk/src/cmp/ts/fragment/Entity.test.fragment.ts +8 -2
- package/project/.sdk/src/cmp/ts/fragment/Main.fragment.ts +21 -1
- package/project/.sdk/tm/go/feature/test_feature.go +51 -3
- package/project/.sdk/tm/go/test/runner_test.go +106 -6
- package/project/.sdk/tm/go/test/sdk-test-control.json +19 -0
- package/project/.sdk/tm/go/utility/fetcher.go +10 -0
- package/project/.sdk/tm/go/utility/make_url.go +12 -0
- package/project/.sdk/tm/lua/feature/test_feature.lua +41 -3
- package/project/.sdk/tm/lua/test/runner.lua +74 -0
- package/project/.sdk/tm/lua/test/sdk-test-control.json +19 -0
- package/project/.sdk/tm/lua/utility/fetcher.lua +13 -0
- package/project/.sdk/tm/lua/utility/make_url.lua +16 -0
- package/project/.sdk/tm/php/feature/TestFeature.php +185 -43
- package/project/.sdk/tm/php/test/Runner.php +62 -0
- package/project/.sdk/tm/php/test/sdk-test-control.json +19 -0
- package/project/.sdk/tm/php/utility/Fetcher.php +132 -9
- package/project/.sdk/tm/php/utility/MakeUrl.php +16 -0
- package/project/.sdk/tm/py/feature/test_feature.py +35 -3
- package/project/.sdk/tm/py/test/runner.py +60 -0
- package/project/.sdk/tm/py/test/sdk-test-control.json +19 -0
- package/project/.sdk/tm/py/utility/fetcher.py +13 -0
- package/project/.sdk/tm/py/utility/make_url.py +13 -0
- package/project/.sdk/tm/rb/feature/test_feature.rb +36 -3
- package/project/.sdk/tm/rb/test/runner.rb +46 -0
- package/project/.sdk/tm/rb/test/sdk-test-control.json +19 -0
- package/project/.sdk/tm/rb/utility/fetcher.rb +49 -28
- package/project/.sdk/tm/rb/utility/make_url.rb +16 -0
- package/project/.sdk/tm/ts/test/sdk-test-control.json +19 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
38
|
+
resp = http.request(request)
|
|
39
|
+
resp_headers = {}
|
|
40
|
+
resp.each_header { |k, v| resp_headers[k.downcase] = v }
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|