@memberjunction/react-test-harness 6.2.0-edge.6 → 6.2.0-edge.7

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.
@@ -131,6 +131,10 @@ export function ResolveDataCapMaxRows(requested, ceiling) {
131
131
  * paging, making it the denominator for "capped at N of M". Providers that omit it, or
132
132
  * leave it equal to `RowCount`, yield `capped: false` rather than a guess.
133
133
  *
134
+ * A result with `Success: false` is a failed call, not an empty one: its `ErrorMessage` is
135
+ * recorded as `error`. Providers return a client-database timeout this way rather than
136
+ * throwing, and recording it as a 0-row success reads as "no data" downstream.
137
+ *
134
138
  * Pure (no I/O) so it can be unit-tested without launching a browser.
135
139
  */
136
140
  export function BuildDataAccessRecord(kind, resolved, result, maxRows, durationMs) {
@@ -146,7 +150,58 @@ export function BuildDataAccessRecord(kind, resolved, result, maxRows, durationM
146
150
  appliedMaxRows: maxRows.applied,
147
151
  requestedMaxRows: maxRows.requested,
148
152
  durationMs,
153
+ ...(result?.Success === false && { error: result.ErrorMessage || 'the call failed with no error message' }),
154
+ };
155
+ }
156
+ /** Start time of each data call that has not returned yet. */
157
+ const pendingDataCalls = new WeakMap();
158
+ /** Records that are final: returned, or reported as still running when the test ended. */
159
+ const settledDataCalls = new WeakSet();
160
+ /**
161
+ * Adds a record for a data call as it starts, so a call still running when the test ends is
162
+ * reported instead of missing. {@link SettleDataAccessRecord} fills it in when the call returns;
163
+ * {@link FinalizePendingDataAccess} marks it if it has not by the time the result is built.
164
+ */
165
+ export function StartDataAccessRecord(dataAccess, kind, resolved, maxRows, now = Date.now()) {
166
+ const record = {
167
+ kind,
168
+ target: resolved.target,
169
+ identity: resolved.identity,
170
+ rowsReturned: 0,
171
+ capped: false,
172
+ appliedMaxRows: maxRows.applied,
173
+ requestedMaxRows: maxRows.requested,
174
+ durationMs: 0,
149
175
  };
176
+ pendingDataCalls.set(record, now);
177
+ dataAccess.push(record);
178
+ return record;
179
+ }
180
+ /**
181
+ * Completes a started record with the call's outcome. A record already reported as still running
182
+ * is left as it was: the result the caller received said so, and must not change afterwards.
183
+ */
184
+ export function SettleDataAccessRecord(placeholder, outcome) {
185
+ if (settledDataCalls.has(placeholder))
186
+ return;
187
+ pendingDataCalls.delete(placeholder);
188
+ settledDataCalls.add(placeholder);
189
+ Object.assign(placeholder, outcome);
190
+ }
191
+ /**
192
+ * Marks every call still running when the test finishes as failed, with how long it had run.
193
+ * Without this, a slow query reads as "the component made no data calls".
194
+ */
195
+ export function FinalizePendingDataAccess(dataAccess, now = Date.now()) {
196
+ for (const record of dataAccess) {
197
+ const startedAt = pendingDataCalls.get(record);
198
+ if (startedAt === undefined)
199
+ continue;
200
+ pendingDataCalls.delete(record);
201
+ settledDataCalls.add(record);
202
+ record.durationMs = now - startedAt;
203
+ record.error = `still running after ${(record.durationMs / 1000).toFixed(1)}s when the test finished`;
204
+ }
150
205
  }
151
206
  /** Debug-log suffix naming the cap, e.g. ` (capped at 1000 of 205802)`. Empty when uncapped. */
152
207
  export function DescribeDataCap(record) {
@@ -157,7 +212,14 @@ export function DescribeDataCap(record) {
157
212
  * `/MJ/AI/Agents/Foo`. Tolerates missing or doubled slashes on either side.
158
213
  */
159
214
  function joinCategoryPath(categoryPath, name) {
160
- const trimmed = categoryPath.replace(/^\/+|\/+$/g, '');
215
+ // Trimmed by scanning rather than /^\/+|\/+$/g, which backtracks quadratically on long runs of '/'
216
+ let start = 0;
217
+ let end = categoryPath.length;
218
+ while (start < end && categoryPath[start] === '/')
219
+ start++;
220
+ while (end > start && categoryPath[end - 1] === '/')
221
+ end--;
222
+ const trimmed = categoryPath.slice(start, end);
161
223
  return trimmed.length === 0 ? `/${name}` : `/${trimmed}/${name}`;
162
224
  }
163
225
  /**
@@ -236,6 +298,12 @@ export function ResolveViewTarget(params) {
236
298
  export function DescribeViewTarget(params) {
237
299
  return ResolveViewTarget(params).target;
238
300
  }
301
+ /**
302
+ * Rule for a call to a component utility the harness was not given (for example
303
+ * `utilities.ai` when the caller passes no AI tools). The call cannot be tested here,
304
+ * which is a limit of the harness, not a defect in the component.
305
+ */
306
+ export const SANDBOX_UTILITY_UNAVAILABLE_RULE = 'sandbox-utility-unavailable';
239
307
  /**
240
308
  * ComponentRunner that uses the actual React runtime via Playwright UMD bundle
241
309
  */
@@ -276,6 +344,94 @@ export class ComponentRunner {
276
344
  const lowerMessage = message.toLowerCase();
277
345
  return ComponentRunner.BROWSER_CRASH_PATTERNS.some(pattern => lowerMessage.includes(pattern));
278
346
  }
347
+ /**
348
+ * Occurrences of one JSX element-type error at which it stops being treated as noise and
349
+ * fails the run.
350
+ *
351
+ * These errors can be spurious: a child component that resolves a moment late produces one
352
+ * or two before settling, which is why they were once dropped entirely. But dropping them
353
+ * has no limit. A component rendering an object where a component belongs throws one per
354
+ * render, and runs have reached thousands in a single component while reporting success.
355
+ * A handful is a race; a few dozen is a defect.
356
+ */
357
+ static { this.JSX_ERROR_FAILURE_THRESHOLD = 25; }
358
+ /** Whether `e` is an "invalid JSX element type" or "undefined component" error. */
359
+ static isJSXElementError(e) {
360
+ return e.type === 'invalid-element-type' || e.type === 'undefined-component' ||
361
+ e.rule === 'invalid-jsx-element' || e.rule === 'undefined-jsx-component';
362
+ }
363
+ /** A JSX element-type error rare enough to be treated as a transient resolution miss. */
364
+ static isSuppressibleJSXError(e) {
365
+ return ComponentRunner.isJSXElementError(e) && e.occurrences < ComponentRunner.JSX_ERROR_FAILURE_THRESHOLD;
366
+ }
367
+ /**
368
+ * Folds an error from the second (post-async) collection into the first. Collection is
369
+ * cumulative, so a JSX error seen again carries a higher count and a different message: it
370
+ * replaces its earlier entry instead of being added beside it, and it counts toward the
371
+ * run's errors once it reaches the threshold.
372
+ */
373
+ static mergeAsyncRuntimeError(err, errors, collected) {
374
+ if (!ComponentRunner.isJSXElementError(err)) {
375
+ if (!errors.includes(err.message)) {
376
+ errors.push(err.message);
377
+ collected.push(err);
378
+ }
379
+ return;
380
+ }
381
+ const index = collected.findIndex(e => e.type === err.type && e.baseMessage === err.baseMessage);
382
+ if (index >= 0) {
383
+ const previousMessage = errors.indexOf(collected[index].message);
384
+ if (previousMessage >= 0)
385
+ errors.splice(previousMessage, 1);
386
+ collected[index] = err;
387
+ }
388
+ else {
389
+ collected.push(err);
390
+ }
391
+ if (!ComponentRunner.isSuppressibleJSXError(err))
392
+ errors.push(err.message);
393
+ }
394
+ /**
395
+ * The reported violation for a collected runtime error. Most are critical. A JSX
396
+ * element-type error is low below the threshold, so it stays visible without failing the
397
+ * run, and high at or above it, which fails `codeExecutionSuccess`.
398
+ */
399
+ static buildRuntimeErrorViolation(e, debug) {
400
+ const isJSXError = ComponentRunner.isJSXElementError(e);
401
+ const isFailingJSXError = isJSXError && !ComponentRunner.isSuppressibleJSXError(e);
402
+ const severity = !isJSXError ? 'critical' : isFailingJSXError ? 'high' : 'low';
403
+ if (isJSXError && debug) {
404
+ console.log(`📝 JSX element error (${e.occurrences}x, severity=${severity}):`, e.message);
405
+ }
406
+ return {
407
+ message: isFailingJSXError
408
+ ? `${e.message} — repeated ${e.occurrences} times, which indicates a genuinely invalid element type rather than a transient resolution miss`
409
+ : e.message,
410
+ severity,
411
+ rule: e.rule || 'runtime-error', // Use specific rule from collectRuntimeErrors
412
+ line: 0,
413
+ column: 0,
414
+ source: e.source,
415
+ stack: e.stack,
416
+ componentStack: e.componentStack,
417
+ };
418
+ }
419
+ /**
420
+ * One warning per utility the component called that this harness was not given. They are
421
+ * warnings rather than errors so they never fail the run or reach a fixer as a defect:
422
+ * the call was not tested, and the component may well be correct.
423
+ */
424
+ static buildUnavailableUtilityWarnings(unavailableUtilities) {
425
+ return [...unavailableUtilities].map(name => ({
426
+ message: `${name} is not available in the test harness, so this call was not tested. ` +
427
+ `This is a limit of the harness, not a defect in the component.`,
428
+ severity: 'medium',
429
+ rule: SANDBOX_UTILITY_UNAVAILABLE_RULE,
430
+ line: 0,
431
+ column: 0,
432
+ source: 'test-harness'
433
+ }));
434
+ }
279
435
  /**
280
436
  * Reclassify violations that are actually browser crashes.
281
437
  * Browser crashes should be marked as low severity with rule='browser-crash'
@@ -321,6 +477,7 @@ export class ComponentRunner {
321
477
  const warnings = [];
322
478
  const consoleLogs = [];
323
479
  const dataErrors = []; // Track data access errors from RunView/RunQuery
480
+ const unavailableUtilities = new Set(); // Utilities the component called that this harness was not given
324
481
  const dataAccess = []; // Track every data call: target, rows, cap state
325
482
  let renderCount = 0;
326
483
  const debug = options.debug !== false; // Default to true for debugging
@@ -391,7 +548,7 @@ export class ComponentRunner {
391
548
  // Set up console logging
392
549
  this.setupConsoleLogging(page, consoleLogs, warnings);
393
550
  // Expose MJ utilities to the page
394
- await this.exposeMJUtilities(page, options, dataErrors, dataAccess, debug);
551
+ await this.exposeMJUtilities(page, options, dataErrors, unavailableUtilities, dataAccess, debug);
395
552
  if (debug) {
396
553
  console.log('📤 NODE: About to call page.evaluate with:');
397
554
  console.log(' - spec.name:', options.componentSpec.name);
@@ -427,11 +584,21 @@ export class ComponentRunner {
427
584
  }
428
585
  // Declare renderCheckInterval at the top scope for cleanup
429
586
  let renderCheckInterval;
587
+ const harnessWindow = window;
588
+ // A failure the harness can name is thrown with that name, so the catch below can
589
+ // report it under its own rule instead of the catch-all 'execution-error'.
590
+ // HarnessSetupError: the page itself is not ready, so the component never ran.
591
+ // ComponentLoadError: the component compiled but did not yield a usable root.
592
+ const namedError = (name, message) => {
593
+ const error = new Error(message);
594
+ error.name = name;
595
+ return error;
596
+ };
430
597
  try {
431
598
  // Access the real runtime classes
432
599
  const MJRuntime = window.MJReactRuntime;
433
600
  if (!MJRuntime) {
434
- throw new Error('React runtime not loaded');
601
+ throw namedError('HarnessSetupError', 'React runtime not loaded');
435
602
  }
436
603
  const { ComponentCompiler, ComponentRegistry, ComponentHierarchyRegistrar, ComponentManager, SetupStyles, ApplyStyleOverrides } = MJRuntime;
437
604
  if (debug) {
@@ -461,7 +628,7 @@ export class ComponentRunner {
461
628
  if (!window.React) {
462
629
  console.error('🔴 CRITICAL: React is NULL when creating runtimeContext!');
463
630
  console.error('Window keys:', Object.keys(window).filter(k => k.toLowerCase().includes('react')));
464
- throw new Error('React is not available in window context');
631
+ throw namedError('HarnessSetupError', 'React is not available in window context');
465
632
  }
466
633
  if (debug) {
467
634
  console.log('✅ React is available:', typeof window.React);
@@ -506,7 +673,7 @@ export class ComponentRunner {
506
673
  // Don't call createRuntimeUtilities() as it tries to create new Metadata() which fails
507
674
  const utilities = window.__mjUtilities;
508
675
  if (!utilities) {
509
- throw new Error('Utilities not found - exposeMJUtilities may have failed');
676
+ throw namedError('HarnessSetupError', 'Utilities not found - exposeMJUtilities may have failed');
510
677
  }
511
678
  // The test harness renders components in isolation without the MJExplorer
512
679
  // chrome, so there are no `--mj-*` theme tokens on the page to bridge from.
@@ -645,7 +812,25 @@ export class ComponentRunner {
645
812
  console.log('❌ Registration failed:', registrationResult.errors);
646
813
  }
647
814
  if (!registrationResult.success) {
648
- throw new Error('Component registration failed: ' + JSON.stringify(registrationResult.errors));
815
+ // One entry per failed component, naming it and the phase that failed, so
816
+ // unrelated failures are reported (and grouped by consumers) separately.
817
+ const failures = registrationResult.errors;
818
+ harnessWindow.__testHarnessRuntimeErrors = harnessWindow.__testHarnessRuntimeErrors || [];
819
+ for (const failure of failures) {
820
+ const who = failure.componentName ? `Component "${failure.componentName}"` : 'A component';
821
+ harnessWindow.__testHarnessRuntimeErrors.push({
822
+ message: `${who} failed during ${failure.phase || 'registration'}: ${failure.error || 'no error message was reported'}`,
823
+ type: 'registration-error',
824
+ phase: failure.phase,
825
+ source: 'runtime-wrapper'
826
+ });
827
+ }
828
+ harnessWindow.__testHarnessTestFailed = true;
829
+ return {
830
+ success: false,
831
+ error: `Component registration failed for ${failures.length} component(s)`,
832
+ componentCount: 0
833
+ };
649
834
  }
650
835
  if (debug) {
651
836
  console.log('📝 Registered components:', registrationResult.registeredComponents);
@@ -654,19 +839,19 @@ export class ComponentRunner {
654
839
  }
655
840
  // NEW: With ComponentManager, we already have the components from loadResult
656
841
  if (!loadResult) {
657
- throw new Error('Component loading failed - no result returned');
842
+ throw namedError('ComponentLoadError', 'Component loading failed - no result returned');
658
843
  }
659
844
  const RootComponentObject = loadResult.rootComponent;
660
845
  if (!RootComponentObject) {
661
846
  // Enhanced error message with debugging info
662
847
  console.error('Failed to load component:', spec.name);
663
848
  console.error('Load errors:', loadResult.errors);
664
- throw new Error('Root component not loaded: ' + spec.name);
849
+ throw namedError('ComponentLoadError', `Root component "${spec.name}" was not loaded`);
665
850
  }
666
851
  // Extract the React component from the ComponentObject
667
852
  const RootComponent = RootComponentObject.component;
668
853
  if (!RootComponent || typeof RootComponent !== 'function') {
669
- throw new Error('Component object does not contain a valid React component');
854
+ throw namedError('ComponentLoadError', `Component "${spec.name}" does not contain a valid React component`);
670
855
  }
671
856
  // Get all loaded components from the result
672
857
  // ComponentManager now returns unwrapped components directly
@@ -710,6 +895,11 @@ export class ComponentRunner {
710
895
  window.__testHarnessRenderCount = window.__testHarnessRenderCount || 0;
711
896
  // Wrap React.createElement to detect invalid element types
712
897
  const originalCreateElement = window.React?.createElement;
898
+ // React.memo, forwardRef, lazy and context providers/consumers are objects tagged
899
+ // with one of these symbols, and are valid element types. Library components are
900
+ // often built this way, so treating every object as invalid flagged working code.
901
+ const reactComponentObjectTags = new Set(['react.memo', 'react.forward_ref', 'react.lazy', 'react.provider', 'react.context', 'react.consumer']
902
+ .map(tag => Symbol.for(tag)));
713
903
  if (originalCreateElement) {
714
904
  window.React.createElement = function (type, props, ...children) {
715
905
  window.__testHarnessRenderCount++;
@@ -717,7 +907,8 @@ export class ComponentRunner {
717
907
  if (type !== null && type !== undefined) {
718
908
  const typeOf = typeof type;
719
909
  // Check for the common "object instead of component" error
720
- if (typeOf === 'object' && !window.React.isValidElement(type)) {
910
+ if (typeOf === 'object' && !window.React.isValidElement(type) &&
911
+ !reactComponentObjectTags.has(type.$$typeof)) {
721
912
  // Try to get a meaningful name for the object
722
913
  let objectInfo = 'unknown object';
723
914
  try {
@@ -965,14 +1156,16 @@ export class ComponentRunner {
965
1156
  console.error('🔴 BROWSER: Error stack:', error.stack);
966
1157
  console.error('🔴 BROWSER: Error type:', typeof error);
967
1158
  console.error('🔴 BROWSER: Error stringified:', JSON.stringify(error, null, 2));
968
- window.__testHarnessRuntimeErrors = window.__testHarnessRuntimeErrors || [];
969
- window.__testHarnessRuntimeErrors.push({
1159
+ const isSetupError = error?.name === 'HarnessSetupError';
1160
+ const isLoadError = error?.name === 'ComponentLoadError';
1161
+ harnessWindow.__testHarnessRuntimeErrors = harnessWindow.__testHarnessRuntimeErrors || [];
1162
+ harnessWindow.__testHarnessRuntimeErrors.push({
970
1163
  message: error.message || String(error),
971
1164
  stack: error.stack,
972
- type: 'execution-error',
973
- source: 'runtime-wrapper'
1165
+ type: isSetupError ? 'harness-setup-error' : isLoadError ? 'component-load-error' : 'execution-error',
1166
+ source: isSetupError ? 'test-harness' : 'runtime-wrapper'
974
1167
  });
975
- window.__testHarnessTestFailed = true;
1168
+ harnessWindow.__testHarnessTestFailed = true;
976
1169
  return {
977
1170
  success: false,
978
1171
  error: error.message || String(error)
@@ -1051,14 +1244,9 @@ export class ComponentRunner {
1051
1244
  renderCount = await page.evaluate(() => window.__testHarnessRenderCount || 0);
1052
1245
  // Collect all errors with source information
1053
1246
  const runtimeErrorsWithSource = await this.collectRuntimeErrors(page);
1054
- // Filter out JSX element type errors when adding to errors array
1247
+ // Low-volume JSX element-type errors stay out of the error count (see JSX_ERROR_FAILURE_THRESHOLD)
1055
1248
  errors.push(...runtimeErrorsWithSource
1056
- .filter(e => {
1057
- // Skip JSX element type errors from error count
1058
- const isJSXError = e.type === 'invalid-element-type' ||
1059
- e.type === 'undefined-component';
1060
- return !isJSXError;
1061
- })
1249
+ .filter(e => !ComponentRunner.isSuppressibleJSXError(e))
1062
1250
  .map(e => e.message)); // Extract messages for backward compat
1063
1251
  // Collect warnings (separate from errors)
1064
1252
  const collectedWarnings = await this.collectWarnings(page);
@@ -1067,19 +1255,7 @@ export class ComponentRunner {
1067
1255
  const asyncWaitTime = options.asyncErrorWaitTime || 1000;
1068
1256
  await page.waitForTimeout(asyncWaitTime);
1069
1257
  const asyncErrors = await this.collectRuntimeErrors(page);
1070
- // Only add new errors (excluding JSX element type errors)
1071
- asyncErrors.forEach(err => {
1072
- const isJSXError = err.type === 'invalid-element-type' ||
1073
- err.type === 'undefined-component';
1074
- if (!isJSXError && !errors.includes(err.message)) {
1075
- errors.push(err.message);
1076
- runtimeErrorsWithSource.push(err); // Keep the structured version too
1077
- }
1078
- else if (isJSXError && !runtimeErrorsWithSource.some(e => e.message === err.message)) {
1079
- // Still track JSX errors for logging but don't add to errors array
1080
- runtimeErrorsWithSource.push(err);
1081
- }
1082
- });
1258
+ asyncErrors.forEach(err => ComponentRunner.mergeAsyncRuntimeError(err, errors, runtimeErrorsWithSource));
1083
1259
  // Also check for new warnings
1084
1260
  const asyncWarnings = await this.collectWarnings(page);
1085
1261
  asyncWarnings.forEach(warn => {
@@ -1190,30 +1366,10 @@ export class ComponentRunner {
1190
1366
  executionResult.success;
1191
1367
  // Combine runtime errors with data errors
1192
1368
  const allErrors = [...errors, ...dataErrors];
1193
- // Map runtime errors with source info and specific rules
1194
- // Filter out JSX element type errors - they're too noisy and often false positives
1369
+ // Map runtime errors with source info and specific rules. JSX element-type errors are
1370
+ // graded by volume rather than dropped (see JSX_ERROR_FAILURE_THRESHOLD).
1195
1371
  const errorViolations = runtimeErrorsWithSource
1196
- .filter(e => {
1197
- // Skip JSX element type errors - still logged but not reported as violations
1198
- const isJSXError = e.rule === 'invalid-jsx-element' ||
1199
- e.rule === 'undefined-jsx-component' ||
1200
- e.type === 'invalid-element-type' ||
1201
- e.type === 'undefined-component';
1202
- if (isJSXError && debug) {
1203
- console.log('📝 JSX element error detected but not reported as violation:', e.message);
1204
- }
1205
- return !isJSXError;
1206
- })
1207
- .map(e => ({
1208
- message: e.message,
1209
- severity: 'critical',
1210
- rule: e.rule || 'runtime-error', // Use specific rule from collectRuntimeErrors
1211
- line: 0,
1212
- column: 0,
1213
- source: e.source,
1214
- stack: e.stack,
1215
- componentStack: e.componentStack,
1216
- }));
1372
+ .map(e => ComponentRunner.buildRuntimeErrorViolation(e, debug));
1217
1373
  // Add timeout error if detected
1218
1374
  if (hasTimeout) {
1219
1375
  errorViolations.push({
@@ -1275,6 +1431,7 @@ export class ComponentRunner {
1275
1431
  // Medium, low, and warning severity don't affect this metric
1276
1432
  const nonCrashCriticalHighErrors = allErrorViolations.filter(e => (e.severity === 'critical' || e.severity === 'high') && e.rule !== 'browser-crash');
1277
1433
  const codeExecutionSuccess = nonCrashCriticalHighErrors.length === 0;
1434
+ FinalizePendingDataAccess(dataAccess);
1278
1435
  // Surface capped data calls as their own warnings, so a downstream evaluator does not
1279
1436
  // read a deliberately truncated render as a defect.
1280
1437
  const capWarnings = dataAccess
@@ -1302,6 +1459,7 @@ export class ComponentRunner {
1302
1459
  column: 0
1303
1460
  })),
1304
1461
  ...capWarnings,
1462
+ ...ComponentRunner.buildUnavailableUtilityWarnings(unavailableUtilities),
1305
1463
  ],
1306
1464
  console: consoleLogs,
1307
1465
  screenshot,
@@ -1364,17 +1522,21 @@ export class ComponentRunner {
1364
1522
  // Medium, low, and warning severity don't affect this metric
1365
1523
  const nonCrashCriticalHighErrors = errorViolations.filter(e => (e.severity === 'critical' || e.severity === 'high') && e.rule !== 'browser-crash');
1366
1524
  const codeExecutionSuccess = nonCrashCriticalHighErrors.length === 0;
1525
+ FinalizePendingDataAccess(dataAccess);
1367
1526
  const result = {
1368
1527
  success: false,
1369
1528
  html: '',
1370
1529
  errors: errorViolations,
1371
- warnings: warnings.map(w => ({
1372
- message: w,
1373
- severity: 'low',
1374
- rule: 'warning',
1375
- line: 0,
1376
- column: 0
1377
- })),
1530
+ warnings: [
1531
+ ...warnings.map(w => ({
1532
+ message: w,
1533
+ severity: 'low',
1534
+ rule: 'warning',
1535
+ line: 0,
1536
+ column: 0
1537
+ })),
1538
+ ...ComponentRunner.buildUnavailableUtilityWarnings(unavailableUtilities),
1539
+ ],
1378
1540
  console: consoleLogs,
1379
1541
  executionTime: Date.now() - startTime,
1380
1542
  renderCount,
@@ -1877,6 +2039,12 @@ export class ComponentRunner {
1877
2039
  case 'execution-error':
1878
2040
  rule = 'execution-error';
1879
2041
  break;
2042
+ case 'harness-setup-error':
2043
+ rule = 'harness-setup-error';
2044
+ break;
2045
+ case 'component-load-error':
2046
+ rule = 'component-load-error';
2047
+ break;
1880
2048
  case 'runtime':
1881
2049
  rule = 'runtime-error';
1882
2050
  break;
@@ -1932,7 +2100,10 @@ export class ComponentRunner {
1932
2100
  : error.message;
1933
2101
  errors.push({
1934
2102
  ...error,
1935
- message
2103
+ message,
2104
+ baseMessage: error.message,
2105
+ // Carried as a field, not only in the message, so severity can threshold on volume
2106
+ occurrences: count
1936
2107
  });
1937
2108
  });
1938
2109
  return errors;
@@ -2129,7 +2300,7 @@ export class ComponentRunner {
2129
2300
  // Generate embeddings for all texts
2130
2301
  const embeddings = [];
2131
2302
  for (const text of texts) {
2132
- const result = await aiEngine.EmbedText(model, text);
2303
+ const result = await aiEngine.EmbedText(model, text, undefined, { inputType: params.inputType });
2133
2304
  if (result && result.vector) {
2134
2305
  embeddings.push(result.vector);
2135
2306
  }
@@ -2158,7 +2329,7 @@ export class ComponentRunner {
2158
2329
  /**
2159
2330
  * Expose MJ utilities to the browser context
2160
2331
  */
2161
- async exposeMJUtilities(page, options, dataErrors, dataAccess, debug = false) {
2332
+ async exposeMJUtilities(page, options, dataErrors, unavailableUtilities, dataAccess, debug = false) {
2162
2333
  // Row ceilings for this run, resolved once rather than per call.
2163
2334
  const viewCeiling = options.dataCaps?.runViewMaxRows ?? DEFAULT_RUN_VIEW_MAX_ROWS;
2164
2335
  const queryCeiling = options.dataCaps?.runQueryMaxRows ?? DEFAULT_RUN_QUERY_MAX_ROWS;
@@ -2252,13 +2423,16 @@ export class ComponentRunner {
2252
2423
  const viewTarget = ResolveViewTarget(params);
2253
2424
  const maxRows = ResolveDataCapMaxRows(params.MaxRows, viewCeiling);
2254
2425
  const startedAt = Date.now();
2426
+ const placeholder = StartDataAccessRecord(dataAccess, 'RunView', viewTarget, maxRows, startedAt);
2255
2427
  try {
2256
2428
  const result = await util.rv.RunView({ ...params, MaxRows: maxRows.applied }, options.contextUser);
2257
2429
  const record = BuildDataAccessRecord('RunView', viewTarget, result, maxRows, Date.now() - startedAt);
2258
- dataAccess.push(record);
2259
- // Debug logging for successful calls
2430
+ SettleDataAccessRecord(placeholder, record);
2431
+ // Debug logging for completed calls
2260
2432
  if (debug) {
2261
- console.log(`💾 RunView SUCCESS: View="${viewTarget.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
2433
+ console.log(record.error
2434
+ ? `❌ RunView FAILED: View="${viewTarget.target}" — ${record.error}`
2435
+ : `💾 RunView SUCCESS: View="${viewTarget.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
2262
2436
  if (params.ExtraFilter) {
2263
2437
  console.log(` Filter: ${params.ExtraFilter}`);
2264
2438
  }
@@ -2267,7 +2441,7 @@ export class ComponentRunner {
2267
2441
  }
2268
2442
  catch (error) {
2269
2443
  const errorMessage = error instanceof Error ? error.message : String(error);
2270
- dataAccess.push({
2444
+ SettleDataAccessRecord(placeholder, {
2271
2445
  kind: 'RunView', target: viewTarget.target, identity: viewTarget.identity, rowsReturned: 0, capped: false,
2272
2446
  appliedMaxRows: maxRows.applied, requestedMaxRows: maxRows.requested,
2273
2447
  durationMs: Date.now() - startedAt, error: errorMessage,
@@ -2289,18 +2463,21 @@ export class ComponentRunner {
2289
2463
  await page.exposeFunction('__mjRunViews', async (params) => {
2290
2464
  const perCallMaxRows = params.map(p => ResolveDataCapMaxRows(p.MaxRows, viewCeiling));
2291
2465
  const startedAt = Date.now();
2466
+ const placeholders = params.map((p, i) => StartDataAccessRecord(dataAccess, 'RunViews', ResolveViewTarget(p), perCallMaxRows[i], startedAt));
2292
2467
  try {
2293
2468
  const results = await util.rv.RunViews(params.map((p, i) => ({ ...p, MaxRows: perCallMaxRows[i].applied })), options.contextUser);
2294
2469
  // One record per view in the batch — a batch that partially returns data is a
2295
2470
  // meaningfully different signal from one that returns none.
2296
2471
  const elapsed = Date.now() - startedAt;
2297
2472
  const records = params.map((p, i) => BuildDataAccessRecord('RunViews', ResolveViewTarget(p), results[i], perCallMaxRows[i], elapsed));
2298
- records.forEach(r => dataAccess.push(r));
2299
- // Debug logging for successful calls
2473
+ records.forEach((r, i) => SettleDataAccessRecord(placeholders[i], r));
2474
+ // Debug logging for completed calls
2300
2475
  if (debug) {
2301
- console.log(`💾 RunViews SUCCESS: ${params.length} queries executed`);
2476
+ console.log(`💾 RunViews completed: ${params.length} queries executed`);
2302
2477
  records.forEach((r, i) => {
2303
- console.log(` [${i + 1}] Entity="${r.target}" Rows=${r.rowsReturned}${DescribeDataCap(r)}`);
2478
+ console.log(r.error
2479
+ ? ` [${i + 1}] Entity="${r.target}" FAILED — ${r.error}`
2480
+ : ` [${i + 1}] Entity="${r.target}" Rows=${r.rowsReturned}${DescribeDataCap(r)}`);
2304
2481
  });
2305
2482
  }
2306
2483
  return results;
@@ -2309,7 +2486,7 @@ export class ComponentRunner {
2309
2486
  const errorMessage = error instanceof Error ? error.message : String(error);
2310
2487
  const entities = params.map(p => DescribeViewTarget(p)).join(', ');
2311
2488
  // RunViews fails as a batch, so every view in it failed.
2312
- params.forEach((p, i) => dataAccess.push({
2489
+ params.forEach((p, i) => SettleDataAccessRecord(placeholders[i], {
2313
2490
  kind: 'RunViews', ...ResolveViewTarget(p), rowsReturned: 0, capped: false,
2314
2491
  appliedMaxRows: perCallMaxRows[i].applied, requestedMaxRows: perCallMaxRows[i].requested,
2315
2492
  durationMs: Date.now() - startedAt, error: errorMessage,
@@ -2332,13 +2509,16 @@ export class ComponentRunner {
2332
2509
  const queryIdentifier = ResolveQueryTarget(params);
2333
2510
  const maxRows = ResolveDataCapMaxRows(params.MaxRows, queryCeiling);
2334
2511
  const startedAt = Date.now();
2512
+ const placeholder = StartDataAccessRecord(dataAccess, 'RunQuery', queryIdentifier, maxRows, startedAt);
2335
2513
  try {
2336
2514
  const result = await util.rq.RunQuery({ ...params, MaxRows: maxRows.applied }, options.contextUser);
2337
2515
  const record = BuildDataAccessRecord('RunQuery', queryIdentifier, result, maxRows, Date.now() - startedAt);
2338
- dataAccess.push(record);
2339
- // Debug logging for successful calls
2516
+ SettleDataAccessRecord(placeholder, record);
2517
+ // Debug logging for completed calls
2340
2518
  if (debug) {
2341
- console.log(`💾 RunQuery SUCCESS: Query="${queryIdentifier.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
2519
+ console.log(record.error
2520
+ ? `❌ RunQuery FAILED: Query="${queryIdentifier.target}" — ${record.error}`
2521
+ : `💾 RunQuery SUCCESS: Query="${queryIdentifier.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
2342
2522
  if (params.Parameters && Object.keys(params.Parameters).length > 0) {
2343
2523
  console.log(` Parameters:`, params.Parameters);
2344
2524
  }
@@ -2347,7 +2527,7 @@ export class ComponentRunner {
2347
2527
  }
2348
2528
  catch (error) {
2349
2529
  const errorMessage = error instanceof Error ? error.message : String(error);
2350
- dataAccess.push({
2530
+ SettleDataAccessRecord(placeholder, {
2351
2531
  kind: 'RunQuery', target: queryIdentifier.target, identity: queryIdentifier.identity, rowsReturned: 0, capped: false,
2352
2532
  appliedMaxRows: maxRows.applied, requestedMaxRows: maxRows.requested,
2353
2533
  durationMs: Date.now() - startedAt, error: errorMessage,
@@ -2368,10 +2548,11 @@ export class ComponentRunner {
2368
2548
  });
2369
2549
  // Expose AI tools
2370
2550
  await page.exposeFunction('__mjExecutePrompt', async (params) => {
2551
+ if (typeof util.ai?.ExecutePrompt !== 'function') {
2552
+ unavailableUtilities.add('utilities.ai.ExecutePrompt');
2553
+ return { success: false, result: 'AI tools are not available in the test harness', modelName: '' };
2554
+ }
2371
2555
  try {
2372
- if (!util.ai) {
2373
- throw new Error('AI tools not available');
2374
- }
2375
2556
  // Add contextUser to params if not provided
2376
2557
  const paramsWithUser = { ...params, contextUser: options.contextUser };
2377
2558
  const result = await util.ai.ExecutePrompt(paramsWithUser);
@@ -2394,10 +2575,11 @@ export class ComponentRunner {
2394
2575
  }
2395
2576
  });
2396
2577
  await page.exposeFunction('__mjEmbedText', async (params) => {
2578
+ if (typeof util.ai?.EmbedText !== 'function') {
2579
+ unavailableUtilities.add('utilities.ai.EmbedText');
2580
+ throw new Error('AI tools are not available in the test harness');
2581
+ }
2397
2582
  try {
2398
- if (!util.ai) {
2399
- throw new Error('AI tools not available');
2400
- }
2401
2583
  // Add contextUser to params if not provided
2402
2584
  const paramsWithUser = { ...params, contextUser: options.contextUser };
2403
2585
  const result = await util.ai.EmbedText(paramsWithUser);