@reliantlabs/forge-web-runtime 0.13.2 → 0.13.4
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/dist/mock-rules.d.ts +61 -0
- package/dist/mock-rules.js +333 -0
- package/dist/mock-rules.js.map +1 -0
- package/dist/mock-transport.d.ts +8 -0
- package/dist/mock-transport.js +47 -15
- package/dist/mock-transport.js.map +1 -1
- package/dist/mock-values.d.ts +132 -0
- package/dist/mock-values.js +1222 -0
- package/dist/mock-values.js.map +1 -0
- package/dist/telemetry.js +156 -0
- package/dist/telemetry.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,1222 @@
|
|
|
1
|
+
// Part of @reliantlabs/forge-web-runtime — the web twin of forge/pkg.
|
|
2
|
+
//
|
|
3
|
+
// Default mock responses for ANY Connect service, built from its descriptor.
|
|
4
|
+
//
|
|
5
|
+
// The entity fixtures (mock-transport.ts) serve generated CRUD only: every
|
|
6
|
+
// other RPC — a dashboard read, a search, a stream — used to need a
|
|
7
|
+
// hand-written scenario handler before a mocked page rendered anything at
|
|
8
|
+
// all. This module answers every RPC of every service with a deterministic,
|
|
9
|
+
// schema-valid message, so a scenario writes only what it wants to SAY, not
|
|
10
|
+
// the shape of the whole response:
|
|
11
|
+
//
|
|
12
|
+
// serviceDefaults(Service) every RPC's default handler (the generated
|
|
13
|
+
// src/mocks/services/*_gen.ts call this)
|
|
14
|
+
// mockService(Service, {...}) typed per-RPC overrides for a scenario,
|
|
15
|
+
// each handed the defaults to start from
|
|
16
|
+
// patch(message, {...}) deep-merge a change onto a default
|
|
17
|
+
// mockValue(Schema) one message, for a test or a factory
|
|
18
|
+
//
|
|
19
|
+
// Values are built at RUNTIME from the descriptor, not emitted as literals.
|
|
20
|
+
// protoc-gen-es embeds every file's FileDescriptorProto — options included —
|
|
21
|
+
// in its `_pb.ts`, so the protovalidate rules (min/max, len, pattern, `in`,
|
|
22
|
+
// formats) are read off `field.proto.options` (mock-rules.ts), with no buf.validate
|
|
23
|
+
// TypeScript in the app and no per-project codegen beyond one import per
|
|
24
|
+
// service. A vendored contract (another repo's protos, generated by its own
|
|
25
|
+
// buf template) works the same as the project's own.
|
|
26
|
+
//
|
|
27
|
+
// What a value is, by field (the field's NAME is a hint, never vocabulary —
|
|
28
|
+
// forge does not invent a domain the schema does not declare):
|
|
29
|
+
//
|
|
30
|
+
// `id` / `*_id` a stable id: `<thing>-<8 hex>`; `trace_id` and
|
|
31
|
+
// `span_id` are 32/16 hex, as OpenTelemetry's are
|
|
32
|
+
// Timestamp, `*_at` recent times; list items newest first; within one
|
|
33
|
+
// message, `start*`/`created*` precede `end*`/`updated*`
|
|
34
|
+
// `email`, `url`, ... example.com addresses and links
|
|
35
|
+
// `name`, `title` "<Message> <n>", numbered by list position
|
|
36
|
+
// other strings "<field>-<n>"
|
|
37
|
+
// numbers a range the name suggests (`*_ms`, `count`, `port`,
|
|
38
|
+
// `percent`, `*_bytes`, ...), else 1..100
|
|
39
|
+
// enums cycle through the defined values (skipping the
|
|
40
|
+
// zero UNSPECIFIED) across a list's items
|
|
41
|
+
// repeated 3–10 items; maps 3–5 entries
|
|
42
|
+
// oneof one member, chosen by the seed
|
|
43
|
+
// Duration, Struct, Value, wrappers handled as their JSON forms suggest
|
|
44
|
+
// `next_page_token` always "" — a mock that pages forever is a bug
|
|
45
|
+
//
|
|
46
|
+
// Then the field's protovalidate rules constrain it: `const` and `in` win,
|
|
47
|
+
// numbers are clamped into gt/gte/lt/lte, strings meet len/min_len/max_len,
|
|
48
|
+
// prefix/suffix/contains, the well-known formats (email, uri, uuid, hostname,
|
|
49
|
+
// ip, ulid) and, where it is cheap, `pattern`. Repeated and map sizes honour
|
|
50
|
+
// min_items/max_items and min_pairs/max_pairs. CEL rules are not evaluated.
|
|
51
|
+
//
|
|
52
|
+
// Determinism: every value is a function of (seed, field path). The seed of a
|
|
53
|
+
// default handler is the method plus the request's wire bytes, so the same
|
|
54
|
+
// request always gets the same answer, and a field added to a message does not
|
|
55
|
+
// reshuffle the others. Scalar fields that the REQUEST also carries (by name
|
|
56
|
+
// and type) echo the request — `GetWorkload{id}` answers that id, and a filter
|
|
57
|
+
// field reads back as filtered — except paging and query controls.
|
|
58
|
+
import { create, isMessage, ScalarType, toBinary, } from "@bufbuild/protobuf";
|
|
59
|
+
import { rulesOf, } from "./mock-rules.js";
|
|
60
|
+
/**
|
|
61
|
+
* Typed factory helper: `mockFactory(TraceSummarySchema, ({ base, index }) =>
|
|
62
|
+
* ({ rootService: index % 2 ? "web" : "api" }))` — the result is patched onto
|
|
63
|
+
* the default for every TraceSummary the engine builds.
|
|
64
|
+
*/
|
|
65
|
+
export function mockFactory(schema, build) {
|
|
66
|
+
return { schema, build };
|
|
67
|
+
}
|
|
68
|
+
// ── Determinism ─────────────────────────────────────────────────────────
|
|
69
|
+
/** FNV-1a, 32-bit: small, fast, and stable across engines. */
|
|
70
|
+
function fnv1a(input) {
|
|
71
|
+
let h = 0x811c9dc5;
|
|
72
|
+
if (typeof input === "string") {
|
|
73
|
+
for (let i = 0; i < input.length; i++) {
|
|
74
|
+
h ^= input.charCodeAt(i);
|
|
75
|
+
h = Math.imul(h, 0x01000193);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
for (const b of input) {
|
|
80
|
+
h ^= b;
|
|
81
|
+
h = Math.imul(h, 0x01000193);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return h >>> 0;
|
|
85
|
+
}
|
|
86
|
+
/** mulberry32 over an FNV-1a seed: a deterministic stream per (seed, path). */
|
|
87
|
+
class Rng {
|
|
88
|
+
state;
|
|
89
|
+
constructor(seed) {
|
|
90
|
+
this.state = fnv1a(seed) || 0x9e3779b9;
|
|
91
|
+
}
|
|
92
|
+
next() {
|
|
93
|
+
this.state = (this.state + 0x6d2b79f5) | 0;
|
|
94
|
+
let t = this.state;
|
|
95
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
96
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
97
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
98
|
+
}
|
|
99
|
+
/** An integer in [lo, hi], inclusive. */
|
|
100
|
+
int(lo, hi) {
|
|
101
|
+
if (hi <= lo)
|
|
102
|
+
return lo;
|
|
103
|
+
return lo + Math.floor(this.next() * (hi - lo + 1));
|
|
104
|
+
}
|
|
105
|
+
pick(items) {
|
|
106
|
+
return items[this.int(0, items.length - 1)];
|
|
107
|
+
}
|
|
108
|
+
hex(n) {
|
|
109
|
+
let out = "";
|
|
110
|
+
while (out.length < n)
|
|
111
|
+
out += this.int(0, 15).toString(16);
|
|
112
|
+
return out;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
let sessionNow;
|
|
116
|
+
function anchorMs(now) {
|
|
117
|
+
if (now !== undefined)
|
|
118
|
+
return typeof now === "number" ? now : now.getTime();
|
|
119
|
+
sessionNow ??= Math.floor(Date.now() / 60_000) * 60_000;
|
|
120
|
+
return sessionNow;
|
|
121
|
+
}
|
|
122
|
+
// ── Names ───────────────────────────────────────────────────────────────
|
|
123
|
+
const words = (s) => s
|
|
124
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
125
|
+
.toLowerCase()
|
|
126
|
+
.split(/[^a-z0-9]+/)
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
const kebab = (s) => words(s).join("-");
|
|
129
|
+
/** `labels` → `label`, `entries` → `entry`: a list item is one of the list. */
|
|
130
|
+
function singularOf(s) {
|
|
131
|
+
if (/ies$/.test(s))
|
|
132
|
+
return s.slice(0, -3) + "y";
|
|
133
|
+
if (/(ss|us|is)$/.test(s))
|
|
134
|
+
return s;
|
|
135
|
+
return s.replace(/s$/, "");
|
|
136
|
+
}
|
|
137
|
+
function human(s) {
|
|
138
|
+
const w = words(s).join(" ");
|
|
139
|
+
return w.charAt(0).toUpperCase() + w.slice(1);
|
|
140
|
+
}
|
|
141
|
+
/** The thing a message stands for: `ListTracesResponse` → `List traces`. */
|
|
142
|
+
function messageNoun(desc) {
|
|
143
|
+
return desc.name.replace(/(Request|Response)$/, "") || desc.name;
|
|
144
|
+
}
|
|
145
|
+
function hasWord(name, ...candidates) {
|
|
146
|
+
const w = words(name);
|
|
147
|
+
return candidates.some((c) => w.includes(c));
|
|
148
|
+
}
|
|
149
|
+
// ── Strings ─────────────────────────────────────────────────────────────
|
|
150
|
+
/** Paging and query controls: echoing or inventing them misleads the caller. */
|
|
151
|
+
const CONTROL_FIELDS = new Set([
|
|
152
|
+
"page_size",
|
|
153
|
+
"page_token",
|
|
154
|
+
"next_page_token",
|
|
155
|
+
"order_by",
|
|
156
|
+
"query",
|
|
157
|
+
"search",
|
|
158
|
+
"filter",
|
|
159
|
+
"limit",
|
|
160
|
+
"offset",
|
|
161
|
+
"cursor",
|
|
162
|
+
]);
|
|
163
|
+
function uuidFrom(rng) {
|
|
164
|
+
const h = rng.hex(32);
|
|
165
|
+
return `${h.slice(0, 8)}-${h.slice(8, 12)}-4${h.slice(13, 16)}-${"89ab"[rng.int(0, 3)]}${h.slice(17, 20)}-${h.slice(20)}`;
|
|
166
|
+
}
|
|
167
|
+
function formatted(format, rng, n) {
|
|
168
|
+
switch (format) {
|
|
169
|
+
case "email":
|
|
170
|
+
return `user-${n}@example.com`;
|
|
171
|
+
case "uri":
|
|
172
|
+
return `https://example.com/${rng.hex(8)}`;
|
|
173
|
+
case "hostname":
|
|
174
|
+
case "address":
|
|
175
|
+
return `host-${n}.example.com`;
|
|
176
|
+
case "hostAndPort":
|
|
177
|
+
return `host-${n}.example.com:8080`;
|
|
178
|
+
case "ip":
|
|
179
|
+
case "ipv4":
|
|
180
|
+
return `10.${rng.int(0, 255)}.${rng.int(0, 255)}.${rng.int(1, 254)}`;
|
|
181
|
+
case "ipv6":
|
|
182
|
+
return `fd00::${rng.hex(4)}`;
|
|
183
|
+
case "ipPrefix":
|
|
184
|
+
return `10.${rng.int(0, 255)}.0.0/16`;
|
|
185
|
+
case "uuid":
|
|
186
|
+
return uuidFrom(rng);
|
|
187
|
+
case "tuuid":
|
|
188
|
+
return rng.hex(32);
|
|
189
|
+
case "ulid": {
|
|
190
|
+
const alphabet = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
191
|
+
let s = "01";
|
|
192
|
+
while (s.length < 26)
|
|
193
|
+
s += alphabet[rng.int(0, 31)];
|
|
194
|
+
return s;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
/** A string for a field, from its name alone (before any rule applies). */
|
|
199
|
+
function stringByName(name, owner, n, rng, listItem = false) {
|
|
200
|
+
const w = words(name);
|
|
201
|
+
const last = w[w.length - 1] ?? "";
|
|
202
|
+
if (name === "next_page_token" ||
|
|
203
|
+
name === "page_token" ||
|
|
204
|
+
name === "cursor") {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
if (name === "trace_id")
|
|
208
|
+
return rng.hex(32);
|
|
209
|
+
if (name === "span_id" || name === "parent_span_id")
|
|
210
|
+
return rng.hex(16);
|
|
211
|
+
if (last === "uuid")
|
|
212
|
+
return uuidFrom(rng);
|
|
213
|
+
if (name === "id")
|
|
214
|
+
return `${kebab(messageNoun(owner))}-${rng.hex(8)}`;
|
|
215
|
+
if (last === "id" || last === "ids") {
|
|
216
|
+
const thing = w.slice(0, -1).join("-") || "id";
|
|
217
|
+
return `${thing}-${rng.hex(8)}`;
|
|
218
|
+
}
|
|
219
|
+
if (last === "email" || last === "emails")
|
|
220
|
+
return `user-${n}@example.com`;
|
|
221
|
+
if (["url", "uri", "link", "href", "endpoint", "website"].includes(last)) {
|
|
222
|
+
return `https://example.com/${w.slice(0, -1).join("-") || "item"}/${n}`;
|
|
223
|
+
}
|
|
224
|
+
if (["host", "hostname", "domain", "fqdn"].includes(last)) {
|
|
225
|
+
return `host-${n}.example.com`;
|
|
226
|
+
}
|
|
227
|
+
if (last === "ip" || name === "ip_address") {
|
|
228
|
+
return `10.0.${rng.int(0, 255)}.${rng.int(1, 254)}`;
|
|
229
|
+
}
|
|
230
|
+
if (last === "phone")
|
|
231
|
+
return `+1-555-01${String(n % 100).padStart(2, "0")}`;
|
|
232
|
+
if (last === "version")
|
|
233
|
+
return `1.${rng.int(0, 20)}.${rng.int(0, 9)}`;
|
|
234
|
+
if (last === "color" || last === "colour")
|
|
235
|
+
return `#${rng.hex(6)}`;
|
|
236
|
+
if (["sha", "commit", "hash", "etag", "fingerprint"].includes(last))
|
|
237
|
+
return rng.hex(40);
|
|
238
|
+
if (last === "digest")
|
|
239
|
+
return `sha256:${rng.hex(64)}`;
|
|
240
|
+
if (last === "token" ||
|
|
241
|
+
last === "secret" ||
|
|
242
|
+
(last === "key" && w.includes("api"))) {
|
|
243
|
+
return `tok_${rng.hex(24)}`;
|
|
244
|
+
}
|
|
245
|
+
if (last === "json")
|
|
246
|
+
return "{}";
|
|
247
|
+
if (last === "locale" || last === "language")
|
|
248
|
+
return "en-US";
|
|
249
|
+
if (last === "currency")
|
|
250
|
+
return "USD";
|
|
251
|
+
if (last === "country")
|
|
252
|
+
return "US";
|
|
253
|
+
if (last === "timezone" || name === "tz")
|
|
254
|
+
return "UTC";
|
|
255
|
+
if (["name", "title", "label"].includes(name) || name === "display_name") {
|
|
256
|
+
return `${human(messageNoun(owner))} ${n}`;
|
|
257
|
+
}
|
|
258
|
+
if (last === "name" || last === "title") {
|
|
259
|
+
return `${human(w.slice(0, -1).join("_"))} ${n}`;
|
|
260
|
+
}
|
|
261
|
+
if ([
|
|
262
|
+
"description",
|
|
263
|
+
"summary",
|
|
264
|
+
"message",
|
|
265
|
+
"body",
|
|
266
|
+
"text",
|
|
267
|
+
"details",
|
|
268
|
+
"detail",
|
|
269
|
+
"reason",
|
|
270
|
+
"comment",
|
|
271
|
+
"note",
|
|
272
|
+
"notes",
|
|
273
|
+
"content",
|
|
274
|
+
"error",
|
|
275
|
+
].includes(last)) {
|
|
276
|
+
return `Sample ${human(messageNoun(owner)).toLowerCase()} ${w.join(" ")} ${n}.`;
|
|
277
|
+
}
|
|
278
|
+
return `${kebab(listItem ? singularOf(name) : name)}-${n}`;
|
|
279
|
+
}
|
|
280
|
+
/** The smallest string matching a cheap regex, or undefined when it is not cheap. */
|
|
281
|
+
function fromPattern(pattern, rng) {
|
|
282
|
+
let i = 0;
|
|
283
|
+
const src = pattern;
|
|
284
|
+
const charClass = () => {
|
|
285
|
+
// At "[" — collect ranges; a negated class picks the first printable miss.
|
|
286
|
+
i++;
|
|
287
|
+
const negate = src[i] === "^";
|
|
288
|
+
if (negate)
|
|
289
|
+
i++;
|
|
290
|
+
const ranges = [];
|
|
291
|
+
let first = true;
|
|
292
|
+
while (i < src.length && (src[i] !== "]" || first)) {
|
|
293
|
+
first = false;
|
|
294
|
+
let c = src[i++];
|
|
295
|
+
if (c === "\\") {
|
|
296
|
+
const e = src[i++];
|
|
297
|
+
if (e === "d") {
|
|
298
|
+
ranges.push([48, 57]);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (e === "w") {
|
|
302
|
+
ranges.push([97, 122], [65, 90], [48, 57], [95, 95]);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (e === "s") {
|
|
306
|
+
ranges.push([32, 32]);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
c = e;
|
|
310
|
+
}
|
|
311
|
+
if (src[i] === "-" && src[i + 1] !== "]" && i + 1 < src.length) {
|
|
312
|
+
i++;
|
|
313
|
+
let hi = src[i++];
|
|
314
|
+
if (hi === "\\")
|
|
315
|
+
hi = src[i++];
|
|
316
|
+
ranges.push([c.charCodeAt(0), hi.charCodeAt(0)]);
|
|
317
|
+
}
|
|
318
|
+
else {
|
|
319
|
+
ranges.push([c.charCodeAt(0), c.charCodeAt(0)]);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
i++; // "]"
|
|
323
|
+
if (negate) {
|
|
324
|
+
return () => {
|
|
325
|
+
for (let code = 97; code < 127; code++) {
|
|
326
|
+
if (!ranges.some(([lo, hi]) => code >= lo && code <= hi)) {
|
|
327
|
+
return String.fromCharCode(code);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return "_";
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
return () => {
|
|
334
|
+
const [lo, hi] = rng.pick(ranges);
|
|
335
|
+
return String.fromCharCode(rng.int(lo, hi));
|
|
336
|
+
};
|
|
337
|
+
};
|
|
338
|
+
const atom = () => {
|
|
339
|
+
const c = src[i];
|
|
340
|
+
if (c === "(") {
|
|
341
|
+
i++;
|
|
342
|
+
if (src[i] === "?") {
|
|
343
|
+
if (src[i + 1] !== ":")
|
|
344
|
+
return undefined; // lookarounds: not cheap
|
|
345
|
+
i += 2;
|
|
346
|
+
}
|
|
347
|
+
const inner = alternation();
|
|
348
|
+
if (src[i] !== ")" || !inner)
|
|
349
|
+
return undefined;
|
|
350
|
+
i++;
|
|
351
|
+
return inner;
|
|
352
|
+
}
|
|
353
|
+
if (c === "[")
|
|
354
|
+
return charClass();
|
|
355
|
+
if (c === ".") {
|
|
356
|
+
i++;
|
|
357
|
+
return () => String.fromCharCode(rng.int(97, 122));
|
|
358
|
+
}
|
|
359
|
+
if (c === "\\") {
|
|
360
|
+
const e = src[i + 1];
|
|
361
|
+
i += 2;
|
|
362
|
+
if (e === "d")
|
|
363
|
+
return () => String(rng.int(0, 9));
|
|
364
|
+
if (e === "w")
|
|
365
|
+
return () => String.fromCharCode(rng.int(97, 122));
|
|
366
|
+
if (e === "s")
|
|
367
|
+
return () => " ";
|
|
368
|
+
if (/[A-Za-z]/.test(e))
|
|
369
|
+
return undefined; // \b, \p{..}, ...: not cheap
|
|
370
|
+
return () => e;
|
|
371
|
+
}
|
|
372
|
+
i++;
|
|
373
|
+
return () => c;
|
|
374
|
+
};
|
|
375
|
+
const sequence = () => {
|
|
376
|
+
const parts = [];
|
|
377
|
+
while (i < src.length && src[i] !== "|" && src[i] !== ")") {
|
|
378
|
+
if (src[i] === "^" || src[i] === "$") {
|
|
379
|
+
i++;
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const a = atom();
|
|
383
|
+
if (!a)
|
|
384
|
+
return undefined;
|
|
385
|
+
let min = 1;
|
|
386
|
+
let max = 1;
|
|
387
|
+
const q = src[i];
|
|
388
|
+
if (q === "?" || q === "*" || q === "+") {
|
|
389
|
+
i++;
|
|
390
|
+
min = q === "+" ? 1 : 0;
|
|
391
|
+
max = q === "?" ? 1 : min + 3;
|
|
392
|
+
}
|
|
393
|
+
else if (q === "{") {
|
|
394
|
+
const m = /^\{(\d+)(,(\d*))?\}/.exec(src.slice(i));
|
|
395
|
+
if (!m)
|
|
396
|
+
return undefined;
|
|
397
|
+
i += m[0].length;
|
|
398
|
+
min = Number(m[1]);
|
|
399
|
+
max = m[2] ? (m[3] ? Number(m[3]) : min + 3) : min;
|
|
400
|
+
}
|
|
401
|
+
if (src[i] === "?")
|
|
402
|
+
i++; // lazy: same strings
|
|
403
|
+
const lo = min;
|
|
404
|
+
const hi = Math.min(max, min + 3);
|
|
405
|
+
parts.push(() => {
|
|
406
|
+
let s = "";
|
|
407
|
+
const count = rng.int(Math.max(lo, 1), Math.max(hi, 1));
|
|
408
|
+
for (let k = 0; k < (lo === 0 && hi === 0 ? 0 : count); k++)
|
|
409
|
+
s += a();
|
|
410
|
+
return s;
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
return () => parts.map((p) => p()).join("");
|
|
414
|
+
};
|
|
415
|
+
const alternation = () => {
|
|
416
|
+
const branches = [];
|
|
417
|
+
for (;;) {
|
|
418
|
+
const s = sequence();
|
|
419
|
+
if (!s)
|
|
420
|
+
return undefined;
|
|
421
|
+
branches.push(s);
|
|
422
|
+
if (src[i] !== "|")
|
|
423
|
+
break;
|
|
424
|
+
i++;
|
|
425
|
+
}
|
|
426
|
+
return () => rng.pick(branches)();
|
|
427
|
+
};
|
|
428
|
+
try {
|
|
429
|
+
const re = new RegExp(pattern, "u");
|
|
430
|
+
const node = alternation();
|
|
431
|
+
if (!node || i < src.length)
|
|
432
|
+
return undefined;
|
|
433
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
434
|
+
const s = node();
|
|
435
|
+
if (re.test(s))
|
|
436
|
+
return s;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
// An RE2 construct JavaScript does not accept: not cheap.
|
|
441
|
+
}
|
|
442
|
+
return undefined;
|
|
443
|
+
}
|
|
444
|
+
function applyStringRules(value, rules, rng, n) {
|
|
445
|
+
if (rules.const !== undefined)
|
|
446
|
+
return rules.const;
|
|
447
|
+
if (rules.in?.length)
|
|
448
|
+
return rng.pick(rules.in);
|
|
449
|
+
let s = value;
|
|
450
|
+
if (rules.format)
|
|
451
|
+
s = formatted(rules.format, rng, n);
|
|
452
|
+
if (rules.pattern) {
|
|
453
|
+
let ok = false;
|
|
454
|
+
try {
|
|
455
|
+
ok = new RegExp(rules.pattern, "u").test(s);
|
|
456
|
+
}
|
|
457
|
+
catch {
|
|
458
|
+
ok = true; // not JavaScript-parsable: leave the value alone
|
|
459
|
+
}
|
|
460
|
+
if (!ok)
|
|
461
|
+
s = fromPattern(rules.pattern, rng) ?? s;
|
|
462
|
+
}
|
|
463
|
+
if (rules.prefix && !s.startsWith(rules.prefix))
|
|
464
|
+
s = rules.prefix + s;
|
|
465
|
+
if (rules.suffix && !s.endsWith(rules.suffix))
|
|
466
|
+
s = s + rules.suffix;
|
|
467
|
+
if (rules.contains && !s.includes(rules.contains))
|
|
468
|
+
s = s + rules.contains;
|
|
469
|
+
if (rules.notIn?.includes(s))
|
|
470
|
+
s = `${s}-${n}`;
|
|
471
|
+
const min = rules.len ?? rules.minLen;
|
|
472
|
+
const max = rules.len ?? rules.maxLen;
|
|
473
|
+
const chars = [...s];
|
|
474
|
+
if (max !== undefined && chars.length > max) {
|
|
475
|
+
s = chars.slice(0, max).join("");
|
|
476
|
+
}
|
|
477
|
+
else if (min !== undefined && chars.length < min) {
|
|
478
|
+
s = s + "x".repeat(min - chars.length);
|
|
479
|
+
}
|
|
480
|
+
return s;
|
|
481
|
+
}
|
|
482
|
+
// ── Numbers ─────────────────────────────────────────────────────────────
|
|
483
|
+
const INT_LIMITS = {
|
|
484
|
+
[ScalarType.INT32]: [-2147483648, 2147483647],
|
|
485
|
+
[ScalarType.SINT32]: [-2147483648, 2147483647],
|
|
486
|
+
[ScalarType.SFIXED32]: [-2147483648, 2147483647],
|
|
487
|
+
[ScalarType.UINT32]: [0, 4294967295],
|
|
488
|
+
[ScalarType.FIXED32]: [0, 4294967295],
|
|
489
|
+
[ScalarType.UINT64]: [0, Number.MAX_SAFE_INTEGER],
|
|
490
|
+
[ScalarType.FIXED64]: [0, Number.MAX_SAFE_INTEGER],
|
|
491
|
+
[ScalarType.INT64]: [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
|
492
|
+
[ScalarType.SINT64]: [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
|
493
|
+
[ScalarType.SFIXED64]: [-Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER],
|
|
494
|
+
};
|
|
495
|
+
const isFloat = (t) => t === ScalarType.DOUBLE || t === ScalarType.FLOAT;
|
|
496
|
+
const is64 = (t) => t === ScalarType.INT64 ||
|
|
497
|
+
t === ScalarType.UINT64 ||
|
|
498
|
+
t === ScalarType.SINT64 ||
|
|
499
|
+
t === ScalarType.FIXED64 ||
|
|
500
|
+
t === ScalarType.SFIXED64;
|
|
501
|
+
/** Epoch unit a time-valued integer field is in, from its name. */
|
|
502
|
+
function epochUnit(name) {
|
|
503
|
+
const w = words(name);
|
|
504
|
+
const timeish = hasWord(name, "unix", "epoch", "timestamp", "at") ||
|
|
505
|
+
(w[w.length - 1] === "time" && !hasWord(name, "duration", "elapsed")) ||
|
|
506
|
+
(w.includes("time") &&
|
|
507
|
+
hasWord(name, "nano", "nanos", "ns", "ms", "millis"));
|
|
508
|
+
if (!timeish || hasWord(name, "duration", "latency", "elapsed", "took"))
|
|
509
|
+
return undefined;
|
|
510
|
+
if (hasWord(name, "nano", "nanos", "ns"))
|
|
511
|
+
return 1e6;
|
|
512
|
+
if (hasWord(name, "us", "micros"))
|
|
513
|
+
return 1e3;
|
|
514
|
+
if (hasWord(name, "ms", "millis"))
|
|
515
|
+
return 1;
|
|
516
|
+
return 1e-3;
|
|
517
|
+
}
|
|
518
|
+
/** The range a number's name suggests: [lo, hi]. */
|
|
519
|
+
function numberRange(name, float) {
|
|
520
|
+
const durationish = hasWord(name, "duration", "latency", "elapsed", "took", "time");
|
|
521
|
+
if (durationish || hasWord(name, "ms", "millis")) {
|
|
522
|
+
if (hasWord(name, "ns", "nanos", "nano"))
|
|
523
|
+
return [100_000, 2_000_000_000];
|
|
524
|
+
if (hasWord(name, "us", "micros"))
|
|
525
|
+
return [100, 2_000_000];
|
|
526
|
+
if (hasWord(name, "seconds", "secs", "s", "sec"))
|
|
527
|
+
return float ? [0.01, 5] : [1, 120];
|
|
528
|
+
return [1, 2000];
|
|
529
|
+
}
|
|
530
|
+
if (hasWord(name, "seconds", "secs"))
|
|
531
|
+
return [1, 3600];
|
|
532
|
+
if (hasWord(name, "percent", "pct", "percentage"))
|
|
533
|
+
return [0, 100];
|
|
534
|
+
if (hasWord(name, "ratio", "fraction", "share", "probability", "rate") &&
|
|
535
|
+
float)
|
|
536
|
+
return [0, 1];
|
|
537
|
+
if (hasWord(name, "rate"))
|
|
538
|
+
return [0, 100];
|
|
539
|
+
if (hasWord(name, "bytes", "size") && !hasWord(name, "page"))
|
|
540
|
+
return [1024, 104_857_600];
|
|
541
|
+
if (hasWord(name, "port"))
|
|
542
|
+
return [1024, 65535];
|
|
543
|
+
if (hasWord(name, "year"))
|
|
544
|
+
return [2020, 2026];
|
|
545
|
+
if (hasWord(name, "month"))
|
|
546
|
+
return [1, 12];
|
|
547
|
+
if (hasWord(name, "day"))
|
|
548
|
+
return [1, 28];
|
|
549
|
+
if (hasWord(name, "hour"))
|
|
550
|
+
return [0, 23];
|
|
551
|
+
if (hasWord(name, "minute", "second"))
|
|
552
|
+
return [0, 59];
|
|
553
|
+
if (hasWord(name, "lat", "latitude"))
|
|
554
|
+
return [-90, 90];
|
|
555
|
+
if (hasWord(name, "lng", "lon", "longitude"))
|
|
556
|
+
return [-180, 180];
|
|
557
|
+
if (hasWord(name, "version", "revision", "generation", "attempt", "attempts", "retry", "retries", "priority", "level", "rank")) {
|
|
558
|
+
return [1, 10];
|
|
559
|
+
}
|
|
560
|
+
if (name === "severity_number")
|
|
561
|
+
return [1, 24];
|
|
562
|
+
if (hasWord(name, "cents", "price", "amount", "cost"))
|
|
563
|
+
return float ? [1, 1000] : [100, 100_000];
|
|
564
|
+
if (hasWord(name, "count", "total", "num", "number", "length", "requests", "errors", "hits", "calls", "events", "items", "value")) {
|
|
565
|
+
return [0, 1000];
|
|
566
|
+
}
|
|
567
|
+
return float ? [0, 1000] : [1, 100];
|
|
568
|
+
}
|
|
569
|
+
/** Clamp/choose a number by its rules. `step` is 1 for integers. */
|
|
570
|
+
function applyNumRules(value, rules, rng, float) {
|
|
571
|
+
if (!rules)
|
|
572
|
+
return value;
|
|
573
|
+
if (rules.const !== undefined)
|
|
574
|
+
return rules.const;
|
|
575
|
+
if (rules.in?.length)
|
|
576
|
+
return rng.pick(rules.in);
|
|
577
|
+
const eps = float ? 0.01 : 1;
|
|
578
|
+
let lo = -Infinity;
|
|
579
|
+
let hi = Infinity;
|
|
580
|
+
if (rules.gte !== undefined)
|
|
581
|
+
lo = rules.gte;
|
|
582
|
+
if (rules.gt !== undefined)
|
|
583
|
+
lo = Math.max(lo, rules.gt + eps);
|
|
584
|
+
if (rules.lte !== undefined)
|
|
585
|
+
hi = rules.lte;
|
|
586
|
+
if (rules.lt !== undefined)
|
|
587
|
+
hi = Math.min(hi, rules.lt - eps);
|
|
588
|
+
let v = value;
|
|
589
|
+
// protovalidate allows an exclusive range (gt > lt: outside the band); then
|
|
590
|
+
// either side is valid, and the value already on one side is kept.
|
|
591
|
+
const exclusive = lo > hi;
|
|
592
|
+
if (!exclusive) {
|
|
593
|
+
if (v < lo || v > hi) {
|
|
594
|
+
const width = Number.isFinite(hi - lo) ? hi - lo : 100;
|
|
595
|
+
v =
|
|
596
|
+
lo === -Infinity
|
|
597
|
+
? hi - rng.next() * Math.min(width, 100)
|
|
598
|
+
: lo + rng.next() * Math.min(width, 100);
|
|
599
|
+
}
|
|
600
|
+
v = Math.min(Math.max(v, lo), hi);
|
|
601
|
+
}
|
|
602
|
+
else if (v < lo && v > hi) {
|
|
603
|
+
v = lo;
|
|
604
|
+
}
|
|
605
|
+
if (!float)
|
|
606
|
+
v = Math.round(v);
|
|
607
|
+
if (!float && rules.gt !== undefined && v <= rules.gt)
|
|
608
|
+
v = rules.gt + 1;
|
|
609
|
+
if (!float && rules.lt !== undefined && v >= rules.lt)
|
|
610
|
+
v = rules.lt - 1;
|
|
611
|
+
for (let k = 0; rules.notIn?.includes(v) && k < 100; k++)
|
|
612
|
+
v += eps;
|
|
613
|
+
return v;
|
|
614
|
+
}
|
|
615
|
+
/** Two decimals, unless rounding would step outside the rules' bounds. */
|
|
616
|
+
function roundWithin(v, rules) {
|
|
617
|
+
if (rules?.const !== undefined || rules?.in?.length)
|
|
618
|
+
return v;
|
|
619
|
+
const r = Math.round(v * 100) / 100;
|
|
620
|
+
const lo = rules?.gt ?? rules?.gte ?? -Infinity;
|
|
621
|
+
const hi = rules?.lt ?? rules?.lte ?? Infinity;
|
|
622
|
+
const inside = r >= lo && r <= hi && r !== rules?.gt && r !== rules?.lt;
|
|
623
|
+
return inside ? r : v;
|
|
624
|
+
}
|
|
625
|
+
const rngAt = (ctx, key) => new Rng(`${ctx.seed}|${ctx.path}.${key}`);
|
|
626
|
+
/** The number list items are labelled with: their position, else a small stable one. */
|
|
627
|
+
const ordinal = (ctx, rng) => ctx.index >= 0 ? ctx.index + 1 : rng.int(1, 9);
|
|
628
|
+
/** Schemas the engine has walked, so `patch` can rebuild a nested message. */
|
|
629
|
+
const knownSchemas = new Map();
|
|
630
|
+
function collectEcho(schema, message) {
|
|
631
|
+
const out = new Map();
|
|
632
|
+
const walk = (desc, msg, depth) => {
|
|
633
|
+
for (const f of desc.fields) {
|
|
634
|
+
if (f.oneof)
|
|
635
|
+
continue;
|
|
636
|
+
const v = msg[f.localName];
|
|
637
|
+
if (v == null)
|
|
638
|
+
continue;
|
|
639
|
+
if (f.fieldKind === "scalar") {
|
|
640
|
+
if (f.scalar === ScalarType.BOOL || f.scalar === ScalarType.BYTES)
|
|
641
|
+
continue;
|
|
642
|
+
if (v === "" || v === 0 || v === 0n || CONTROL_FIELDS.has(f.name))
|
|
643
|
+
continue;
|
|
644
|
+
if (!out.has(f.name))
|
|
645
|
+
out.set(f.name, { value: v, scalar: f.scalar });
|
|
646
|
+
}
|
|
647
|
+
else if (f.fieldKind === "enum") {
|
|
648
|
+
if (v === 0)
|
|
649
|
+
continue;
|
|
650
|
+
if (!out.has(f.name))
|
|
651
|
+
out.set(f.name, { value: v, enumType: f.enum.typeName });
|
|
652
|
+
}
|
|
653
|
+
else if (f.fieldKind === "message" && depth < 2 && isMessage(v)) {
|
|
654
|
+
walk(f.message, v, depth + 1);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
};
|
|
658
|
+
walk(schema, message, 0);
|
|
659
|
+
return out;
|
|
660
|
+
}
|
|
661
|
+
function echoFor(ctx, name, scalar, enumType) {
|
|
662
|
+
// A bare `id` is the record's own key: echo it only on the answer's top
|
|
663
|
+
// record, never on list items or nested records (they would all collide).
|
|
664
|
+
if (name === "id" && (ctx.index >= 0 || ctx.depth > 1))
|
|
665
|
+
return undefined;
|
|
666
|
+
if (CONTROL_FIELDS.has(name))
|
|
667
|
+
return undefined;
|
|
668
|
+
const e = ctx.echo.get(name);
|
|
669
|
+
if (!e)
|
|
670
|
+
return undefined;
|
|
671
|
+
if (scalar !== undefined && e.scalar !== scalar) {
|
|
672
|
+
// Same family is good enough: an int32 filter echoes into an int64 field.
|
|
673
|
+
const fam = (t) => t === undefined
|
|
674
|
+
? "?"
|
|
675
|
+
: t === ScalarType.STRING
|
|
676
|
+
? "s"
|
|
677
|
+
: isFloat(t)
|
|
678
|
+
? "f"
|
|
679
|
+
: "i";
|
|
680
|
+
if (fam(e.scalar) !== fam(scalar))
|
|
681
|
+
return undefined;
|
|
682
|
+
}
|
|
683
|
+
if (enumType !== undefined && e.enumType !== enumType)
|
|
684
|
+
return undefined;
|
|
685
|
+
return e;
|
|
686
|
+
}
|
|
687
|
+
function scalarValue(scalar, name, owner, rules, ctx, longAsString) {
|
|
688
|
+
const rng = rngAt(ctx, name);
|
|
689
|
+
const n = ordinal(ctx, rng);
|
|
690
|
+
const echo = echoFor(ctx, name, scalar);
|
|
691
|
+
if (echo)
|
|
692
|
+
return coerceScalar(scalar, echo.value, longAsString);
|
|
693
|
+
switch (scalar) {
|
|
694
|
+
case ScalarType.STRING: {
|
|
695
|
+
const s = stringByName(name, owner, n, rng, ctx.index >= 0);
|
|
696
|
+
return rules?.str ? applyStringRules(s, rules.str, rng, n) : s;
|
|
697
|
+
}
|
|
698
|
+
case ScalarType.BOOL: {
|
|
699
|
+
if (rules?.bool !== undefined)
|
|
700
|
+
return rules.bool;
|
|
701
|
+
const likely = hasWord(name, "enabled", "active", "healthy", "success", "ok", "valid", "available", "ready", "visible", "verified", "public");
|
|
702
|
+
const rare = hasWord(name, "deleted", "disabled", "failed", "error", "errors", "truncated", "partial", "archived", "hidden", "locked", "null", "unknown", "expired", "revoked", "blocked", "provisional", "only");
|
|
703
|
+
return rng.next() < (likely ? 0.85 : rare ? 0.15 : 0.5);
|
|
704
|
+
}
|
|
705
|
+
case ScalarType.BYTES: {
|
|
706
|
+
const len = bytesLength(owner, name, rules?.bytes);
|
|
707
|
+
const out = new Uint8Array(len);
|
|
708
|
+
for (let k = 0; k < len; k++)
|
|
709
|
+
out[k] = rng.int(0, 255);
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
default: {
|
|
713
|
+
const float = isFloat(scalar);
|
|
714
|
+
const unit = float ? undefined : epochUnit(name);
|
|
715
|
+
let v;
|
|
716
|
+
if (unit !== undefined && is64(scalar)) {
|
|
717
|
+
// Epoch values: exact, so they are generated in bigint space.
|
|
718
|
+
const ms = ctx.baseTime - rng.int(0, 30_000);
|
|
719
|
+
const big = unit >= 1 ? BigInt(ms) * BigInt(unit) : BigInt(Math.floor(ms / 1000));
|
|
720
|
+
return longAsString ? big.toString() : big;
|
|
721
|
+
}
|
|
722
|
+
else if (unit !== undefined) {
|
|
723
|
+
v = Math.floor((ctx.baseTime - rng.int(0, 30_000)) / 1000);
|
|
724
|
+
}
|
|
725
|
+
else {
|
|
726
|
+
const [lo, hi] = numberRange(name, float);
|
|
727
|
+
v = float
|
|
728
|
+
? Math.round((lo + rng.next() * (hi - lo)) * 100) / 100
|
|
729
|
+
: rng.int(lo, hi);
|
|
730
|
+
}
|
|
731
|
+
v = applyNumRules(v, rules?.num, rng, float);
|
|
732
|
+
if (float)
|
|
733
|
+
v = roundWithin(v, rules?.num);
|
|
734
|
+
const limits = INT_LIMITS[scalar];
|
|
735
|
+
if (limits)
|
|
736
|
+
v = Math.min(Math.max(Math.round(v), limits[0]), limits[1]);
|
|
737
|
+
return coerceScalar(scalar, v, longAsString);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
/**
|
|
742
|
+
* Length of a generated bytes value: 16 unless the rules say otherwise.
|
|
743
|
+
* Bounds no value can satisfy throw, naming the field, rather than emit a
|
|
744
|
+
* value the server's protovalidate would reject.
|
|
745
|
+
*/
|
|
746
|
+
function bytesLength(owner, name, b) {
|
|
747
|
+
const min = Math.max(b?.len ?? 0, b?.minLen ?? 0);
|
|
748
|
+
const max = Math.min(b?.len ?? Infinity, b?.maxLen ?? Infinity);
|
|
749
|
+
if (min > max)
|
|
750
|
+
throw new Error(`mock values: ${owner.typeName}.${name} has bytes rules no value satisfies ` +
|
|
751
|
+
`(len ${b?.len ?? "-"}, min_len ${b?.minLen ?? "-"}, max_len ${b?.maxLen ?? "-"})`);
|
|
752
|
+
return Math.min(Math.max(16, min), max);
|
|
753
|
+
}
|
|
754
|
+
function coerceScalar(scalar, v, longAsString) {
|
|
755
|
+
if (is64(scalar)) {
|
|
756
|
+
const big = typeof v === "bigint" ? v : BigInt(Math.round(Number(v)));
|
|
757
|
+
return longAsString ? big.toString() : big;
|
|
758
|
+
}
|
|
759
|
+
if (scalar === ScalarType.STRING)
|
|
760
|
+
return String(v);
|
|
761
|
+
if (typeof v === "bigint")
|
|
762
|
+
return Number(v);
|
|
763
|
+
return v;
|
|
764
|
+
}
|
|
765
|
+
function enumValue(e, name, rules, ctx) {
|
|
766
|
+
const echo = echoFor(ctx, name, undefined, e.typeName);
|
|
767
|
+
if (echo)
|
|
768
|
+
return echo.value;
|
|
769
|
+
const r = rules?.enm;
|
|
770
|
+
if (r?.const !== undefined)
|
|
771
|
+
return r.const;
|
|
772
|
+
let values = e.values.map((v) => v.number);
|
|
773
|
+
// Skip the proto3 zero ("UNSPECIFIED") when anything else is defined.
|
|
774
|
+
if (values.length > 1)
|
|
775
|
+
values = values.filter((v) => v !== 0);
|
|
776
|
+
if (r?.in?.length)
|
|
777
|
+
values = values.filter((v) => r.in.includes(v));
|
|
778
|
+
if (r?.notIn?.length)
|
|
779
|
+
values = values.filter((v) => !r.notIn.includes(v));
|
|
780
|
+
if (values.length === 0)
|
|
781
|
+
return r?.in?.[0] ?? e.values[0]?.number ?? 0;
|
|
782
|
+
// Cycle across a list: item i takes value (offset + i), so a list of n
|
|
783
|
+
// items shows every value once before any repeats.
|
|
784
|
+
const offset = new Rng(`${ctx.seed}|${ctx.path.replace(/\[\d+\]/g, "")}.${name}`).int(0, values.length - 1);
|
|
785
|
+
const i = ctx.index >= 0 ? ctx.index : 0;
|
|
786
|
+
return values[(offset + i) % values.length];
|
|
787
|
+
}
|
|
788
|
+
/** Recent instant for a Timestamp field, respecting start/end order and rules. */
|
|
789
|
+
function timestampMs(name, rules, ctx) {
|
|
790
|
+
const rng = rngAt(ctx, name);
|
|
791
|
+
let ms;
|
|
792
|
+
if (hasWord(name, "end", "ended", "finish", "finished", "completed", "updated", "last", "stop", "stopped", "until", "to", "modified", "resolved", "closed")) {
|
|
793
|
+
ms = ctx.baseTime;
|
|
794
|
+
}
|
|
795
|
+
else if (hasWord(name, "start", "started", "created", "first", "since", "from", "begin", "opened", "submitted")) {
|
|
796
|
+
ms = ctx.baseTime - rng.int(1_000, 120_000);
|
|
797
|
+
}
|
|
798
|
+
else if (hasWord(name, "expires", "expiry", "expiration", "deadline", "due", "next", "scheduled")) {
|
|
799
|
+
ms = ctx.now + rng.int(3_600_000, 7 * 86_400_000);
|
|
800
|
+
}
|
|
801
|
+
else {
|
|
802
|
+
ms = ctx.baseTime - rng.int(0, 30_000);
|
|
803
|
+
}
|
|
804
|
+
const t = rules?.timestamp;
|
|
805
|
+
if (t) {
|
|
806
|
+
if (t.const !== undefined)
|
|
807
|
+
return t.const;
|
|
808
|
+
if (t.gtNow)
|
|
809
|
+
ms = Math.max(ms, ctx.now + rng.int(60_000, 86_400_000));
|
|
810
|
+
if (t.ltNow)
|
|
811
|
+
ms = Math.min(ms, ctx.now - 1_000);
|
|
812
|
+
if (t.withinMs !== undefined)
|
|
813
|
+
ms = Math.min(Math.max(ms, ctx.now - t.withinMs + 1_000), ctx.now + t.withinMs - 1_000);
|
|
814
|
+
ms = applyNumRules(ms, t, rng, false);
|
|
815
|
+
}
|
|
816
|
+
return ms;
|
|
817
|
+
}
|
|
818
|
+
function timestampInit(ms) {
|
|
819
|
+
const seconds = Math.floor(ms / 1000);
|
|
820
|
+
return { seconds: BigInt(seconds), nanos: (ms - seconds * 1000) * 1_000_000 };
|
|
821
|
+
}
|
|
822
|
+
function durationInit(name, rules, ctx) {
|
|
823
|
+
const rng = rngAt(ctx, name);
|
|
824
|
+
let seconds = hasWord(name, "timeout", "ttl", "interval", "period", "window", "retention", "expiry", "max", "age")
|
|
825
|
+
? rng.int(30, 3600)
|
|
826
|
+
: Math.round((0.005 + rng.next() * 5) * 1000) / 1000;
|
|
827
|
+
seconds = applyNumRules(seconds, rules?.duration, rng, true);
|
|
828
|
+
const ms = Math.round(seconds * 1000) / 1000;
|
|
829
|
+
const d = rules?.duration;
|
|
830
|
+
if ((d?.gt === undefined || ms > d.gt) &&
|
|
831
|
+
(d?.gte === undefined || ms >= d.gte) &&
|
|
832
|
+
(d?.lt === undefined || ms < d.lt) &&
|
|
833
|
+
(d?.lte === undefined || ms <= d.lte)) {
|
|
834
|
+
seconds = ms;
|
|
835
|
+
}
|
|
836
|
+
const whole = Math.floor(seconds);
|
|
837
|
+
return { seconds: BigInt(whole), nanos: Math.round((seconds - whole) * 1e9) };
|
|
838
|
+
}
|
|
839
|
+
function jsonObject(name, ctx) {
|
|
840
|
+
const rng = rngAt(ctx, name);
|
|
841
|
+
const out = {};
|
|
842
|
+
const stem = kebab(name).replace(/s$/, "") || "key";
|
|
843
|
+
const count = rng.int(2, 4);
|
|
844
|
+
for (let k = 1; k <= count; k++)
|
|
845
|
+
out[`${stem}-${k}`] = `value-${k}`;
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
const WKT_TIMESTAMP = "google.protobuf.Timestamp";
|
|
849
|
+
const WKT_DURATION = "google.protobuf.Duration";
|
|
850
|
+
const WKT_STRUCT = "google.protobuf.Struct";
|
|
851
|
+
const WKT_VALUE = "google.protobuf.Value";
|
|
852
|
+
const WKT_LIST_VALUE = "google.protobuf.ListValue";
|
|
853
|
+
const WRAPPERS = new Set([
|
|
854
|
+
"google.protobuf.DoubleValue",
|
|
855
|
+
"google.protobuf.FloatValue",
|
|
856
|
+
"google.protobuf.Int64Value",
|
|
857
|
+
"google.protobuf.UInt64Value",
|
|
858
|
+
"google.protobuf.Int32Value",
|
|
859
|
+
"google.protobuf.UInt32Value",
|
|
860
|
+
"google.protobuf.BoolValue",
|
|
861
|
+
"google.protobuf.StringValue",
|
|
862
|
+
"google.protobuf.BytesValue",
|
|
863
|
+
]);
|
|
864
|
+
const LEAVE_UNSET = new Set([
|
|
865
|
+
"google.protobuf.Any",
|
|
866
|
+
"google.protobuf.FieldMask",
|
|
867
|
+
"google.protobuf.Empty",
|
|
868
|
+
]);
|
|
869
|
+
/**
|
|
870
|
+
* The init value for one message-typed position. `unboxed` is true where
|
|
871
|
+
* protobuf-es represents a wrapper as its primitive and a Struct as a
|
|
872
|
+
* JsonObject (a singular field outside a oneof; Struct in lists too).
|
|
873
|
+
*/
|
|
874
|
+
function messageAt(msg, name, owner, rules, ctx, unboxed) {
|
|
875
|
+
switch (msg.typeName) {
|
|
876
|
+
case WKT_TIMESTAMP:
|
|
877
|
+
return timestampInit(timestampMs(name, rules, ctx));
|
|
878
|
+
case WKT_DURATION:
|
|
879
|
+
return durationInit(name, rules, ctx);
|
|
880
|
+
case WKT_STRUCT:
|
|
881
|
+
return unboxed ? jsonObject(name, ctx) : undefined;
|
|
882
|
+
case WKT_VALUE:
|
|
883
|
+
return {
|
|
884
|
+
kind: {
|
|
885
|
+
case: "stringValue",
|
|
886
|
+
value: `${kebab(name)}-${ordinal(ctx, rngAt(ctx, name))}`,
|
|
887
|
+
},
|
|
888
|
+
};
|
|
889
|
+
case WKT_LIST_VALUE:
|
|
890
|
+
return {
|
|
891
|
+
values: [1, 2, 3].map((k) => ({
|
|
892
|
+
kind: { case: "stringValue", value: `${kebab(name)}-${k}` },
|
|
893
|
+
})),
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (WRAPPERS.has(msg.typeName)) {
|
|
897
|
+
const inner = msg.fields[0];
|
|
898
|
+
if (!inner || inner.fieldKind !== "scalar")
|
|
899
|
+
return undefined;
|
|
900
|
+
const v = scalarValue(inner.scalar, name, owner, rules, ctx, inner.longAsString);
|
|
901
|
+
return unboxed ? v : { value: v };
|
|
902
|
+
}
|
|
903
|
+
if (LEAVE_UNSET.has(msg.typeName))
|
|
904
|
+
return undefined;
|
|
905
|
+
// Depth and recursion guards: a self-referencing type nests once.
|
|
906
|
+
if (ctx.depth >= ctx.maxDepth)
|
|
907
|
+
return undefined;
|
|
908
|
+
if (ctx.stack.filter((t) => t === msg.typeName).length >= 2)
|
|
909
|
+
return undefined;
|
|
910
|
+
return buildMessage(msg, ctx);
|
|
911
|
+
}
|
|
912
|
+
function singular(field, owner, rules, ctx, index, unboxed) {
|
|
913
|
+
// A list item or map value is generated at its own path, so items differ.
|
|
914
|
+
const at = index >= 0 ? { ...ctx, index, path: `${ctx.path}[${index}]` } : ctx;
|
|
915
|
+
const f = field;
|
|
916
|
+
const kind = field.fieldKind === "list"
|
|
917
|
+
? field.listKind
|
|
918
|
+
: field.fieldKind === "map"
|
|
919
|
+
? field.mapKind
|
|
920
|
+
: field.fieldKind;
|
|
921
|
+
switch (kind) {
|
|
922
|
+
case "scalar":
|
|
923
|
+
return scalarValue(f.scalar, field.name, owner, rules, at, f.longAsString ?? false);
|
|
924
|
+
case "enum":
|
|
925
|
+
return enumValue(f.enum, field.name, rules, at);
|
|
926
|
+
default: {
|
|
927
|
+
const msg = f.message;
|
|
928
|
+
const c = {
|
|
929
|
+
...at,
|
|
930
|
+
path: `${at.path}.${field.name}`,
|
|
931
|
+
depth: ctx.depth + 1,
|
|
932
|
+
stack: [...ctx.stack, msg.typeName],
|
|
933
|
+
};
|
|
934
|
+
return messageAt(msg, field.name, owner, rules, c, unboxed);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
function listCount(rules, ctx, name, fallbackMax) {
|
|
939
|
+
const rng = rngAt(ctx, `${name}#count`);
|
|
940
|
+
const r = rules?.repeated;
|
|
941
|
+
const min = r?.minItems ?? 0;
|
|
942
|
+
const max = r?.maxItems ?? Infinity;
|
|
943
|
+
// Past half the depth budget lists shrink to their minimum, so a deep tree
|
|
944
|
+
// of repeated messages stays a few hundred values rather than thousands.
|
|
945
|
+
if (ctx.depth * 2 > ctx.maxDepth)
|
|
946
|
+
return min;
|
|
947
|
+
const n = rng.int(3, fallbackMax);
|
|
948
|
+
return Math.max(min, Math.min(max, n));
|
|
949
|
+
}
|
|
950
|
+
function buildMessage(desc, ctx) {
|
|
951
|
+
knownSchemas.set(desc.typeName, desc);
|
|
952
|
+
const rng = rngAt(ctx, "#time");
|
|
953
|
+
// Newest first across a list: item i sits ~i·4 minutes before the anchor.
|
|
954
|
+
const baseTime = ctx.index >= 0
|
|
955
|
+
? ctx.now - ctx.index * 240_000 - rng.int(0, 120_000)
|
|
956
|
+
: ctx.now - rng.int(0, 3_600_000);
|
|
957
|
+
const here = { ...ctx, baseTime };
|
|
958
|
+
const init = {};
|
|
959
|
+
for (const member of desc.members) {
|
|
960
|
+
if (member.kind === "oneof") {
|
|
961
|
+
const fields = member.fields;
|
|
962
|
+
if (fields.length === 0)
|
|
963
|
+
continue;
|
|
964
|
+
const pick = fields[rngAt(here, `${member.name}#case`).int(0, fields.length - 1)];
|
|
965
|
+
const value = singular(pick, desc, rulesOf(pick), here, -1, false);
|
|
966
|
+
if (value !== undefined)
|
|
967
|
+
init[member.localName] = { case: pick.localName, value };
|
|
968
|
+
continue;
|
|
969
|
+
}
|
|
970
|
+
const field = member;
|
|
971
|
+
const rules = rulesOf(field);
|
|
972
|
+
if (field.fieldKind === "list") {
|
|
973
|
+
const count = listCount(rules, here, field.name, 10);
|
|
974
|
+
const itemRules = rules?.repeated?.items;
|
|
975
|
+
const items = [];
|
|
976
|
+
for (let i = 0; i < count; i++) {
|
|
977
|
+
const v = singular(field, desc, itemRules, here, i, field.listKind === "message" && field.message.typeName === WKT_STRUCT);
|
|
978
|
+
if (v !== undefined)
|
|
979
|
+
items.push(v);
|
|
980
|
+
}
|
|
981
|
+
init[field.localName] = items;
|
|
982
|
+
}
|
|
983
|
+
else if (field.fieldKind === "map") {
|
|
984
|
+
const r = rules?.map;
|
|
985
|
+
const mrng = rngAt(here, `${field.name}#pairs`);
|
|
986
|
+
let count = mrng.int(3, 5);
|
|
987
|
+
if (here.depth * 2 > here.maxDepth)
|
|
988
|
+
count = 0;
|
|
989
|
+
count = Math.max(r?.minPairs ?? 0, Math.min(r?.maxPairs ?? Infinity, count));
|
|
990
|
+
const out = {};
|
|
991
|
+
const stem = kebab(field.name).replace(/s$/, "") || "key";
|
|
992
|
+
for (let i = 0; i < count; i++) {
|
|
993
|
+
let key;
|
|
994
|
+
if (field.mapKey === ScalarType.STRING) {
|
|
995
|
+
key = `${stem}-${i + 1}`;
|
|
996
|
+
if (r?.keys?.str)
|
|
997
|
+
key = applyStringRules(key, r.keys.str, mrng, i + 1);
|
|
998
|
+
}
|
|
999
|
+
else if (field.mapKey === ScalarType.BOOL) {
|
|
1000
|
+
if (i > 1)
|
|
1001
|
+
break;
|
|
1002
|
+
key = String(i === 0);
|
|
1003
|
+
}
|
|
1004
|
+
else {
|
|
1005
|
+
key = String(applyNumRules(i + 1, r?.keys?.num, mrng, false));
|
|
1006
|
+
}
|
|
1007
|
+
const at = { ...here, path: `${here.path}.${field.name}{${key}}` };
|
|
1008
|
+
const v = field.mapKind === "scalar" && field.scalar === ScalarType.STRING
|
|
1009
|
+
? applyStringRules(`value-${i + 1}`, r?.values?.str ?? {}, rngAt(at, "value"), i + 1)
|
|
1010
|
+
: singular(field, desc, r?.values, at, i, false);
|
|
1011
|
+
if (v !== undefined)
|
|
1012
|
+
out[key] = v;
|
|
1013
|
+
}
|
|
1014
|
+
init[field.localName] = out;
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
const v = singular(field, desc, rules, here, -1, field.fieldKind === "message");
|
|
1018
|
+
if (v !== undefined)
|
|
1019
|
+
init[field.localName] = v;
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
// A count beside the one list it counts reads as that list's length.
|
|
1023
|
+
const lists = desc.fields.filter((f) => f.fieldKind === "list" && f.listKind === "message");
|
|
1024
|
+
if (lists.length === 1) {
|
|
1025
|
+
const total = desc.fields.find((f) => f.fieldKind === "scalar" &&
|
|
1026
|
+
["total_count", "total_size", "total", "total_items"].includes(f.name));
|
|
1027
|
+
if (total && total.fieldKind === "scalar") {
|
|
1028
|
+
const len = init[lists[0].localName].length;
|
|
1029
|
+
init[total.localName] = coerceScalar(total.scalar, len, total.longAsString);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
let message = create(desc, init);
|
|
1033
|
+
const factory = ctx.factories.get(desc.typeName);
|
|
1034
|
+
if (factory) {
|
|
1035
|
+
const result = factory.build({
|
|
1036
|
+
base: message,
|
|
1037
|
+
index: ctx.index,
|
|
1038
|
+
seed: `${ctx.seed}|${ctx.path}`,
|
|
1039
|
+
});
|
|
1040
|
+
message = isMessage(result)
|
|
1041
|
+
? result
|
|
1042
|
+
: patch(message, result);
|
|
1043
|
+
}
|
|
1044
|
+
return message;
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* One deterministic, schema-valid message of `schema` — see the header for
|
|
1048
|
+
* what each field becomes. Same options, same message.
|
|
1049
|
+
*/
|
|
1050
|
+
export function mockValue(schema, options = {}) {
|
|
1051
|
+
return buildValue(schema, options);
|
|
1052
|
+
}
|
|
1053
|
+
/** mockValue, echoing `echo`'s scalar fields (the default handlers pass the request). */
|
|
1054
|
+
function buildValue(schema, options, echo) {
|
|
1055
|
+
const now = anchorMs(options.now);
|
|
1056
|
+
const ctx = {
|
|
1057
|
+
seed: options.seed ?? schema.typeName,
|
|
1058
|
+
path: "",
|
|
1059
|
+
index: -1,
|
|
1060
|
+
depth: 0,
|
|
1061
|
+
maxDepth: options.maxDepth ?? 6,
|
|
1062
|
+
now,
|
|
1063
|
+
baseTime: now,
|
|
1064
|
+
stack: [schema.typeName],
|
|
1065
|
+
echo: echo ? collectEcho(echo.schema, echo.message) : new Map(),
|
|
1066
|
+
factories: new Map((options.factories ?? []).map((f) => [f.schema.typeName, f])),
|
|
1067
|
+
};
|
|
1068
|
+
return buildMessage(schema, ctx);
|
|
1069
|
+
}
|
|
1070
|
+
function knownSchemaOf(message) {
|
|
1071
|
+
return knownSchemas.get(message.$typeName);
|
|
1072
|
+
}
|
|
1073
|
+
function isPlainObject(v) {
|
|
1074
|
+
return (typeof v === "object" &&
|
|
1075
|
+
v !== null &&
|
|
1076
|
+
!Array.isArray(v) &&
|
|
1077
|
+
!(v instanceof Uint8Array) &&
|
|
1078
|
+
!isMessage(v));
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Deep-merge `changes` onto `base` and return a NEW message (base is not
|
|
1082
|
+
* mutated). A nested message merges field by field; a list, a map, a oneof
|
|
1083
|
+
* and a scalar are replaced. Partial list items are completed with zero
|
|
1084
|
+
* values, as `create()` would.
|
|
1085
|
+
*
|
|
1086
|
+
* const res = defaults.listTraces(req);
|
|
1087
|
+
* return patch(res, { traces: res.traces.slice(0, 2), red: { errorRate: 0.5 } });
|
|
1088
|
+
*/
|
|
1089
|
+
export function patch(base, changes) {
|
|
1090
|
+
const desc = knownSchemaOf(base);
|
|
1091
|
+
const merged = mergeInit(desc, base, changes);
|
|
1092
|
+
return (desc ? rebuild(desc, merged) : merged);
|
|
1093
|
+
}
|
|
1094
|
+
/**
|
|
1095
|
+
* create() over a merged init. The init still carries the base's $typeName,
|
|
1096
|
+
* and create() returns anything that looks like a message untouched — which
|
|
1097
|
+
* would leave a partial list item uncompleted — so the marker is dropped.
|
|
1098
|
+
*/
|
|
1099
|
+
function rebuild(desc, init) {
|
|
1100
|
+
const { $typeName: _drop, ...fields } = init;
|
|
1101
|
+
return create(desc, fields);
|
|
1102
|
+
}
|
|
1103
|
+
function mergeInit(desc, base, changes) {
|
|
1104
|
+
const out = { ...base };
|
|
1105
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
1106
|
+
if (value === undefined)
|
|
1107
|
+
continue;
|
|
1108
|
+
const field = desc?.fields.find((f) => f.localName === key);
|
|
1109
|
+
const current = base[key];
|
|
1110
|
+
if (isPlainObject(value) &&
|
|
1111
|
+
isMessage(current) &&
|
|
1112
|
+
(!field || (field.fieldKind === "message" && !field.oneof))) {
|
|
1113
|
+
const nested = field?.fieldKind === "message" ? field.message : knownSchemaOf(current);
|
|
1114
|
+
const m = mergeInit(nested, current, value);
|
|
1115
|
+
out[key] = nested ? rebuild(nested, m) : m;
|
|
1116
|
+
}
|
|
1117
|
+
else {
|
|
1118
|
+
out[key] = value;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
return out;
|
|
1122
|
+
}
|
|
1123
|
+
/** Messages in a default stream. */
|
|
1124
|
+
const STREAM_LENGTH = 3;
|
|
1125
|
+
const defaultsCache = new WeakMap();
|
|
1126
|
+
function requestMessage(method, req) {
|
|
1127
|
+
return isMessage(req)
|
|
1128
|
+
? req
|
|
1129
|
+
: create(method.input, (req ?? {}));
|
|
1130
|
+
}
|
|
1131
|
+
/** The seed of one call: the method plus the request's wire bytes. */
|
|
1132
|
+
function callSeed(method, reqs) {
|
|
1133
|
+
let digest = "";
|
|
1134
|
+
for (const r of reqs) {
|
|
1135
|
+
try {
|
|
1136
|
+
digest += fnv1a(toBinary(method.input, r)).toString(16) + ".";
|
|
1137
|
+
}
|
|
1138
|
+
catch {
|
|
1139
|
+
digest += "?.";
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
return `${method.parent.typeName}/${method.name}:${digest}`;
|
|
1143
|
+
}
|
|
1144
|
+
/**
|
|
1145
|
+
* The default handlers of every RPC of `service`: a deterministic,
|
|
1146
|
+
* schema-valid response per request (a 3-message stream for server and bidi
|
|
1147
|
+
* streams). Generated `src/mocks/services/*_gen.ts` modules export one per
|
|
1148
|
+
* service, and the mock transport serves them for any RPC that no scenario,
|
|
1149
|
+
* passthrough or entity fixture answered.
|
|
1150
|
+
*
|
|
1151
|
+
* Without options the result is memoized per service, so every caller shares
|
|
1152
|
+
* one object.
|
|
1153
|
+
*/
|
|
1154
|
+
export function serviceDefaults(service, options = {}) {
|
|
1155
|
+
const plain = options.now === undefined &&
|
|
1156
|
+
options.maxDepth === undefined &&
|
|
1157
|
+
!options.factories?.length;
|
|
1158
|
+
if (plain) {
|
|
1159
|
+
const cached = defaultsCache.get(service);
|
|
1160
|
+
if (cached)
|
|
1161
|
+
return cached;
|
|
1162
|
+
}
|
|
1163
|
+
const out = {};
|
|
1164
|
+
for (const method of service.methods) {
|
|
1165
|
+
const streams = method.methodKind === "server_streaming" ||
|
|
1166
|
+
method.methodKind === "bidi_streaming";
|
|
1167
|
+
out[method.localName] = (req) => {
|
|
1168
|
+
const reqs = (Array.isArray(req) ? req : [req]).map((r) => requestMessage(method, r));
|
|
1169
|
+
const seed = callSeed(method, reqs);
|
|
1170
|
+
const last = reqs[reqs.length - 1];
|
|
1171
|
+
const echo = last ? { schema: method.input, message: last } : undefined;
|
|
1172
|
+
const one = (s) => buildValue(method.output, { ...options, seed: s }, echo);
|
|
1173
|
+
if (!streams)
|
|
1174
|
+
return one(seed);
|
|
1175
|
+
return Array.from({ length: STREAM_LENGTH }, (_, i) => one(`${seed}#${i}`));
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
Object.defineProperty(out, "$service", { value: service, enumerable: false });
|
|
1179
|
+
const defaults = out;
|
|
1180
|
+
if (plain)
|
|
1181
|
+
defaultsCache.set(service, defaults);
|
|
1182
|
+
return defaults;
|
|
1183
|
+
}
|
|
1184
|
+
/** True for what {@link serviceDefaults} returns — how the generated registry finds them. */
|
|
1185
|
+
export function isServiceDefaults(value) {
|
|
1186
|
+
if (typeof value !== "object" || value === null)
|
|
1187
|
+
return false;
|
|
1188
|
+
const service = value.$service;
|
|
1189
|
+
return (typeof service === "object" &&
|
|
1190
|
+
service !== null &&
|
|
1191
|
+
service.kind === "service");
|
|
1192
|
+
}
|
|
1193
|
+
/**
|
|
1194
|
+
* Typed overrides for one service, as scenario handlers:
|
|
1195
|
+
*
|
|
1196
|
+
* handlers: {
|
|
1197
|
+
* ...mockService(ObservabilityService, {
|
|
1198
|
+
* listTraces: (req, d) => patch(d.listTraces(req), { traces: [] }),
|
|
1199
|
+
* }),
|
|
1200
|
+
* }
|
|
1201
|
+
*
|
|
1202
|
+
* Each override receives the typed request and the service's defaults (built
|
|
1203
|
+
* with `options.factories` when given). Only overridden RPCs get a handler —
|
|
1204
|
+
* the rest fall through to the transport's own defaults — unless factories
|
|
1205
|
+
* are given, in which case every RPC is answered with them.
|
|
1206
|
+
*/
|
|
1207
|
+
export function mockService(service, overrides, options = {}) {
|
|
1208
|
+
const defaults = serviceDefaults(service, options);
|
|
1209
|
+
const out = {};
|
|
1210
|
+
const everyMethod = (options.factories?.length ?? 0) > 0;
|
|
1211
|
+
for (const method of service.methods) {
|
|
1212
|
+
const override = overrides[method.localName];
|
|
1213
|
+
const fallback = defaults[method.localName];
|
|
1214
|
+
if (!override && !everyMethod)
|
|
1215
|
+
continue;
|
|
1216
|
+
out[`${service.typeName}/${method.name}`] = override
|
|
1217
|
+
? (req) => override(requestMessage(method, req), defaults)
|
|
1218
|
+
: (req) => fallback?.(req);
|
|
1219
|
+
}
|
|
1220
|
+
return out;
|
|
1221
|
+
}
|
|
1222
|
+
//# sourceMappingURL=mock-values.js.map
|