@openwop/openwop-conformance 1.0.0 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +31 -6
  3. package/api/grpc/openwop.proto +251 -0
  4. package/api/openapi.yaml +109 -3
  5. package/coverage.md +48 -9
  6. package/fixtures/conformance-configurable-schema.json +39 -0
  7. package/fixtures/conformance-subworkflow-parent.json +1 -1
  8. package/fixtures/conformance-wasm-pack-memory-cap-breach.json +23 -0
  9. package/fixtures/openwop-smoke-byok-roundtrip.json +25 -0
  10. package/fixtures.md +21 -0
  11. package/package.json +3 -1
  12. package/schemas/README.md +4 -0
  13. package/schemas/audit-verify-result.schema.json +90 -0
  14. package/schemas/capabilities.schema.json +293 -1
  15. package/schemas/node-pack-manifest.schema.json +4 -4
  16. package/schemas/pack-lockfile.schema.json +92 -0
  17. package/schemas/registry-version-manifest.schema.json +145 -0
  18. package/schemas/run-event-payloads.schema.json +2 -2
  19. package/schemas/security-advisory.schema.json +109 -0
  20. package/src/lib/a2a-fake-peer.ts +143 -56
  21. package/src/lib/behavior-gate.ts +68 -0
  22. package/src/lib/env.ts +10 -0
  23. package/src/lib/grpc-framing.test.ts +96 -0
  24. package/src/lib/grpc-framing.ts +76 -0
  25. package/src/lib/oidc-issuer.test.ts +328 -0
  26. package/src/lib/oidc-issuer.ts +241 -0
  27. package/src/lib/otel-collector-grpc.test.ts +191 -0
  28. package/src/lib/otel-collector.test.ts +303 -0
  29. package/src/lib/otel-collector.ts +318 -14
  30. package/src/lib/otlp-protobuf.test.ts +461 -0
  31. package/src/lib/otlp-protobuf.ts +529 -0
  32. package/src/scenarios/a2a-task-roundtrip.test.ts +147 -28
  33. package/src/scenarios/agentConfidenceEscalation.test.ts +1 -0
  34. package/src/scenarios/agentMemoryCrossTenantIsolation.test.ts +1 -0
  35. package/src/scenarios/agentMemoryRedactionContract.test.ts +1 -0
  36. package/src/scenarios/agentMemoryRoundTrip.test.ts +1 -0
  37. package/src/scenarios/agentMemoryTtlExpiry.test.ts +1 -0
  38. package/src/scenarios/agentMessageReducer.test.ts +1 -0
  39. package/src/scenarios/agentMetadata.test.ts +1 -0
  40. package/src/scenarios/agentPackExport.test.ts +1 -0
  41. package/src/scenarios/agentPackInstall.test.ts +1 -0
  42. package/src/scenarios/agentPackProvenance.test.ts +1 -0
  43. package/src/scenarios/audit-log-integrity.test.ts +3 -6
  44. package/src/scenarios/auth-api-key-rotation.test.ts +182 -0
  45. package/src/scenarios/auth-mtls.test.ts +274 -0
  46. package/src/scenarios/auth-oauth2-client-credentials.test.ts +259 -0
  47. package/src/scenarios/auth-oidc-user-bearer.test.ts +361 -0
  48. package/src/scenarios/bulk-cancel.test.ts +111 -0
  49. package/src/scenarios/configurable-schema.test.ts +48 -0
  50. package/src/scenarios/conversationCapabilityNegotiation.test.ts +1 -0
  51. package/src/scenarios/conversationLifecycle.test.ts +1 -0
  52. package/src/scenarios/conversationReplayDeterminism.test.ts +1 -0
  53. package/src/scenarios/conversationVsLegacySuspend.test.ts +1 -0
  54. package/src/scenarios/debug-bundle-truncation.test.ts +95 -0
  55. package/src/scenarios/discovery.test.ts +183 -0
  56. package/src/scenarios/http-client-ssrf.test.ts +71 -0
  57. package/src/scenarios/idempotency.test.ts +6 -0
  58. package/src/scenarios/idempotencyRetry.test.ts +3 -0
  59. package/src/scenarios/mcp-tool-roundtrip.test.ts +198 -34
  60. package/src/scenarios/mcp-toolcall-redaction.test.ts +66 -0
  61. package/src/scenarios/metric-emission.test.ts +113 -0
  62. package/src/scenarios/orchestratorConservativePath.test.ts +1 -0
  63. package/src/scenarios/orchestratorDispatch.test.ts +1 -0
  64. package/src/scenarios/orchestratorTermination.test.ts +1 -0
  65. package/src/scenarios/otel-emission-grpc.test.ts +98 -0
  66. package/src/scenarios/pause-resume.test.ts +119 -0
  67. package/src/scenarios/production-backpressure.test.ts +342 -0
  68. package/src/scenarios/production-retention-expiry.test.ts +164 -0
  69. package/src/scenarios/registry-public.test.ts +131 -0
  70. package/src/scenarios/replay-llm-cache-key.test.ts +35 -0
  71. package/src/scenarios/replay-retention-expiry.test.ts +178 -0
  72. package/src/scenarios/restart-during-run.test.ts +177 -0
  73. package/src/scenarios/spec-corpus-validity.test.ts +54 -26
  74. package/src/scenarios/staleClaim.test.ts +3 -0
  75. package/src/scenarios/wasm-pack-abi-version-rejection.test.ts +67 -10
  76. package/src/scenarios/wasm-pack-memory-cap.test.ts +64 -9
  77. package/src/scenarios/webhook-negative.test.ts +90 -0
  78. package/src/scenarios/webhook-signed-delivery.test.ts +178 -0
  79. package/src/setup.ts +25 -1
  80. package/vitest.config.ts +5 -1
