@loadstrike/loadstrike-sdk 1.0.30201 → 1.0.31001

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/esm/local.js CHANGED
@@ -9,7 +9,7 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
9
9
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
10
10
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
11
11
  };
12
- var _LoadStrikeLocalClient_licensingApiBaseUrl, _LoadStrikeLocalClient_signingKeyCache;
12
+ var _LoadStrikeLocalClient_licensingApiBaseUrl, _LoadStrikeLocalClient_signingKeyCache, _LoadStrikeLocalClient_heartbeatDrains;
13
13
  import os from "node:os";
14
14
  import * as fs from "node:fs";
15
15
  import * as childProcess from "node:child_process";
@@ -70,6 +70,7 @@ export class LoadStrikeLocalClient {
70
70
  constructor(options = {}) {
71
71
  _LoadStrikeLocalClient_licensingApiBaseUrl.set(this, void 0);
72
72
  _LoadStrikeLocalClient_signingKeyCache.set(this, new Map());
73
+ _LoadStrikeLocalClient_heartbeatDrains.set(this, new WeakMap());
73
74
  assertNoDisableLicenseEnforcementOption(options, "LoadStrikeLocalClient");
74
75
  __classPrivateFieldSet(this, _LoadStrikeLocalClient_licensingApiBaseUrl, resolveLicensingApiBaseUrl(), "f");
75
76
  this.licenseValidationTimeoutMs = normalizeTimeoutMs(options.licenseValidationTimeoutMs);
@@ -189,28 +190,59 @@ export class LoadStrikeLocalClient {
189
190
  }
190
191
  await this.verifySignedRunToken(runToken, request, requestedFeatures, runnerKey, sessionId, computedDeviceHash);
191
192
  const heartbeatIntervalSeconds = Math.max(asInt(pickValue(json, "HeartbeatIntervalSeconds", "heartbeatIntervalSeconds")), 1);
192
- const heartbeatTimer = setInterval(() => {
193
- void this.sendRunTokenHeartbeat({
194
- runToken,
195
- sessionId,
196
- deviceHash: computedDeviceHash,
197
- machineName,
198
- environmentClassification
199
- }).catch(() => {
200
- // Best-effort heartbeat: server-side lease expiration is authoritative.
201
- });
202
- }, heartbeatIntervalSeconds * 1000);
203
- if (typeof heartbeatTimer.unref === "function") {
204
- heartbeatTimer.unref();
205
- }
206
- return {
193
+ const session = {
207
194
  runToken,
208
195
  sessionId,
209
196
  deviceHash: computedDeviceHash,
210
197
  machineName,
211
- environmentClassification,
212
- heartbeatTimer
198
+ environmentClassification
213
199
  };
200
+ let heartbeatInFlight = false;
201
+ const runHeartbeat = async () => {
202
+ heartbeatInFlight = true;
203
+ try {
204
+ const currentRunToken = stringOrDefault(session.runToken, "").trim();
205
+ if (!currentRunToken) {
206
+ return;
207
+ }
208
+ const refreshedRunToken = await this.sendRunTokenHeartbeat({
209
+ runToken: currentRunToken,
210
+ sessionId,
211
+ deviceHash: computedDeviceHash,
212
+ machineName,
213
+ environmentClassification
214
+ });
215
+ if (!refreshedRunToken) {
216
+ return;
217
+ }
218
+ try {
219
+ await this.verifySignedRunToken(refreshedRunToken, request, requestedFeatures, runnerKey, sessionId, computedDeviceHash);
220
+ }
221
+ catch {
222
+ return;
223
+ }
224
+ session.runToken = refreshedRunToken;
225
+ }
226
+ catch {
227
+ // Best-effort heartbeat: server-side lease expiration is authoritative.
228
+ }
229
+ finally {
230
+ heartbeatInFlight = false;
231
+ }
232
+ };
233
+ let currentHeartbeat = Promise.resolve();
234
+ const heartbeatTimer = setInterval(() => {
235
+ if (!heartbeatInFlight) {
236
+ currentHeartbeat = runHeartbeat();
237
+ }
238
+ return currentHeartbeat;
239
+ }, heartbeatIntervalSeconds * 1000);
240
+ if (typeof heartbeatTimer.unref === "function") {
241
+ heartbeatTimer.unref();
242
+ }
243
+ session.heartbeatTimer = heartbeatTimer;
244
+ __classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").set(session, () => currentHeartbeat);
245
+ return session;
214
246
  }
215
247
  finally {
216
248
  clearTimeout(timer);
@@ -394,18 +426,35 @@ export class LoadStrikeLocalClient {
394
426
  const timer = controller
395
427
  ? setTimeout(() => controller.abort(), this.licenseValidationTimeoutMs)
396
428
  : null;
397
- const { response } = await this.postLicensingRequest("/api/v1/licenses/heartbeat", heartbeatPayload, signal ?? controller.signal);
429
+ const { response, json } = await this.postLicensingRequest("/api/v1/licenses/heartbeat", heartbeatPayload, signal ?? controller.signal);
398
430
  if (timer) {
399
431
  clearTimeout(timer);
400
432
  }
401
433
  if (!response.ok) {
402
434
  throw new Error(`Runner key validation denied. DenialCode=run_token_heartbeat_failed, Message=Run token heartbeat failed with status ${response.status}.`);
403
435
  }
436
+ if (pickValue(json ?? {}, "IsValid", "isValid") !== true) {
437
+ return undefined;
438
+ }
439
+ const refreshedRunToken = stringOrDefault(pickValue(json ?? {}, "RunToken", "runToken"), "").trim();
440
+ return refreshedRunToken || undefined;
404
441
  }
405
442
  async stopLicenseLeaseIfRequired(session, _request) {
406
443
  if (session.heartbeatTimer) {
407
444
  clearInterval(session.heartbeatTimer);
408
445
  }
446
+ const heartbeatDrain = __classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").get(session);
447
+ if (heartbeatDrain) {
448
+ try {
449
+ await heartbeatDrain();
450
+ }
451
+ catch {
452
+ // Heartbeats are best-effort and retain the last fully verified token.
453
+ }
454
+ finally {
455
+ __classPrivateFieldGet(this, _LoadStrikeLocalClient_heartbeatDrains, "f").delete(session);
456
+ }
457
+ }
409
458
  if (!session.runToken) {
410
459
  return;
411
460
  }
@@ -463,7 +512,7 @@ export class LoadStrikeLocalClient {
463
512
  return { response, json };
464
513
  }
465
514
  }
466
- _LoadStrikeLocalClient_licensingApiBaseUrl = new WeakMap(), _LoadStrikeLocalClient_signingKeyCache = new WeakMap();
515
+ _LoadStrikeLocalClient_licensingApiBaseUrl = new WeakMap(), _LoadStrikeLocalClient_signingKeyCache = new WeakMap(), _LoadStrikeLocalClient_heartbeatDrains = new WeakMap();
467
516
  function assertNoDisableLicenseEnforcementOption(value, source) {
468
517
  if (value == null || typeof value !== "object" || Array.isArray(value)) {
469
518
  return;
@@ -1329,9 +1378,20 @@ function readTrackingId(payload, selector) {
1329
1378
  return null;
1330
1379
  }
1331
1380
  let current = body;
1332
- for (const segment of path.split(".").filter(Boolean)) {
1381
+ let segments;
1382
+ try {
1383
+ segments = safeJsonPathSegments(path);
1384
+ }
1385
+ catch {
1386
+ return null;
1387
+ }
1388
+ for (const segment of segments) {
1333
1389
  if (current && typeof current === "object" && !Array.isArray(current)) {
1334
- current = current[segment];
1390
+ const record = current;
1391
+ if (!Object.prototype.hasOwnProperty.call(record, segment)) {
1392
+ return null;
1393
+ }
1394
+ current = record[segment];
1335
1395
  }
1336
1396
  else {
1337
1397
  return null;
@@ -1364,23 +1424,57 @@ function readOptionalTrackingSelectorValue(value) {
1364
1424
  return undefined;
1365
1425
  }
1366
1426
  function setJsonPathValue(body, path, value) {
1367
- const target = body && typeof body === "object" && !Array.isArray(body)
1368
- ? { ...body }
1369
- : {};
1370
- const segments = path.split(".").filter(Boolean);
1427
+ const target = cloneJsonRecord(body);
1428
+ const segments = safeJsonPathSegments(path);
1371
1429
  if (!segments.length) {
1372
1430
  return target;
1373
1431
  }
1374
1432
  let current = target;
1375
1433
  for (let i = 0; i < segments.length - 1; i += 1) {
1376
1434
  const segment = segments[i];
1377
- const next = current[segment];
1435
+ const next = readOwnJsonProperty(current, segment);
1436
+ let child;
1378
1437
  if (!next || typeof next !== "object" || Array.isArray(next)) {
1379
- current[segment] = {};
1438
+ child = {};
1380
1439
  }
1381
- current = current[segment];
1440
+ else {
1441
+ child = cloneJsonRecord(next);
1442
+ }
1443
+ defineJsonProperty(current, segment, child);
1444
+ current = child;
1445
+ }
1446
+ defineJsonProperty(current, segments[segments.length - 1], value);
1447
+ return target;
1448
+ }
1449
+ const FORBIDDEN_JSON_PATH_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
1450
+ function safeJsonPathSegments(path) {
1451
+ const segments = path.split(".").filter(Boolean);
1452
+ const forbidden = segments.find((segment) => FORBIDDEN_JSON_PATH_SEGMENTS.has(segment));
1453
+ if (forbidden) {
1454
+ throw new Error(`Tracking selector contains forbidden JSON path segment '${forbidden}'.`);
1455
+ }
1456
+ return segments;
1457
+ }
1458
+ function defineJsonProperty(target, key, value) {
1459
+ Object.defineProperty(target, key, {
1460
+ configurable: true,
1461
+ enumerable: true,
1462
+ value,
1463
+ writable: true
1464
+ });
1465
+ }
1466
+ function readOwnJsonProperty(target, key) {
1467
+ const descriptor = Object.getOwnPropertyDescriptor(target, key);
1468
+ return descriptor && "value" in descriptor ? descriptor.value : undefined;
1469
+ }
1470
+ function cloneJsonRecord(value) {
1471
+ const target = {};
1472
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1473
+ return target;
1474
+ }
1475
+ for (const [key, entry] of Object.entries(value)) {
1476
+ defineJsonProperty(target, key, entry);
1382
1477
  }
1383
- current[segments[segments.length - 1]] = value;
1384
1478
  return target;
1385
1479
  }
1386
1480
  function mapCorrelationStore(tracking, runNamespace) {
@@ -1827,6 +1921,10 @@ function isRuntimeReportingSink(value) {
1827
1921
  "SaveRealtimeMetrics",
1828
1922
  "saveRunResult",
1829
1923
  "SaveRunResult",
1924
+ "saveIterationBatch",
1925
+ "SaveIterationBatch",
1926
+ "completeIterationObservationStream",
1927
+ "CompleteIterationObservationStream",
1830
1928
  "stop",
1831
1929
  "Stop"
1832
1930
  ].some((name) => typeof record[name] === "function");
@@ -38,8 +38,12 @@ function reportValue(source, ...keys) {
38
38
  }
39
39
  const record = source;
40
40
  for (const key of keys) {
41
- if (record[key] !== undefined && record[key] !== null) {
42
- return record[key];
41
+ const descriptor = Object.getOwnPropertyDescriptor(record, key);
42
+ if (descriptor
43
+ && "value" in descriptor
44
+ && descriptor.value !== undefined
45
+ && descriptor.value !== null) {
46
+ return descriptor.value;
43
47
  }
44
48
  }
45
49
  return undefined;
@@ -132,6 +136,26 @@ function asFloat(value) {
132
136
  const parsed = Number.parseFloat(asString(value));
133
137
  return Number.isFinite(parsed) ? parsed : 0;
134
138
  }
139
+ function combinedMeasurement(source) {
140
+ const all = reportValue(source, "allMeasurement", "AllMeasurement");
141
+ if (all && typeof all === "object" && !Array.isArray(all)) {
142
+ return all;
143
+ }
144
+ const ok = reportObject(source, "ok", "Ok");
145
+ const fail = reportObject(source, "fail", "Fail");
146
+ const okCount = asInt(reportValue(reportObject(ok, "request", "Request"), "count", "Count"));
147
+ const failCount = asInt(reportValue(reportObject(fail, "request", "Request"), "count", "Count"));
148
+ if (okCount === 0) {
149
+ return fail;
150
+ }
151
+ return failCount === 0 ? ok : undefined;
152
+ }
153
+ function formatCombinedLatency(source, ...keys) {
154
+ const measurement = combinedMeasurement(source);
155
+ return measurement
156
+ ? formatReportNumber(reportValue(reportObject(measurement, "latency", "Latency"), ...keys))
157
+ : "n/a";
158
+ }
135
159
  function asBool(value) {
136
160
  if (typeof value === "boolean") {
137
161
  return value;
@@ -302,7 +326,22 @@ function formatDotnetDateTime(value) {
302
326
  return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}.${fraction}Z`;
303
327
  }
304
328
  function escapeJsonForHtmlScript(value) {
305
- return value.split("</").join("<\\/").split("<!--").join("<\\!--");
329
+ return value.replace(/[<>&\u2028\u2029]/g, (character) => {
330
+ switch (character) {
331
+ case "<":
332
+ return "\\u003c";
333
+ case ">":
334
+ return "\\u003e";
335
+ case "&":
336
+ return "\\u0026";
337
+ case "\u2028":
338
+ return "\\u2028";
339
+ case "\u2029":
340
+ return "\\u2029";
341
+ default:
342
+ return character;
343
+ }
344
+ });
306
345
  }
307
346
  function buildReportLogoDataUri(resourceName) {
308
347
  const cached = REPORT_LOGO_CACHE.get(resourceName);
@@ -747,8 +786,8 @@ function buildDotnetScenarioRows(nodeStats) {
747
786
  FAIL: reportFailCountValue(scenario),
748
787
  Duration: formatDotnetTimeSpan(reportDurationValue(scenario)),
749
788
  RPS: formatReportNumber(reportDurationSeconds(reportDurationValue(scenario)) <= 0 ? 0 : requestCount / reportDurationSeconds(reportDurationValue(scenario))),
750
- LatencyP95Ms: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95")))),
751
- LatencyP99Ms: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent99", "Percent99")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent99", "Percent99")))),
789
+ LatencyP95Ms: formatCombinedLatency(scenario, "percent95", "Percent95"),
790
+ LatencyP99Ms: formatCombinedLatency(scenario, "percent99", "Percent99"),
752
791
  CurrentOperation: asString(reportValue(scenario, "currentOperation", "CurrentOperation"))
753
792
  };
754
793
  });
@@ -765,8 +804,8 @@ function buildDotnetStepRows(nodeStats) {
765
804
  FAIL: asInt(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "count", "Count")),
766
805
  OK_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "ok", "Ok"), "request", "Request"), "rps", "RPS")),
767
806
  FAIL_RPS: formatReportNumber(reportValue(reportObject(reportObject(step, "fail", "Fail"), "request", "Request"), "rps", "RPS")),
768
- LatencyMeanMs: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(step, "ok", "Ok"), "latency", "Latency"), "meanMs", "MeanMs")), asFloat(reportValue(reportObject(reportObject(step, "fail", "Fail"), "latency", "Latency"), "meanMs", "MeanMs")))),
769
- P95LatencyMs: formatReportNumber(Math.max(asFloat(reportValue(reportObject(reportObject(step, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(step, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95"))))
807
+ LatencyMeanMs: formatCombinedLatency(step, "meanMs", "MeanMs"),
808
+ P95LatencyMs: formatCombinedLatency(step, "percent95", "Percent95")
770
809
  });
771
810
  }
772
811
  }
@@ -776,6 +815,10 @@ function buildDotnetScenarioMeasurementRows(nodeStats) {
776
815
  const rows = [];
777
816
  for (const scenario of reportScenarios(nodeStats)) {
778
817
  const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
818
+ const allMeasurement = reportValue(scenario, "allMeasurement", "AllMeasurement");
819
+ if (allMeasurement && typeof allMeasurement === "object" && !Array.isArray(allMeasurement)) {
820
+ pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "ALL", allMeasurement);
821
+ }
779
822
  pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "OK", reportObject(scenario, "ok", "Ok"));
780
823
  pushMeasurementRowIfData(rows, "Scenario", scenarioName, "", "FAIL", reportObject(scenario, "fail", "Fail"));
781
824
  }
@@ -787,6 +830,10 @@ function buildDotnetStepMeasurementRows(nodeStats) {
787
830
  const scenarioName = asString(reportValue(scenario, "scenarioName", "ScenarioName"));
788
831
  for (const step of reportSteps(scenario)) {
789
832
  const stepName = asString(reportValue(step, "stepName", "StepName"));
833
+ const allMeasurement = reportValue(step, "allMeasurement", "AllMeasurement");
834
+ if (allMeasurement && typeof allMeasurement === "object" && !Array.isArray(allMeasurement)) {
835
+ pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "ALL", allMeasurement);
836
+ }
790
837
  pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "OK", reportObject(step, "ok", "Ok"));
791
838
  pushMeasurementRowIfData(rows, "Step", scenarioName, stepName, "FAIL", reportObject(step, "fail", "Fail"));
792
839
  }
@@ -867,6 +914,9 @@ function buildDotnetStatusCodeClassChart(scenarios) {
867
914
  }
868
915
  function buildDotnetChartData(nodeStats) {
869
916
  const scenarios = reportScenarios(nodeStats);
917
+ const combinedScenarios = scenarios
918
+ .map((scenario) => ({ scenario, measurement: combinedMeasurement(scenario) }))
919
+ .filter((item) => item.measurement !== undefined);
870
920
  return {
871
921
  overallOutcome: [
872
922
  { label: "OK", value: reportTotalOkCount(nodeStats, scenarios), color: "#18a957" },
@@ -877,9 +927,9 @@ function buildDotnetChartData(nodeStats) {
877
927
  value: reportRequestCountValue(scenario),
878
928
  color: "#3b82f6"
879
929
  })),
880
- scenarioP95Latency: scenarios.map((scenario) => ({
930
+ scenarioP95Latency: combinedScenarios.map(({ scenario, measurement }) => ({
881
931
  label: asString(reportValue(scenario, "scenarioName", "ScenarioName")),
882
- value: Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95"))),
932
+ value: asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95")),
883
933
  color: "#8b5cf6"
884
934
  })),
885
935
  scenarioRps: scenarios.map((scenario) => ({
@@ -903,12 +953,12 @@ function buildDotnetChartData(nodeStats) {
903
953
  })),
904
954
  statusCodeClasses: buildDotnetStatusCodeClassChart(scenarios),
905
955
  scenarioLatencyTrend: {
906
- labels: scenarios.map((scenario) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
956
+ labels: combinedScenarios.map(({ scenario }) => asString(reportValue(scenario, "scenarioName", "ScenarioName"))),
907
957
  series: [
908
- { name: "P50", color: "#38bdf8", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent50", "Percent50")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent50", "Percent50")))) },
909
- { name: "P75", color: "#22c55e", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent75", "Percent75")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent75", "Percent75")))) },
910
- { name: "P95", color: "#f59e0b", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent95", "Percent95")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent95", "Percent95")))) },
911
- { name: "P99", color: "#f43f5e", values: scenarios.map((scenario) => Math.max(asFloat(reportValue(reportObject(reportObject(scenario, "ok", "Ok"), "latency", "Latency"), "percent99", "Percent99")), asFloat(reportValue(reportObject(reportObject(scenario, "fail", "Fail"), "latency", "Latency"), "percent99", "Percent99")))) }
958
+ { name: "P50", color: "#38bdf8", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent50", "Percent50"))) },
959
+ { name: "P75", color: "#22c55e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent75", "Percent75"))) },
960
+ { name: "P95", color: "#f59e0b", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent95", "Percent95"))) },
961
+ { name: "P99", color: "#f43f5e", values: combinedScenarios.map(({ measurement }) => asFloat(reportValue(reportObject(measurement, "latency", "Latency"), "percent99", "Percent99"))) }
912
962
  ]
913
963
  }
914
964
  };
@@ -1053,6 +1103,82 @@ function buildDotnetThresholdHtml(nodeStats) {
1053
1103
  function buildDotnetMetricHtml(nodeStats) {
1054
1104
  return buildDotnetTableHtml(buildDotnetMetricRows(nodeStats));
1055
1105
  }
1106
+ function buildDotnetGeneratorDeliveryHtml(nodeStats) {
1107
+ const warnings = reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings");
1108
+ const stats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1109
+ const topLevelSegments = reportArray(nodeStats, "schedulerSegments", "SchedulerSegments");
1110
+ const segments = topLevelSegments.length > 0
1111
+ ? topLevelSegments
1112
+ : reportArray(stats, "segments", "Segments");
1113
+ const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
1114
+ const reportingCompleteValue = reportValue(nodeStats, "reportingComplete", "ReportingComplete");
1115
+ const reportingComplete = reportingCompleteValue == null
1116
+ ? "N/A"
1117
+ : asBool(reportingCompleteValue) ? "Yes" : "No";
1118
+ const parts = [];
1119
+ appendReportLine(parts, "<div class=\"card\">");
1120
+ appendReportLine(parts, "<h2>Generator Delivery</h2>");
1121
+ appendReportLine(parts, "<p>Application failures remain separate from generator and reporting warnings. Dropped arrivals, unavailable worker slots, and raw observation loss are not synthetic SUT errors.</p>");
1122
+ appendReportLine(parts, "<div class=\"card-grid\">");
1123
+ for (const [label, value] of [
1124
+ ["Configured Max In Flight", asInt(reportValue(stats, "configuredMaxInFlight", "ConfiguredMaxInFlight"))],
1125
+ ["Observed Max In Flight", asInt(reportValue(stats, "maxInFlightObserved", "MaxInFlightObserved"))],
1126
+ ["Current In Flight", asInt(reportValue(stats, "currentInFlight", "CurrentInFlight"))],
1127
+ ["Observation Captured", asString(reportValue(observationStats, "capturedCount64", "CapturedCount64")) || "0"],
1128
+ ["Observation Delivered", asString(reportValue(observationStats, "deliveredCount64", "DeliveredCount64")) || "0"],
1129
+ ["Observation Buffer Drops", asString(reportValue(observationStats, "droppedBufferCount64", "DroppedBufferCount64")) || "0"],
1130
+ ["Observation Sink Drops", asString(reportValue(observationStats, "droppedSinkCount64", "DroppedSinkCount64")) || "0"],
1131
+ ["Reporting Complete", reportingComplete],
1132
+ ["Warning Groups", warnings.length]
1133
+ ]) {
1134
+ appendReportLine(parts, `<div class="stat-card"><div class="stat-label">${escapeHtml(label)}</div><div class="stat-value">${escapeHtml(value)}</div></div>`);
1135
+ }
1136
+ appendReportLine(parts, "</div></div>");
1137
+ if (warnings.length) {
1138
+ appendReportLine(parts, "<div class=\"card\"><h2>Generator Warnings</h2>");
1139
+ parts.push(buildDotnetTableHtml(warnings, false));
1140
+ appendReportLine(parts, "</div>");
1141
+ }
1142
+ if (segments.length) {
1143
+ const rows = segments.map((segment) => ({
1144
+ Scenario: asString(reportValue(segment, "scenarioName", "ScenarioName")),
1145
+ Simulation: asString(reportValue(segment, "kind", "Kind")),
1146
+ Shard: `${asInt(reportValue(segment, "shardIndex", "ShardIndex"))}/${Math.max(asInt(reportValue(segment, "shardCount", "ShardCount")), 1)}`,
1147
+ Planned: asString(reportValue(segment, "plannedIterations64", "PlannedIterations64")),
1148
+ Due: asString(reportValue(segment, "dueIterations64", "DueIterations64")),
1149
+ Started: asString(reportValue(segment, "startedIterations64", "StartedIterations64")),
1150
+ Completed: asString(reportValue(segment, "completedIterations64", "CompletedIterations64")),
1151
+ Dropped: asString(reportValue(segment, "droppedIterations64", "DroppedIterations64")),
1152
+ Unreached: asString(reportValue(segment, "unreachedIterations64", "UnreachedIterations64")),
1153
+ "Unavailable Workers": asString(reportValue(segment, "unavailableWorkerSlots64", "UnavailableWorkerSlots64")),
1154
+ "Delivery %": formatReportNumber(reportValue(segment, "deliveryPercent", "DeliveryPercent")),
1155
+ "Accounting Complete": Boolean(reportValue(segment, "accountingComplete", "AccountingComplete"))
1156
+ }));
1157
+ appendReportLine(parts, "<div class=\"card\"><h2>Scheduler Segments</h2>");
1158
+ parts.push(buildDotnetTableHtml(rows, false));
1159
+ appendReportLine(parts, "</div>");
1160
+ }
1161
+ return parts.join("");
1162
+ }
1163
+ function hasDotnetGeneratorDeliveryData(nodeStats) {
1164
+ const schedulerStats = reportObject(nodeStats, "schedulerStats", "SchedulerStats");
1165
+ const observationStats = reportObject(nodeStats, "observationDeliveryStats", "ObservationDeliveryStats");
1166
+ const hasNonZeroDecimal = (value) => {
1167
+ const text = asString(value);
1168
+ return text.trim().length > 0 && text !== "0";
1169
+ };
1170
+ return reportArray(nodeStats, "generatorWarnings", "GeneratorWarnings").length > 0
1171
+ || reportArray(nodeStats, "schedulerSegments", "SchedulerSegments").length > 0
1172
+ || reportArray(schedulerStats, "segments", "Segments").length > 0
1173
+ || asInt(reportValue(schedulerStats, "configuredMaxInFlight", "ConfiguredMaxInFlight")) > 0
1174
+ || asInt(reportValue(schedulerStats, "maxInFlightObserved", "MaxInFlightObserved")) > 0
1175
+ || asInt(reportValue(schedulerStats, "currentInFlight", "CurrentInFlight")) > 0
1176
+ || reportValue(nodeStats, "reportingComplete", "ReportingComplete") === false
1177
+ || hasNonZeroDecimal(reportValue(observationStats, "capturedCount64", "CapturedCount64"))
1178
+ || hasNonZeroDecimal(reportValue(observationStats, "deliveredCount64", "DeliveredCount64"))
1179
+ || hasNonZeroDecimal(reportValue(observationStats, "droppedBufferCount64", "DroppedBufferCount64"))
1180
+ || hasNonZeroDecimal(reportValue(observationStats, "droppedSinkCount64", "DroppedSinkCount64"));
1181
+ }
1056
1182
  function buildDotnetGroupedCorrelationSummaryHtml(rows, groupedChartKey) {
1057
1183
  const parts = [];
1058
1184
  const payloads = buildGroupedCorrelationChartPayloads(rows);
@@ -1129,6 +1255,9 @@ function buildDotnetHtmlTabs(nodeStats) {
1129
1255
  if (metricRows.length) {
1130
1256
  tabs.push(["metrics", "Metrics", buildDotnetTableHtml(metricRows)]);
1131
1257
  }
1258
+ if (hasDotnetGeneratorDeliveryData(nodeStats)) {
1259
+ tabs.push(["generator-delivery", "Generator Delivery", buildDotnetGeneratorDeliveryHtml(nodeStats)]);
1260
+ }
1132
1261
  for (const plugin of reportArray(nodeStats, "pluginsData", "PluginsData")) {
1133
1262
  const pluginName = asString(reportValue(plugin, "pluginName", "PluginName"));
1134
1263
  const hints = buildDotnetPluginHints(plugin);
@@ -1162,7 +1291,7 @@ function buildDotnetHtmlTabs(nodeStats) {
1162
1291
  body = buildDotnetGroupedCorrelationSummaryHtml(bodyRows, `grouped-correlation-${tabs.length}`);
1163
1292
  }
1164
1293
  else if (lowerPlugin.includes("correlation") && lowerTable.includes("ungrouped correlation rows")) {
1165
- title = "Ungrouped Corelation Summary";
1294
+ title = "Ungrouped Correlation Summary";
1166
1295
  body = buildDotnetUngroupedCorrelationSummaryHtml(bodyRows, `ungrouped-correlation-${tabs.length}`);
1167
1296
  }
1168
1297
  tabs.push([`plugin-${tabs.length}`, title, `${hints}${body}`]);
@@ -1278,9 +1407,9 @@ export function buildDotnetMarkdownReport(nodeStats) {
1278
1407
  */
1279
1408
  export function buildDotnetHtmlReport(nodeStats) {
1280
1409
  const tabs = buildDotnetHtmlTabs(nodeStats);
1281
- const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${tabId}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1282
- const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${tabId}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1283
- const chartDataJson = JSON.stringify(buildDotnetChartData(nodeStats));
1410
+ const buttonsHtml = tabs.map(([tabId, title]) => `<button class="tab-btn" data-tab="${escapeHtml(tabId)}">${escapeHtml(title)}</button>${REPORT_EOL}`).join("");
1411
+ const sectionsHtml = tabs.map(([tabId, , html]) => `<section id="${escapeHtml(tabId)}" class="tab">${html}</section>${REPORT_EOL}`).join("");
1412
+ const chartDataJson = escapeJsonForHtmlScript(JSON.stringify(buildDotnetChartData(nodeStats)));
1284
1413
  const testInfo = reportObject(nodeStats, "testInfo", "TestInfo");
1285
1414
  const template = `<!doctype html>
1286
1415
  <html lang="en">
@@ -1372,7 +1501,7 @@ const reportLogo=document.querySelector('[data-report-logo]');
1372
1501
  function applyReportTheme(theme){const normalized=theme==='dark'?'dark':'light';document.body.setAttribute('data-theme',normalized);if(reportLogo){const lightLogo=reportLogo.dataset.logoLight||reportLogo.getAttribute('src');const darkLogo=reportLogo.dataset.logoDark||reportLogo.getAttribute('src');reportLogo.setAttribute('src',normalized==='dark'?darkLogo:lightLogo);}if(reportThemeToggle){const darkActive=normalized==='dark';const nextLabel=darkActive?'light':'dark';reportThemeToggle.innerHTML=darkActive?'&#x2600;':'&#x263E;';reportThemeToggle.setAttribute('aria-pressed',darkActive?'true':'false');reportThemeToggle.setAttribute('aria-label','Switch to '+nextLabel+' theme');reportThemeToggle.setAttribute('title','Switch to '+nextLabel+' theme');}}
1373
1502
  function show(id){btns.forEach(b=>b.classList.toggle('active',b.dataset.tab===id));tabSections.forEach(t=>t.classList.toggle('active',t.id===id));}
1374
1503
  function formatMetric(v){if(!Number.isFinite(v))return '0';return Math.abs(v)>=100?v.toFixed(0):v.toFixed(2);}
1375
- function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const w=Math.max(320,canvas.clientWidth||320);const h=Math.max(220,canvas.clientHeight||220);canvas.width=Math.floor(w*dpr);canvas.height=Math.floor(h*dpr);const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);return {ctx,w,h};}
1504
+ function setupCanvas(canvas){const dpr=window.devicePixelRatio||1;const rect=canvas.getBoundingClientRect();const w=Math.max(1,Math.round(rect.width||canvas.clientWidth||320));const h=Math.max(1,Math.round(rect.height||canvas.clientHeight||220));canvas.width=Math.max(1,Math.round(w*dpr));canvas.height=Math.max(1,Math.round(h*dpr));const ctx=canvas.getContext('2d');ctx.setTransform(dpr,0,0,dpr,0,0);return {ctx,w,h};}
1376
1505
  function drawNoData(ctx,w,h,msg){ctx.fillStyle='#9fb0c3';ctx.font='13px Segoe UI';ctx.textAlign='center';ctx.fillText(msg,w/2,h/2);}
1377
1506
  function drawBar(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const left=46,right=14,top=16,bottom=62;const pw=w-left-right;const ph=h-top-bottom;const max=Math.max(...points.map(p=>p.value),1);ctx.strokeStyle='#334155';ctx.lineWidth=1;for(let i=0;i<=4;i++){const y=top+(ph*(i/4));ctx.beginPath();ctx.moveTo(left,y);ctx.lineTo(w-right,y);ctx.stroke();}const slot=pw/points.length;const bar=Math.max(8,slot*0.58);ctx.font='11px Segoe UI';for(let i=0;i<points.length;i++){const p=points[i];const x=left+i*slot+(slot-bar)/2;const bh=(p.value/max)*ph;const y=top+ph-bh;ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(x,y,bar,bh);ctx.fillStyle='#dbe6f4';ctx.textAlign='center';ctx.fillText(formatMetric(p.value),x+bar/2,Math.max(12,y-4));ctx.save();ctx.translate(x+bar/2,h-bottom+14);ctx.rotate(-0.6);ctx.fillStyle='#b5c2d3';ctx.fillText((p.label||'').slice(0,26),0,0);ctx.restore();}ctx.fillStyle='#b5c2d3';ctx.textAlign='right';for(let i=0;i<=4;i++){const value=max*(1-i/4);const y=top+(ph*(i/4))+4;ctx.fillText(formatMetric(value),left-6,y);}}
1378
1507
  function drawPie(canvasId,points){const canvas=document.getElementById(canvasId);if(!canvas)return;const c=setupCanvas(canvas);const ctx=c.ctx,w=c.w,h=c.h;ctx.clearRect(0,0,w,h);if(!points||points.length===0){drawNoData(ctx,w,h,'No data');return;}const total=points.reduce((s,p)=>s+(p.value||0),0);if(total<=0){drawNoData(ctx,w,h,'No data');return;}const cx=w*0.35,cy=h*0.5,r=Math.min(w,h)*0.28;let angle=-Math.PI/2;for(const p of points){const val=Math.max(0,p.value||0);const delta=(val/total)*Math.PI*2;ctx.beginPath();ctx.moveTo(cx,cy);ctx.arc(cx,cy,r,angle,angle+delta);ctx.closePath();ctx.fillStyle=p.color||'#3b82f6';ctx.fill();angle+=delta;}ctx.fillStyle='#0f172a';ctx.beginPath();ctx.arc(cx,cy,r*0.54,0,Math.PI*2);ctx.fill();ctx.fillStyle='#e5eefc';ctx.font='bold 18px Segoe UI';ctx.textAlign='center';ctx.fillText(total.toString(),cx,cy+6);ctx.font='12px Segoe UI';ctx.fillStyle='#9fb0c3';ctx.fillText('requests',cx,cy+24);ctx.textAlign='left';let y=cy-r+10;for(const p of points){ctx.fillStyle=p.color||'#3b82f6';ctx.fillRect(w*0.64,y-10,12,12);ctx.fillStyle='#e6edf3';ctx.font='12px Segoe UI';const pct=total<=0?0:((p.value/total)*100);ctx.fillText(\`\${p.label}: \${p.value} (\${pct.toFixed(1)}%)\`,w*0.64+18,y);y+=20;}}