@memberjunction/react-test-harness 6.2.0-edge.5 → 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.
- package/dist/lib/assertion-helpers.d.ts.map +1 -1
- package/dist/lib/assertion-helpers.js +22 -3
- package/dist/lib/assertion-helpers.js.map +1 -1
- package/dist/lib/component-runner.d.ts +68 -0
- package/dist/lib/component-runner.d.ts.map +1 -1
- package/dist/lib/component-runner.js +269 -87
- package/dist/lib/component-runner.js.map +1 -1
- package/package.json +15 -15
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
969
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
.
|
|
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:
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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
|
-
|
|
2259
|
-
// Debug logging for
|
|
2430
|
+
SettleDataAccessRecord(placeholder, record);
|
|
2431
|
+
// Debug logging for completed calls
|
|
2260
2432
|
if (debug) {
|
|
2261
|
-
console.log(
|
|
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
|
-
|
|
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 =>
|
|
2299
|
-
// Debug logging for
|
|
2473
|
+
records.forEach((r, i) => SettleDataAccessRecord(placeholders[i], r));
|
|
2474
|
+
// Debug logging for completed calls
|
|
2300
2475
|
if (debug) {
|
|
2301
|
-
console.log(`💾 RunViews
|
|
2476
|
+
console.log(`💾 RunViews completed: ${params.length} queries executed`);
|
|
2302
2477
|
records.forEach((r, i) => {
|
|
2303
|
-
console.log(
|
|
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) =>
|
|
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
|
-
|
|
2339
|
-
// Debug logging for
|
|
2516
|
+
SettleDataAccessRecord(placeholder, record);
|
|
2517
|
+
// Debug logging for completed calls
|
|
2340
2518
|
if (debug) {
|
|
2341
|
-
console.log(
|
|
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
|
-
|
|
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);
|