@@ -0,0 +1,461 @@
1
+ /**
2
+ * Server-free unit tests for the OTLP protobuf decoder.
3
+ *
4
+ * The decoder is real protobuf wire-format parsing. Track 11 follow-up
5
+ * landed it to unlock the broader class of OTLP-only hosts that emit
6
+ * application/x-protobuf rather than application/json. A regression in
7
+ * varint decoding, fixed64 endianness, or AnyValue oneof handling would
8
+ * cause silent telemetry loss — the host emits, the collector accepts,
9
+ * but the spans/metrics never reach the assertion path.
10
+ *
11
+ * Strategy: an in-file `PbWriter` encoder synthesizes minimal OTLP
12
+ * payloads; we round-trip them through `decodeExportTraceServiceRequest`
13
+ * / `decodeExportMetricsServiceRequest` and assert the output shape.
14
+ * The writer is test-only — production code only ever decodes.
15
+ *
16
+ * @see conformance/src/lib/otlp-protobuf.ts
17
+ */
18
+
19
+ import { describe, it, expect } from 'vitest';
20
+ import {
21
+ PbReader,
22
+ decodeExportTraceServiceRequest,
23
+ decodeExportMetricsServiceRequest,
24
+ } from './otlp-protobuf.js';
25
+
26
+ // ─── Test-only protobuf encoder ────────────────────────────────────────────
27
+
28
+ const WIRE_VARINT = 0;
29
+ const WIRE_I64 = 1;
30
+ const WIRE_LEN = 2;
31
+
32
+ class PbWriter {
33
+ private chunks: number[] = [];
34
+
35
+ bytes(): Uint8Array {
36
+ return new Uint8Array(this.chunks);
37
+ }
38
+
39
+ writeVarint(v: bigint | number): void {
40
+ let x = typeof v === 'bigint' ? v : BigInt(v);
41
+ while (x >= 0x80n) {
42
+ this.chunks.push(Number(x & 0x7fn) | 0x80);
43
+ x >>= 7n;
44
+ }
45
+ this.chunks.push(Number(x & 0x7fn));
46
+ }
47
+
48
+ writeTag(fieldNumber: number, wireType: number): void {
49
+ this.writeVarint((fieldNumber << 3) | wireType);
50
+ }
51
+
52
+ writeString(fieldNumber: number, value: string): void {
53
+ this.writeTag(fieldNumber, WIRE_LEN);
54
+ const enc = new TextEncoder().encode(value);
55
+ this.writeVarint(enc.length);
56
+ for (const b of enc) this.chunks.push(b);
57
+ }
58
+
59
+ writeBytes(fieldNumber: number, value: Uint8Array): void {
60
+ this.writeTag(fieldNumber, WIRE_LEN);
61
+ this.writeVarint(value.length);
62
+ for (const b of value) this.chunks.push(b);
63
+ }
64
+
65
+ writeBytesHex(fieldNumber: number, hex: string): void {
66
+ const bytes = new Uint8Array(hex.length / 2);
67
+ for (let i = 0; i < bytes.length; i++) {
68
+ bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
69
+ }
70
+ this.writeBytes(fieldNumber, bytes);
71
+ }
72
+
73
+ writeVarintField(fieldNumber: number, v: bigint | number): void {
74
+ this.writeTag(fieldNumber, WIRE_VARINT);
75
+ this.writeVarint(v);
76
+ }
77
+
78
+ writeFixed64Uint(fieldNumber: number, v: bigint): void {
79
+ this.writeTag(fieldNumber, WIRE_I64);
80
+ const buf = new ArrayBuffer(8);
81
+ const view = new DataView(buf);
82
+ view.setBigUint64(0, v, true);
83
+ for (let i = 0; i < 8; i++) this.chunks.push(view.getUint8(i));
84
+ }
85
+
86
+ writeFixed64Int(fieldNumber: number, v: bigint): void {
87
+ this.writeTag(fieldNumber, WIRE_I64);
88
+ const buf = new ArrayBuffer(8);
89
+ const view = new DataView(buf);
90
+ view.setBigInt64(0, v, true);
91
+ for (let i = 0; i < 8; i++) this.chunks.push(view.getUint8(i));
92
+ }
93
+
94
+ writeDouble(fieldNumber: number, v: number): void {
95
+ this.writeTag(fieldNumber, WIRE_I64);
96
+ const buf = new ArrayBuffer(8);
97
+ const view = new DataView(buf);
98
+ view.setFloat64(0, v, true);
99
+ for (let i = 0; i < 8; i++) this.chunks.push(view.getUint8(i));
100
+ }
101
+
102
+ writeMessage(fieldNumber: number, body: Uint8Array): void {
103
+ this.writeTag(fieldNumber, WIRE_LEN);
104
+ this.writeVarint(body.length);
105
+ for (const b of body) this.chunks.push(b);
106
+ }
107
+ }
108
+
109
+ // Helpers for building OTLP message shapes.
110
+
111
+ function buildAnyValue(variant: { type: 'string' | 'int' | 'double' | 'bool'; value: unknown }): Uint8Array {
112
+ const w = new PbWriter();
113
+ switch (variant.type) {
114
+ case 'string':
115
+ w.writeString(1, variant.value as string);
116
+ break;
117
+ case 'int':
118
+ w.writeVarintField(3, variant.value as number);
119
+ break;
120
+ case 'double':
121
+ w.writeDouble(4, variant.value as number);
122
+ break;
123
+ case 'bool':
124
+ w.writeVarintField(2, (variant.value as boolean) ? 1 : 0);
125
+ break;
126
+ }
127
+ return w.bytes();
128
+ }
129
+
130
+ function buildKeyValue(key: string, value: Uint8Array): Uint8Array {
131
+ const w = new PbWriter();
132
+ w.writeString(1, key);
133
+ w.writeMessage(2, value);
134
+ return w.bytes();
135
+ }
136
+
137
+ function buildSpan(s: {
138
+ traceId: string;
139
+ spanId: string;
140
+ parentSpanId?: string;
141
+ name: string;
142
+ startNanos: bigint;
143
+ endNanos: bigint;
144
+ attrs: Array<{ key: string; type: 'string' | 'int' | 'double' | 'bool'; value: unknown }>;
145
+ }): Uint8Array {
146
+ const w = new PbWriter();
147
+ w.writeBytesHex(1, s.traceId);
148
+ w.writeBytesHex(2, s.spanId);
149
+ if (s.parentSpanId) w.writeBytesHex(4, s.parentSpanId);
150
+ w.writeString(5, s.name);
151
+ w.writeFixed64Uint(7, s.startNanos);
152
+ w.writeFixed64Uint(8, s.endNanos);
153
+ for (const a of s.attrs) {
154
+ w.writeMessage(9, buildKeyValue(a.key, buildAnyValue({ type: a.type, value: a.value })));
155
+ }
156
+ return w.bytes();
157
+ }
158
+
159
+ function buildExportTrace(span: Uint8Array, resourceAttrs: Array<{ key: string; value: string }> = []): Uint8Array {
160
+ const scopeSpans = new PbWriter();
161
+ scopeSpans.writeMessage(2, span);
162
+
163
+ const resource = new PbWriter();
164
+ for (const a of resourceAttrs) {
165
+ resource.writeMessage(1, buildKeyValue(a.key, buildAnyValue({ type: 'string', value: a.value })));
166
+ }
167
+
168
+ const resourceSpans = new PbWriter();
169
+ resourceSpans.writeMessage(1, resource.bytes());
170
+ resourceSpans.writeMessage(2, scopeSpans.bytes());
171
+
172
+ const req = new PbWriter();
173
+ req.writeMessage(1, resourceSpans.bytes());
174
+ return req.bytes();
175
+ }
176
+
177
+ function buildNumberDataPoint(opts: {
178
+ asDouble?: number;
179
+ asInt?: bigint;
180
+ attrs?: Array<{ key: string; value: string }>;
181
+ }): Uint8Array {
182
+ const w = new PbWriter();
183
+ if (opts.asDouble !== undefined) w.writeDouble(4, opts.asDouble);
184
+ if (opts.asInt !== undefined) w.writeFixed64Int(6, opts.asInt);
185
+ for (const a of opts.attrs ?? []) {
186
+ w.writeMessage(7, buildKeyValue(a.key, buildAnyValue({ type: 'string', value: a.value })));
187
+ }
188
+ return w.bytes();
189
+ }
190
+
191
+ function buildMetric(opts: {
192
+ name: string;
193
+ description?: string;
194
+ unit?: string;
195
+ variant: 'gauge' | 'sum';
196
+ dataPoint: Uint8Array;
197
+ }): Uint8Array {
198
+ const dpc = new PbWriter();
199
+ dpc.writeMessage(1, opts.dataPoint);
200
+
201
+ const m = new PbWriter();
202
+ m.writeString(1, opts.name);
203
+ if (opts.description) m.writeString(2, opts.description);
204
+ if (opts.unit) m.writeString(3, opts.unit);
205
+ if (opts.variant === 'gauge') {
206
+ m.writeMessage(5, dpc.bytes());
207
+ } else {
208
+ m.writeMessage(7, dpc.bytes());
209
+ }
210
+ return m.bytes();
211
+ }
212
+
213
+ function buildExportMetrics(metric: Uint8Array): Uint8Array {
214
+ const scopeMetrics = new PbWriter();
215
+ scopeMetrics.writeMessage(2, metric);
216
+
217
+ const resourceMetrics = new PbWriter();
218
+ resourceMetrics.writeMessage(2, scopeMetrics.bytes());
219
+
220
+ const req = new PbWriter();
221
+ req.writeMessage(1, resourceMetrics.bytes());
222
+ return req.bytes();
223
+ }
224
+
225
+ // ─── Tests ─────────────────────────────────────────────────────────────────
226
+
227
+ describe('PbReader: low-level wire format', () => {
228
+ it('decodes single-byte varint', () => {
229
+ const r = new PbReader(new Uint8Array([0x7f]));
230
+ expect(r.readVarintNumber()).toBe(127);
231
+ });
232
+
233
+ it('decodes multi-byte varint (continuation bit)', () => {
234
+ // 300 = 0xAC, 0x02
235
+ const r = new PbReader(new Uint8Array([0xac, 0x02]));
236
+ expect(r.readVarintNumber()).toBe(300);
237
+ });
238
+
239
+ it('throws on varint exceeding 10 bytes', () => {
240
+ const r = new PbReader(new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]));
241
+ expect(() => r.readVarint()).toThrow(/exceeds 10 bytes/);
242
+ });
243
+
244
+ it('readTag splits field number and wire type', () => {
245
+ const w = new PbWriter();
246
+ w.writeTag(42, WIRE_LEN);
247
+ const r = new PbReader(w.bytes());
248
+ const t = r.readTag();
249
+ expect(t.fieldNumber).toBe(42);
250
+ expect(t.wireType).toBe(WIRE_LEN);
251
+ });
252
+
253
+ it('readDouble round-trips little-endian IEEE 754', () => {
254
+ const w = new PbWriter();
255
+ w.writeDouble(1, 3.14159);
256
+ const r = new PbReader(w.bytes());
257
+ r.readTag();
258
+ expect(r.readDouble()).toBeCloseTo(3.14159, 10);
259
+ });
260
+
261
+ it('readFixed64Uint round-trips a large unsigned 64-bit', () => {
262
+ const w = new PbWriter();
263
+ const v = 1758000000000000000n; // realistic OTel timestamp (Sept 2025-ish)
264
+ w.writeFixed64Uint(1, v);
265
+ const r = new PbReader(w.bytes());
266
+ r.readTag();
267
+ expect(r.readFixed64Uint()).toBe(v);
268
+ });
269
+
270
+ it('readFixed64Int handles negative two\'s-complement', () => {
271
+ const w = new PbWriter();
272
+ w.writeFixed64Int(1, -42n);
273
+ const r = new PbReader(w.bytes());
274
+ r.readTag();
275
+ expect(r.readFixed64Int()).toBe(-42n);
276
+ });
277
+
278
+ it('readBytesAsHex produces canonical lowercase hex', () => {
279
+ const w = new PbWriter();
280
+ w.writeBytes(1, new Uint8Array([0x00, 0xfa, 0xce, 0xb0, 0x0c]));
281
+ const r = new PbReader(w.bytes());
282
+ r.readTag();
283
+ expect(r.readBytesAsHex()).toBe('00faceb00c');
284
+ });
285
+ });
286
+
287
+ describe('decodeExportTraceServiceRequest: span round-trip', () => {
288
+ it('decodes a single span with required fields', () => {
289
+ const span = buildSpan({
290
+ traceId: '0123456789abcdef0123456789abcdef',
291
+ spanId: 'fedcba9876543210',
292
+ name: 'openwop.run',
293
+ startNanos: 1700000000000000000n,
294
+ endNanos: 1700000000100000000n,
295
+ attrs: [
296
+ { key: 'openwop.run_id', type: 'string', value: 'run-abc' },
297
+ { key: 'openwop.workflow_id', type: 'string', value: 'conformance-noop' },
298
+ ],
299
+ });
300
+ const req = buildExportTrace(span);
301
+
302
+ const decoded = decodeExportTraceServiceRequest(req);
303
+ expect(decoded.resourceSpans.length).toBe(1);
304
+ const spans = decoded.resourceSpans[0].scopeSpans?.[0]?.spans ?? [];
305
+ expect(spans.length).toBe(1);
306
+ const s = spans[0];
307
+ expect(s.traceId).toBe('0123456789abcdef0123456789abcdef');
308
+ expect(s.spanId).toBe('fedcba9876543210');
309
+ expect(s.parentSpanId).toBeUndefined();
310
+ expect(s.name).toBe('openwop.run');
311
+ expect(s.startTimeUnixNano).toBe('1700000000000000000');
312
+ expect(s.endTimeUnixNano).toBe('1700000000100000000');
313
+ expect(s.attributes?.map((a) => a.key)).toEqual([
314
+ 'openwop.run_id',
315
+ 'openwop.workflow_id',
316
+ ]);
317
+ expect(s.attributes?.[0].value).toEqual({ stringValue: 'run-abc' });
318
+ });
319
+
320
+ it('handles parentSpanId when present', () => {
321
+ const span = buildSpan({
322
+ traceId: 'a'.repeat(32),
323
+ spanId: 'b'.repeat(16),
324
+ parentSpanId: 'c'.repeat(16),
325
+ name: 'child',
326
+ startNanos: 0n,
327
+ endNanos: 1n,
328
+ attrs: [],
329
+ });
330
+ const req = buildExportTrace(span);
331
+ const decoded = decodeExportTraceServiceRequest(req);
332
+ const s = decoded.resourceSpans[0].scopeSpans?.[0]?.spans?.[0];
333
+ expect(s?.parentSpanId).toBe('c'.repeat(16));
334
+ });
335
+
336
+ it('threads resource attributes through ResourceSpans', () => {
337
+ const span = buildSpan({
338
+ traceId: '00'.repeat(16),
339
+ spanId: '11'.repeat(8),
340
+ name: 's',
341
+ startNanos: 0n,
342
+ endNanos: 1n,
343
+ attrs: [],
344
+ });
345
+ const req = buildExportTrace(span, [{ key: 'service.name', value: 'openwop-host-sqlite' }]);
346
+ const decoded = decodeExportTraceServiceRequest(req);
347
+ const resource = decoded.resourceSpans[0].resource;
348
+ expect(resource?.attributes?.[0]).toEqual({
349
+ key: 'service.name',
350
+ value: { stringValue: 'openwop-host-sqlite' },
351
+ });
352
+ });
353
+ });
354
+
355
+ describe('decodeExportTraceServiceRequest: AnyValue oneof', () => {
356
+ it('decodes intValue as a string (matches JSON shape)', () => {
357
+ const span = buildSpan({
358
+ traceId: '00'.repeat(16),
359
+ spanId: '11'.repeat(8),
360
+ name: 's',
361
+ startNanos: 0n,
362
+ endNanos: 1n,
363
+ attrs: [{ key: 'openwop.node_count', type: 'int', value: 7 }],
364
+ });
365
+ const decoded = decodeExportTraceServiceRequest(buildExportTrace(span));
366
+ const attr = decoded.resourceSpans[0].scopeSpans?.[0]?.spans?.[0]?.attributes?.[0];
367
+ expect(attr?.value).toEqual({ intValue: '7' });
368
+ });
369
+
370
+ it('decodes doubleValue as a number', () => {
371
+ const span = buildSpan({
372
+ traceId: '00'.repeat(16),
373
+ spanId: '11'.repeat(8),
374
+ name: 's',
375
+ startNanos: 0n,
376
+ endNanos: 1n,
377
+ attrs: [{ key: 'openwop.cost.usd', type: 'double', value: 0.0123 }],
378
+ });
379
+ const decoded = decodeExportTraceServiceRequest(buildExportTrace(span));
380
+ const attr = decoded.resourceSpans[0].scopeSpans?.[0]?.spans?.[0]?.attributes?.[0];
381
+ expect(attr?.value).toEqual({ doubleValue: 0.0123 });
382
+ });
383
+
384
+ it('decodes boolValue', () => {
385
+ const span = buildSpan({
386
+ traceId: '00'.repeat(16),
387
+ spanId: '11'.repeat(8),
388
+ name: 's',
389
+ startNanos: 0n,
390
+ endNanos: 1n,
391
+ attrs: [{ key: 'openwop.is_replay', type: 'bool', value: true }],
392
+ });
393
+ const decoded = decodeExportTraceServiceRequest(buildExportTrace(span));
394
+ const attr = decoded.resourceSpans[0].scopeSpans?.[0]?.spans?.[0]?.attributes?.[0];
395
+ expect(attr?.value).toEqual({ boolValue: true });
396
+ });
397
+ });
398
+
399
+ describe('decodeExportMetricsServiceRequest: gauge + sum', () => {
400
+ it('decodes a gauge with double data point + attributes', () => {
401
+ const dp = buildNumberDataPoint({
402
+ asDouble: 5.5,
403
+ attrs: [{ key: 'openwop.run_id', value: 'run-xyz' }],
404
+ });
405
+ const m = buildMetric({
406
+ name: 'openwop.run.duration',
407
+ unit: 'ms',
408
+ variant: 'gauge',
409
+ dataPoint: dp,
410
+ });
411
+ const req = buildExportMetrics(m);
412
+
413
+ const decoded = decodeExportMetricsServiceRequest(req);
414
+ const metric = decoded.resourceMetrics[0].scopeMetrics?.[0]?.metrics?.[0];
415
+ expect(metric?.name).toBe('openwop.run.duration');
416
+ expect(metric?.unit).toBe('ms');
417
+ expect(metric?.gauge?.dataPoints?.[0].asDouble).toBe(5.5);
418
+ expect(metric?.gauge?.dataPoints?.[0].attributes?.[0]).toEqual({
419
+ key: 'openwop.run_id',
420
+ value: { stringValue: 'run-xyz' },
421
+ });
422
+ });
423
+
424
+ it('decodes a sum with sfixed64 as_int (negative value as string)', () => {
425
+ const dp = buildNumberDataPoint({ asInt: -1234n });
426
+ const m = buildMetric({
427
+ name: 'openwop.queue.depth',
428
+ variant: 'sum',
429
+ dataPoint: dp,
430
+ });
431
+ const decoded = decodeExportMetricsServiceRequest(buildExportMetrics(m));
432
+ const metric = decoded.resourceMetrics[0].scopeMetrics?.[0]?.metrics?.[0];
433
+ expect(metric?.sum?.dataPoints?.[0].asInt).toBe('-1234');
434
+ });
435
+ });
436
+
437
+ describe('decodeExportTraceServiceRequest: forward-compat (unknown fields)', () => {
438
+ it('skips unknown field numbers without erroring', () => {
439
+ // Build a Span body that includes an unknown field 99 (varint).
440
+ const w = new PbWriter();
441
+ w.writeBytesHex(1, '00'.repeat(16)); // traceId
442
+ w.writeBytesHex(2, '11'.repeat(8)); // spanId
443
+ w.writeString(5, 'forward-compat-span'); // name
444
+ w.writeFixed64Uint(7, 0n); // startTimeUnixNano
445
+ w.writeFixed64Uint(8, 1n); // endTimeUnixNano
446
+ w.writeVarintField(99, 1234); // unknown — decoder MUST skip
447
+ w.writeString(100, 'future-field'); // unknown LEN — decoder MUST skip
448
+
449
+ const req = buildExportTrace(w.bytes());
450
+ const decoded = decodeExportTraceServiceRequest(req);
451
+ const s = decoded.resourceSpans[0].scopeSpans?.[0]?.spans?.[0];
452
+ expect(s?.name).toBe('forward-compat-span');
453
+ });
454
+ });
455
+
456
+ describe('decodeExportTraceServiceRequest: empty payload', () => {
457
+ it('returns empty resourceSpans for an empty buffer', () => {
458
+ const decoded = decodeExportTraceServiceRequest(new Uint8Array(0));
459
+ expect(decoded.resourceSpans).toEqual([]);
460
+ });
461
+ });