@memberjunction/react-test-harness 6.2.0-edge.0 → 6.2.0-edge.2
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/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/lib/assertion-helpers.d.ts +49 -0
- package/dist/lib/assertion-helpers.d.ts.map +1 -1
- package/dist/lib/assertion-helpers.js +85 -25
- package/dist/lib/assertion-helpers.js.map +1 -1
- package/dist/lib/browser-context.d.ts +22 -0
- package/dist/lib/browser-context.d.ts.map +1 -1
- package/dist/lib/browser-context.js +58 -18
- package/dist/lib/browser-context.js.map +1 -1
- package/dist/lib/component-runner.d.ts +192 -0
- package/dist/lib/component-runner.d.ts.map +1 -1
- package/dist/lib/component-runner.js +364 -49
- package/dist/lib/component-runner.js.map +1 -1
- package/dist/lib/linter-test-tool.d.ts +12 -2
- package/dist/lib/linter-test-tool.d.ts.map +1 -1
- package/dist/lib/linter-test-tool.js +39 -19
- package/dist/lib/linter-test-tool.js.map +1 -1
- package/dist/lib/test-harness.d.ts +50 -0
- package/dist/lib/test-harness.d.ts.map +1 -1
- package/dist/lib/test-harness.js +68 -16
- package/dist/lib/test-harness.js.map +1 -1
- package/package.json +15 -15
|
@@ -7,6 +7,79 @@ import { ComponentLinter } from '@memberjunction/react-linter';
|
|
|
7
7
|
import { ComponentMetadataEngine } from '@memberjunction/core-entities';
|
|
8
8
|
import { SimpleVectorService } from '@memberjunction/ai-vectors-memory';
|
|
9
9
|
import { AIEngine } from '@memberjunction/aiengine';
|
|
10
|
+
/**
|
|
11
|
+
* Field properties carried into the browser's mock metadata.
|
|
12
|
+
*
|
|
13
|
+
* `EntityFieldInfo` exposes ~77 own properties at runtime. The list below is the subset the
|
|
14
|
+
* in-page React runtime actually reads — derived from its bundle, not assumed — plus a few
|
|
15
|
+
* cheap scalars component code plausibly touches (`Sequence`, `AllowsNull`, `DefaultInView`,
|
|
16
|
+
* `Description`). Everything omitted is a server-side concern: search predicates, generated
|
|
17
|
+
* form layout, schema auto-update settings. None of it is reachable from component code.
|
|
18
|
+
*
|
|
19
|
+
* Note that several of the most frequently read names — `CodeName`, `TSType`,
|
|
20
|
+
* `DisplayNameOrName`, `NeedsQuotes` — are prototype GETTERS. Serialization to the browser
|
|
21
|
+
* has never carried prototype members, so they are already `undefined` in the page today;
|
|
22
|
+
* they are listed here only so that a build which promotes them to own properties keeps
|
|
23
|
+
* working.
|
|
24
|
+
*/
|
|
25
|
+
const BROWSER_FIELD_PROPERTIES = new Set([
|
|
26
|
+
'ID', 'EntityID', 'Name', 'DisplayName', 'Description', 'Sequence',
|
|
27
|
+
'Type', 'Length', 'MaxLength', 'Precision', 'Scale', 'AllowsNull', 'DefaultValue',
|
|
28
|
+
'IsPrimaryKey', 'IsUnique', 'IsVirtual', 'IsNameField', 'DefaultInView',
|
|
29
|
+
'ExtendedType', 'ValueListType', 'EntityFieldValues',
|
|
30
|
+
'RelatedEntityID', 'RelatedEntity', 'RelatedEntityFieldName',
|
|
31
|
+
'CodeName', 'TSType',
|
|
32
|
+
]);
|
|
33
|
+
/**
|
|
34
|
+
* Builds the entity metadata handed to the browser context.
|
|
35
|
+
*
|
|
36
|
+
* Replaces a `JSON.parse(JSON.stringify(entities))` deep clone, which was there to strip
|
|
37
|
+
* functions before the value reached `page.evaluate`. Playwright already does that — it
|
|
38
|
+
* serializes the argument itself and takes only own enumerable properties — so the round
|
|
39
|
+
* trip was redundant, and strictly worse in one respect: `JSON.stringify` throws on a
|
|
40
|
+
* circular graph, and the caller's catch then yields an EMPTY entity list silently, where
|
|
41
|
+
* Playwright would have handled the cycle by reference.
|
|
42
|
+
*
|
|
43
|
+
* The projection matters more than the clone removal. `page.evaluate` does not JSON-encode
|
|
44
|
+
* its argument; it walks the graph building a tagged protocol representation, so every
|
|
45
|
+
* property becomes additional objects and the cost scales with NODE COUNT rather than byte
|
|
46
|
+
* size. Field objects dominate that count: an entity with 1,043 fields at ~77 properties
|
|
47
|
+
* each contributes ~80,000 property slots on its own.
|
|
48
|
+
*
|
|
49
|
+
* Measured against a client with 363 entities and 32,209 fields: the injection blocked the
|
|
50
|
+
* event loop for ~18s and allocated ~2.6GB, tripping the caller's memory watchdog before the
|
|
51
|
+
* component rendered. With the caller also narrowing to the 7 entities a component declares,
|
|
52
|
+
* projecting fields took that injection from 1,059ms to 321ms — 23 of 77 properties kept,
|
|
53
|
+
* 75% smaller — and halved total component-test time.
|
|
54
|
+
*
|
|
55
|
+
* Entity-level properties are preserved as-is; only `Fields` is projected. The input is never
|
|
56
|
+
* mutated.
|
|
57
|
+
*/
|
|
58
|
+
function projectEntitiesForBrowser(entities) {
|
|
59
|
+
if (!Array.isArray(entities) || entities.length === 0) {
|
|
60
|
+
return entities;
|
|
61
|
+
}
|
|
62
|
+
return entities.map(entity => {
|
|
63
|
+
if (!entity || typeof entity !== 'object')
|
|
64
|
+
return entity;
|
|
65
|
+
const source = entity;
|
|
66
|
+
const projected = { ...source };
|
|
67
|
+
const fields = source.Fields;
|
|
68
|
+
if (Array.isArray(fields)) {
|
|
69
|
+
projected.Fields = fields.map(field => {
|
|
70
|
+
if (!field || typeof field !== 'object')
|
|
71
|
+
return field;
|
|
72
|
+
const slim = {};
|
|
73
|
+
for (const [key, value] of Object.entries(field)) {
|
|
74
|
+
if (BROWSER_FIELD_PROPERTIES.has(key))
|
|
75
|
+
slim[key] = value;
|
|
76
|
+
}
|
|
77
|
+
return slim;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
return projected;
|
|
81
|
+
});
|
|
82
|
+
}
|
|
10
83
|
/**
|
|
11
84
|
* Pre-resolve a component spec for browser execution
|
|
12
85
|
* Converts registry components to embedded format with all code included
|
|
@@ -28,6 +101,141 @@ async function preResolveComponentSpec(spec, contextUser) {
|
|
|
28
101
|
// For other types, return as-is
|
|
29
102
|
return spec;
|
|
30
103
|
}
|
|
104
|
+
/** Default row ceiling for `RunView` / `RunViews`. @see DataCapOptions */
|
|
105
|
+
export const DEFAULT_RUN_VIEW_MAX_ROWS = 1000;
|
|
106
|
+
/** Default row ceiling for `RunQuery`. @see DataCapOptions */
|
|
107
|
+
export const DEFAULT_RUN_QUERY_MAX_ROWS = 1000;
|
|
108
|
+
/**
|
|
109
|
+
* Resolves the `MaxRows` to send for one data call.
|
|
110
|
+
*
|
|
111
|
+
* Clamps downward only: a component asking for 50 rows still gets 50, while one asking for
|
|
112
|
+
* nothing or for more than the ceiling gets the ceiling. A nonsensical request (zero,
|
|
113
|
+
* negative, non-numeric) is treated as absent.
|
|
114
|
+
*
|
|
115
|
+
* Returns the requested value alongside the applied one, so a capped call can report what
|
|
116
|
+
* the component wanted.
|
|
117
|
+
*
|
|
118
|
+
* Pure (no I/O) so it can be unit-tested without launching a browser.
|
|
119
|
+
*/
|
|
120
|
+
export function ResolveDataCapMaxRows(requested, ceiling) {
|
|
121
|
+
const validRequest = typeof requested === 'number' && Number.isFinite(requested) && requested > 0 ? requested : undefined;
|
|
122
|
+
return {
|
|
123
|
+
applied: validRequest === undefined ? ceiling : Math.min(validRequest, ceiling),
|
|
124
|
+
requested: validRequest,
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Builds a {@link DataAccessRecord} from a completed provider call.
|
|
129
|
+
*
|
|
130
|
+
* `TotalRowCount` is the provider's count of rows the query would have produced without
|
|
131
|
+
* paging, making it the denominator for "capped at N of M". Providers that omit it, or
|
|
132
|
+
* leave it equal to `RowCount`, yield `capped: false` rather than a guess.
|
|
133
|
+
*
|
|
134
|
+
* Pure (no I/O) so it can be unit-tested without launching a browser.
|
|
135
|
+
*/
|
|
136
|
+
export function BuildDataAccessRecord(kind, resolved, result, maxRows, durationMs) {
|
|
137
|
+
const rowsReturned = result?.Results?.length ?? 0;
|
|
138
|
+
const total = typeof result?.TotalRowCount === 'number' ? result.TotalRowCount : undefined;
|
|
139
|
+
return {
|
|
140
|
+
kind,
|
|
141
|
+
target: resolved.target,
|
|
142
|
+
identity: resolved.identity,
|
|
143
|
+
rowsReturned,
|
|
144
|
+
totalRowCount: total,
|
|
145
|
+
capped: total !== undefined && total > rowsReturned,
|
|
146
|
+
appliedMaxRows: maxRows.applied,
|
|
147
|
+
requestedMaxRows: maxRows.requested,
|
|
148
|
+
durationMs,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** Debug-log suffix naming the cap, e.g. ` (capped at 1000 of 205802)`. Empty when uncapped. */
|
|
152
|
+
export function DescribeDataCap(record) {
|
|
153
|
+
return record.capped ? ` (capped at ${record.rowsReturned} of ${record.totalRowCount})` : '';
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Normalizes a category path and appends a leaf name, e.g. (`/MJ/AI/Agents/`, `Foo`) →
|
|
157
|
+
* `/MJ/AI/Agents/Foo`. Tolerates missing or doubled slashes on either side.
|
|
158
|
+
*/
|
|
159
|
+
function joinCategoryPath(categoryPath, name) {
|
|
160
|
+
const trimmed = categoryPath.replace(/^\/+|\/+$/g, '');
|
|
161
|
+
return trimmed.length === 0 ? `/${name}` : `/${trimmed}/${name}`;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Names the query a `RunQuery` call resolved to, as both a display string and a structured
|
|
165
|
+
* {@link DataAccessIdentity}.
|
|
166
|
+
*
|
|
167
|
+
* A query name is not unique on its own — name and category path identify a query together
|
|
168
|
+
* — so the display form is category-qualified.
|
|
169
|
+
*
|
|
170
|
+
* Precedence mirrors how `RunQuery` itself resolves: `QueryID` wins and `QueryName` is
|
|
171
|
+
* ignored when both are supplied, so the ID is reported as the identity and the supplied
|
|
172
|
+
* name is kept only as a label. `identity` retains every part the caller passed, so a
|
|
173
|
+
* correlation by name and path still works when an ID drove the resolution.
|
|
174
|
+
*/
|
|
175
|
+
export function ResolveQueryTarget(params) {
|
|
176
|
+
// `identity` records what the caller supplied, so a correlation against declared
|
|
177
|
+
// requirements can match on name+categoryPath even when an ID drove the resolution.
|
|
178
|
+
const identity = {};
|
|
179
|
+
if (params.QueryID)
|
|
180
|
+
identity.queryId = params.QueryID;
|
|
181
|
+
if (params.QueryName)
|
|
182
|
+
identity.queryName = params.QueryName;
|
|
183
|
+
if (params.CategoryPath)
|
|
184
|
+
identity.categoryPath = params.CategoryPath;
|
|
185
|
+
if (params.QueryID) {
|
|
186
|
+
return {
|
|
187
|
+
target: params.QueryName
|
|
188
|
+
? `${params.QueryName} (resolved by QueryID ${params.QueryID})`
|
|
189
|
+
: `QueryID ${params.QueryID}`,
|
|
190
|
+
identity,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
if (params.QueryName) {
|
|
194
|
+
if (params.CategoryPath) {
|
|
195
|
+
return { target: joinCategoryPath(params.CategoryPath, params.QueryName), identity };
|
|
196
|
+
}
|
|
197
|
+
if (params.CategoryID) {
|
|
198
|
+
return { target: `${params.QueryName} (CategoryID ${params.CategoryID})`, identity };
|
|
199
|
+
}
|
|
200
|
+
// Neither qualifier given: the name is all the caller supplied, so it is all we can
|
|
201
|
+
// report — flagged, because this is the ambiguous case rather than a resolved identity.
|
|
202
|
+
return { target: `${params.QueryName} (uncategorized)`, identity };
|
|
203
|
+
}
|
|
204
|
+
return { target: 'unknown', identity };
|
|
205
|
+
}
|
|
206
|
+
/** Display-only form of {@link ResolveQueryTarget}. */
|
|
207
|
+
export function DescribeQueryTarget(params) {
|
|
208
|
+
return ResolveQueryTarget(params).target;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Names the view a `RunView` call resolved to, as both a display string and a structured
|
|
212
|
+
* {@link DataAccessIdentity}.
|
|
213
|
+
*
|
|
214
|
+
* Follows `RunViewParams`' own precedence — `ViewEntity` → `ViewID` → `ViewName` →
|
|
215
|
+
* `EntityName` — where each earlier field causes the later ones to be ignored. Reporting
|
|
216
|
+
* `EntityName` regardless would name something the call did not resolve by.
|
|
217
|
+
*/
|
|
218
|
+
export function ResolveViewTarget(params) {
|
|
219
|
+
const identity = {};
|
|
220
|
+
if (params.EntityName)
|
|
221
|
+
identity.entityName = params.EntityName;
|
|
222
|
+
if (params.ViewID)
|
|
223
|
+
identity.viewId = params.ViewID;
|
|
224
|
+
if (params.ViewName)
|
|
225
|
+
identity.viewName = params.ViewName;
|
|
226
|
+
const entitySuffix = params.EntityName ? ` on ${params.EntityName}` : '';
|
|
227
|
+
if (params.ViewEntity)
|
|
228
|
+
return { target: `saved view via ViewEntity${entitySuffix}`, identity };
|
|
229
|
+
if (params.ViewID)
|
|
230
|
+
return { target: `ViewID ${params.ViewID}${entitySuffix}`, identity };
|
|
231
|
+
if (params.ViewName)
|
|
232
|
+
return { target: `view "${params.ViewName}"${entitySuffix}`, identity };
|
|
233
|
+
return { target: params.EntityName || 'unknown', identity };
|
|
234
|
+
}
|
|
235
|
+
/** Display-only form of {@link ResolveViewTarget}. */
|
|
236
|
+
export function DescribeViewTarget(params) {
|
|
237
|
+
return ResolveViewTarget(params).target;
|
|
238
|
+
}
|
|
31
239
|
/**
|
|
32
240
|
* ComponentRunner that uses the actual React runtime via Playwright UMD bundle
|
|
33
241
|
*/
|
|
@@ -94,7 +302,7 @@ export class ComponentRunner {
|
|
|
94
302
|
/**
|
|
95
303
|
* Lint component code before execution
|
|
96
304
|
*/
|
|
97
|
-
async
|
|
305
|
+
async LintComponent(componentCode, componentName, componentSpec, isRootComponent, contextUser, options) {
|
|
98
306
|
const lintResult = await ComponentLinter.lintComponent(componentCode, componentName, componentSpec, isRootComponent, contextUser, false, // debugMode
|
|
99
307
|
options);
|
|
100
308
|
const hasErrors = lintResult.violations.some(v => v.severity === 'critical' || v.severity === 'high');
|
|
@@ -103,12 +311,17 @@ export class ComponentRunner {
|
|
|
103
311
|
hasErrors
|
|
104
312
|
};
|
|
105
313
|
}
|
|
106
|
-
|
|
314
|
+
/** @deprecated Use {@link LintComponent}. */
|
|
315
|
+
async lintComponent(componentCode, componentName, componentSpec, isRootComponent, contextUser, options) {
|
|
316
|
+
return this.LintComponent(componentCode, componentName, componentSpec, isRootComponent, contextUser, options);
|
|
317
|
+
}
|
|
318
|
+
async ExecuteComponent(options) {
|
|
107
319
|
const startTime = Date.now();
|
|
108
320
|
const errors = [];
|
|
109
321
|
const warnings = [];
|
|
110
322
|
const consoleLogs = [];
|
|
111
323
|
const dataErrors = []; // Track data access errors from RunView/RunQuery
|
|
324
|
+
const dataAccess = []; // Track every data call: target, rows, cap state
|
|
112
325
|
let renderCount = 0;
|
|
113
326
|
const debug = options.debug !== false; // Default to true for debugging
|
|
114
327
|
const globalTimeout = options.timeout || 30000; // Default 30 seconds total timeout
|
|
@@ -178,7 +391,7 @@ export class ComponentRunner {
|
|
|
178
391
|
// Set up console logging
|
|
179
392
|
this.setupConsoleLogging(page, consoleLogs, warnings);
|
|
180
393
|
// Expose MJ utilities to the page
|
|
181
|
-
await this.exposeMJUtilities(page, options, dataErrors, debug);
|
|
394
|
+
await this.exposeMJUtilities(page, options, dataErrors, dataAccess, debug);
|
|
182
395
|
if (debug) {
|
|
183
396
|
console.log('📤 NODE: About to call page.evaluate with:');
|
|
184
397
|
console.log(' - spec.name:', options.componentSpec.name);
|
|
@@ -682,6 +895,10 @@ export class ComponentRunner {
|
|
|
682
895
|
super(props);
|
|
683
896
|
this.state = { hasError: false, error: null };
|
|
684
897
|
}
|
|
898
|
+
// React reads this static into a local and calls it UNBOUND
|
|
899
|
+
// (`var f = fiber.type.getDerivedStateFromError; f(error)`), so a stub
|
|
900
|
+
// that forwards through `this` throws before the capture below can run —
|
|
901
|
+
// the harness would then miss the user component's error entirely.
|
|
685
902
|
static getDerivedStateFromError(error) {
|
|
686
903
|
// Capture the actual error message IMMEDIATELY
|
|
687
904
|
window.__testHarnessRuntimeErrors = window.__testHarnessRuntimeErrors || [];
|
|
@@ -905,16 +1122,27 @@ export class ComponentRunner {
|
|
|
905
1122
|
let html;
|
|
906
1123
|
let screenshot;
|
|
907
1124
|
try {
|
|
908
|
-
//
|
|
909
|
-
|
|
910
|
-
//
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
1125
|
+
// Backstop only — the row ceilings on the data bridges (see DataCapOptions) stop a
|
|
1126
|
+
// runaway result set before it reaches the DOM. Deliberately far below Node's ~536MB
|
|
1127
|
+
// string limit: the harness shares a heap with the host process, so by the time a
|
|
1128
|
+
// page serializes to tens of megabytes the run is pathological whether or not the
|
|
1129
|
+
// string itself fits.
|
|
1130
|
+
const maxSafeSize = 25 * 1024 * 1024; // 25MB
|
|
1131
|
+
// Measure and slice IN THE PAGE. `page.content()` materialises the entire document
|
|
1132
|
+
// as a Node string first, so the ceiling below used to be enforced only after the
|
|
1133
|
+
// allocation it exists to prevent — and beyond Node's string limit the transfer
|
|
1134
|
+
// throws before any check can run. Extracting a bounded slice keeps this side
|
|
1135
|
+
// bounded by the cap no matter how large the document is; the true length still
|
|
1136
|
+
// crosses, so an oversized render is just as visible in the error.
|
|
1137
|
+
const extracted = await page.evaluate((limit) => {
|
|
1138
|
+
const full = document.documentElement.outerHTML;
|
|
1139
|
+
return { length: full.length, html: full.length > limit ? full.slice(0, limit) : full };
|
|
1140
|
+
}, maxSafeSize);
|
|
1141
|
+
html = extracted.html;
|
|
1142
|
+
if (extracted.length > maxSafeSize) {
|
|
1143
|
+
console.warn(`⚠️ HTML content is very large (${Math.round(extracted.length / 1024 / 1024)}MB). Truncating to prevent crashes.`);
|
|
1144
|
+
html += '\n<!-- TRUNCATED: Content exceeded safe size limit -->';
|
|
1145
|
+
errors.push(`HTML content too large (${Math.round(extracted.length / 1024 / 1024)}MB). This may indicate excessive data or a render issue.`);
|
|
918
1146
|
}
|
|
919
1147
|
}
|
|
920
1148
|
catch (error) {
|
|
@@ -1047,23 +1275,40 @@ export class ComponentRunner {
|
|
|
1047
1275
|
// Medium, low, and warning severity don't affect this metric
|
|
1048
1276
|
const nonCrashCriticalHighErrors = allErrorViolations.filter(e => (e.severity === 'critical' || e.severity === 'high') && e.rule !== 'browser-crash');
|
|
1049
1277
|
const codeExecutionSuccess = nonCrashCriticalHighErrors.length === 0;
|
|
1278
|
+
// Surface capped data calls as their own warnings, so a downstream evaluator does not
|
|
1279
|
+
// read a deliberately truncated render as a defect.
|
|
1280
|
+
const capWarnings = dataAccess
|
|
1281
|
+
.filter(d => d.capped)
|
|
1282
|
+
.map(d => ({
|
|
1283
|
+
message: `Data capped: ${d.kind} "${d.target}" returned ${d.rowsReturned} of ${d.totalRowCount} rows ` +
|
|
1284
|
+
`(harness ceiling ${d.appliedMaxRows}). Values aggregated in the component from this ` +
|
|
1285
|
+
`result are computed over partial data.`,
|
|
1286
|
+
severity: 'medium',
|
|
1287
|
+
rule: 'data-row-cap',
|
|
1288
|
+
line: 0,
|
|
1289
|
+
column: 0
|
|
1290
|
+
}));
|
|
1050
1291
|
const result = {
|
|
1051
1292
|
success: success && dataErrors.length === 0 && criticalWarningViolations.length === 0, // Fail on critical warnings too
|
|
1052
1293
|
html,
|
|
1053
1294
|
errors: allErrorViolations,
|
|
1054
1295
|
browserCrash: hasBrowserCrash,
|
|
1055
|
-
warnings:
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1296
|
+
warnings: [
|
|
1297
|
+
...regularWarnings.map(w => ({
|
|
1298
|
+
message: w,
|
|
1299
|
+
severity: 'low',
|
|
1300
|
+
rule: 'warning',
|
|
1301
|
+
line: 0,
|
|
1302
|
+
column: 0
|
|
1303
|
+
})),
|
|
1304
|
+
...capWarnings,
|
|
1305
|
+
],
|
|
1062
1306
|
console: consoleLogs,
|
|
1063
1307
|
screenshot,
|
|
1064
1308
|
executionTime: Date.now() - startTime,
|
|
1065
1309
|
renderCount,
|
|
1066
1310
|
codeExecutionSuccess,
|
|
1311
|
+
dataAccess,
|
|
1067
1312
|
sourceMaps: this._lastSourceMaps,
|
|
1068
1313
|
};
|
|
1069
1314
|
if (debug) {
|
|
@@ -1134,7 +1379,9 @@ export class ComponentRunner {
|
|
|
1134
1379
|
executionTime: Date.now() - startTime,
|
|
1135
1380
|
renderCount,
|
|
1136
1381
|
browserCrash: hasBrowserCrash,
|
|
1137
|
-
codeExecutionSuccess
|
|
1382
|
+
codeExecutionSuccess,
|
|
1383
|
+
// Data calls made before the failure still describe what the component reached for.
|
|
1384
|
+
dataAccess,
|
|
1138
1385
|
};
|
|
1139
1386
|
if (debug) {
|
|
1140
1387
|
if (hasBrowserCrash && codeExecutionSuccess) {
|
|
@@ -1163,6 +1410,10 @@ export class ComponentRunner {
|
|
|
1163
1410
|
}
|
|
1164
1411
|
}
|
|
1165
1412
|
}
|
|
1413
|
+
/** @deprecated Use {@link ExecuteComponent}. */
|
|
1414
|
+
async executeComponent(options) {
|
|
1415
|
+
return this.ExecuteComponent(options);
|
|
1416
|
+
}
|
|
1166
1417
|
/**
|
|
1167
1418
|
* Load core runtime libraries into the page (React, ReactDOM, Babel, MJRuntime)
|
|
1168
1419
|
* Component-specific libraries are loaded by the runtime's ComponentCompiler
|
|
@@ -1707,20 +1958,47 @@ export class ComponentRunner {
|
|
|
1707
1958
|
* Set up console logging
|
|
1708
1959
|
*/
|
|
1709
1960
|
setupConsoleLogging(page, consoleLogs, warnings) {
|
|
1961
|
+
// Ceilings, not trimming. Normal runs land far below both — a passing component test was
|
|
1962
|
+
// observed at ~1,800 messages and 0.7MB — so these exist only to stop a component stuck
|
|
1963
|
+
// in a render or error loop from growing this array without bound. The harness shares a
|
|
1964
|
+
// heap with its host process, so an unbounded collector here is the host's problem too.
|
|
1965
|
+
const MAX_CONSOLE_MESSAGES = 5000;
|
|
1966
|
+
const MAX_MESSAGE_CHARS = 32 * 1024;
|
|
1967
|
+
// Membership test, not a linear scan: `warnings.includes` made de-duplication O(n^2) in
|
|
1968
|
+
// the number of distinct warnings, on the event loop everything else shares. `warnings`
|
|
1969
|
+
// stays an array because callers index it.
|
|
1970
|
+
const seenWarnings = new Set(warnings);
|
|
1971
|
+
let overflowed = false;
|
|
1972
|
+
const record = (type, text) => {
|
|
1973
|
+
const clipped = typeof text === 'string' && text.length > MAX_MESSAGE_CHARS
|
|
1974
|
+
? `${text.slice(0, MAX_MESSAGE_CHARS)}… [truncated, ${text.length} chars]`
|
|
1975
|
+
: text;
|
|
1976
|
+
if (consoleLogs.length < MAX_CONSOLE_MESSAGES) {
|
|
1977
|
+
consoleLogs.push({ type, text: clipped });
|
|
1978
|
+
}
|
|
1979
|
+
else if (!overflowed) {
|
|
1980
|
+
// Record the overflow once, so a truncated run is never mistaken for a quiet one.
|
|
1981
|
+
overflowed = true;
|
|
1982
|
+
consoleLogs.push({
|
|
1983
|
+
type: 'warning',
|
|
1984
|
+
text: `[harness] console output exceeded ${MAX_CONSOLE_MESSAGES} messages; further `
|
|
1985
|
+
+ `messages are not retained. This usually indicates a render or error loop.`,
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1710
1989
|
page.on('console', (msg) => {
|
|
1711
1990
|
const type = msg.type();
|
|
1712
1991
|
const text = msg.text();
|
|
1713
|
-
|
|
1992
|
+
record(type, text);
|
|
1714
1993
|
// Note: We're already handling warnings in our console.error override
|
|
1715
1994
|
// This catches any direct console.warn() calls
|
|
1716
|
-
if (type === 'warning') {
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
}
|
|
1995
|
+
if (type === 'warning' && !seenWarnings.has(text)) {
|
|
1996
|
+
seenWarnings.add(text);
|
|
1997
|
+
warnings.push(text);
|
|
1720
1998
|
}
|
|
1721
1999
|
});
|
|
1722
2000
|
page.on('pageerror', (error) => {
|
|
1723
|
-
|
|
2001
|
+
record('error', error.message);
|
|
1724
2002
|
});
|
|
1725
2003
|
}
|
|
1726
2004
|
async buildLocalMJUtilities(contextUser) {
|
|
@@ -1880,7 +2158,10 @@ export class ComponentRunner {
|
|
|
1880
2158
|
/**
|
|
1881
2159
|
* Expose MJ utilities to the browser context
|
|
1882
2160
|
*/
|
|
1883
|
-
async exposeMJUtilities(page, options, dataErrors, debug = false) {
|
|
2161
|
+
async exposeMJUtilities(page, options, dataErrors, dataAccess, debug = false) {
|
|
2162
|
+
// Row ceilings for this run, resolved once rather than per call.
|
|
2163
|
+
const viewCeiling = options.dataCaps?.runViewMaxRows ?? DEFAULT_RUN_VIEW_MAX_ROWS;
|
|
2164
|
+
const queryCeiling = options.dataCaps?.runQueryMaxRows ?? DEFAULT_RUN_QUERY_MAX_ROWS;
|
|
1884
2165
|
// Don't check if already exposed - we always start fresh after goto('about:blank')
|
|
1885
2166
|
// The page.exposeFunction calls need to be made for each new page instance
|
|
1886
2167
|
// Serialize contextUser to pass to the browser context
|
|
@@ -1894,9 +2175,11 @@ export class ComponentRunner {
|
|
|
1894
2175
|
try {
|
|
1895
2176
|
// Try to get entities if available, otherwise use empty array
|
|
1896
2177
|
if (util.md?.Entities) {
|
|
1897
|
-
//
|
|
1898
|
-
|
|
1899
|
-
//
|
|
2178
|
+
// Project to the properties the in-page runtime reads, rather than deep-cloning the
|
|
2179
|
+
// whole EntityInfo graph. See `projectEntitiesForBrowser` for why this matters: the
|
|
2180
|
+
// value goes to `page.evaluate`, whose cost scales with NODE COUNT, and field
|
|
2181
|
+
// objects dominate that count on a wide schema.
|
|
2182
|
+
entitiesData = projectEntitiesForBrowser(util.md.Entities);
|
|
1900
2183
|
}
|
|
1901
2184
|
else {
|
|
1902
2185
|
// Metadata.Entities not available, using empty array
|
|
@@ -1966,12 +2249,16 @@ export class ComponentRunner {
|
|
|
1966
2249
|
}
|
|
1967
2250
|
});
|
|
1968
2251
|
await page.exposeFunction('__mjRunView', async (params) => {
|
|
2252
|
+
const viewTarget = ResolveViewTarget(params);
|
|
2253
|
+
const maxRows = ResolveDataCapMaxRows(params.MaxRows, viewCeiling);
|
|
2254
|
+
const startedAt = Date.now();
|
|
1969
2255
|
try {
|
|
1970
|
-
const result = await util.rv.RunView(params, options.contextUser);
|
|
2256
|
+
const result = await util.rv.RunView({ ...params, MaxRows: maxRows.applied }, options.contextUser);
|
|
2257
|
+
const record = BuildDataAccessRecord('RunView', viewTarget, result, maxRows, Date.now() - startedAt);
|
|
2258
|
+
dataAccess.push(record);
|
|
1971
2259
|
// Debug logging for successful calls
|
|
1972
2260
|
if (debug) {
|
|
1973
|
-
|
|
1974
|
-
console.log(`💾 RunView SUCCESS: Entity="${params.EntityName}" Rows=${rowCount}`);
|
|
2261
|
+
console.log(`💾 RunView SUCCESS: View="${viewTarget.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
|
|
1975
2262
|
if (params.ExtraFilter) {
|
|
1976
2263
|
console.log(` Filter: ${params.ExtraFilter}`);
|
|
1977
2264
|
}
|
|
@@ -1980,36 +2267,53 @@ export class ComponentRunner {
|
|
|
1980
2267
|
}
|
|
1981
2268
|
catch (error) {
|
|
1982
2269
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2270
|
+
dataAccess.push({
|
|
2271
|
+
kind: 'RunView', target: viewTarget.target, identity: viewTarget.identity, rowsReturned: 0, capped: false,
|
|
2272
|
+
appliedMaxRows: maxRows.applied, requestedMaxRows: maxRows.requested,
|
|
2273
|
+
durationMs: Date.now() - startedAt, error: errorMessage,
|
|
2274
|
+
});
|
|
1983
2275
|
// Debug logging for errors
|
|
1984
2276
|
if (debug) {
|
|
1985
|
-
console.log(`❌ RunView FAILED:
|
|
2277
|
+
console.log(`❌ RunView FAILED: View="${viewTarget.target}"`);
|
|
1986
2278
|
console.log(` Error: ${errorMessage}`);
|
|
1987
2279
|
}
|
|
1988
2280
|
else {
|
|
1989
2281
|
console.error('Error in __mjRunView:', errorMessage);
|
|
1990
2282
|
}
|
|
1991
2283
|
// Collect this error for the test report
|
|
1992
|
-
dataErrors.push(`RunView error: ${errorMessage} (
|
|
2284
|
+
dataErrors.push(`RunView error: ${errorMessage} (View: ${viewTarget.target})`);
|
|
1993
2285
|
// Return error result that won't crash the component
|
|
1994
2286
|
return { Success: false, ErrorMessage: errorMessage, Results: [] };
|
|
1995
2287
|
}
|
|
1996
2288
|
});
|
|
1997
2289
|
await page.exposeFunction('__mjRunViews', async (params) => {
|
|
2290
|
+
const perCallMaxRows = params.map(p => ResolveDataCapMaxRows(p.MaxRows, viewCeiling));
|
|
2291
|
+
const startedAt = Date.now();
|
|
1998
2292
|
try {
|
|
1999
|
-
const results = await util.rv.RunViews(params, options.contextUser);
|
|
2293
|
+
const results = await util.rv.RunViews(params.map((p, i) => ({ ...p, MaxRows: perCallMaxRows[i].applied })), options.contextUser);
|
|
2294
|
+
// One record per view in the batch — a batch that partially returns data is a
|
|
2295
|
+
// meaningfully different signal from one that returns none.
|
|
2296
|
+
const elapsed = Date.now() - startedAt;
|
|
2297
|
+
const records = params.map((p, i) => BuildDataAccessRecord('RunViews', ResolveViewTarget(p), results[i], perCallMaxRows[i], elapsed));
|
|
2298
|
+
records.forEach(r => dataAccess.push(r));
|
|
2000
2299
|
// Debug logging for successful calls
|
|
2001
2300
|
if (debug) {
|
|
2002
2301
|
console.log(`💾 RunViews SUCCESS: ${params.length} queries executed`);
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
console.log(` [${i + 1}] Entity="${p.EntityName}" Rows=${rowCount}`);
|
|
2302
|
+
records.forEach((r, i) => {
|
|
2303
|
+
console.log(` [${i + 1}] Entity="${r.target}" Rows=${r.rowsReturned}${DescribeDataCap(r)}`);
|
|
2006
2304
|
});
|
|
2007
2305
|
}
|
|
2008
2306
|
return results;
|
|
2009
2307
|
}
|
|
2010
2308
|
catch (error) {
|
|
2011
2309
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2012
|
-
const entities = params.map(p => p
|
|
2310
|
+
const entities = params.map(p => DescribeViewTarget(p)).join(', ');
|
|
2311
|
+
// RunViews fails as a batch, so every view in it failed.
|
|
2312
|
+
params.forEach((p, i) => dataAccess.push({
|
|
2313
|
+
kind: 'RunViews', ...ResolveViewTarget(p), rowsReturned: 0, capped: false,
|
|
2314
|
+
appliedMaxRows: perCallMaxRows[i].applied, requestedMaxRows: perCallMaxRows[i].requested,
|
|
2315
|
+
durationMs: Date.now() - startedAt, error: errorMessage,
|
|
2316
|
+
}));
|
|
2013
2317
|
// Debug logging for errors
|
|
2014
2318
|
if (debug) {
|
|
2015
2319
|
console.log(`❌ RunViews FAILED: Entities=[${entities}]`);
|
|
@@ -2025,13 +2329,16 @@ export class ComponentRunner {
|
|
|
2025
2329
|
}
|
|
2026
2330
|
});
|
|
2027
2331
|
await page.exposeFunction('__mjRunQuery', async (params) => {
|
|
2332
|
+
const queryIdentifier = ResolveQueryTarget(params);
|
|
2333
|
+
const maxRows = ResolveDataCapMaxRows(params.MaxRows, queryCeiling);
|
|
2334
|
+
const startedAt = Date.now();
|
|
2028
2335
|
try {
|
|
2029
|
-
const result = await util.rq.RunQuery(params, options.contextUser);
|
|
2336
|
+
const result = await util.rq.RunQuery({ ...params, MaxRows: maxRows.applied }, options.contextUser);
|
|
2337
|
+
const record = BuildDataAccessRecord('RunQuery', queryIdentifier, result, maxRows, Date.now() - startedAt);
|
|
2338
|
+
dataAccess.push(record);
|
|
2030
2339
|
// Debug logging for successful calls
|
|
2031
2340
|
if (debug) {
|
|
2032
|
-
|
|
2033
|
-
const rowCount = result.Results?.length || 0;
|
|
2034
|
-
console.log(`💾 RunQuery SUCCESS: Query="${queryIdentifier}" Rows=${rowCount}`);
|
|
2341
|
+
console.log(`💾 RunQuery SUCCESS: Query="${queryIdentifier.target}" Rows=${record.rowsReturned}${DescribeDataCap(record)}`);
|
|
2035
2342
|
if (params.Parameters && Object.keys(params.Parameters).length > 0) {
|
|
2036
2343
|
console.log(` Parameters:`, params.Parameters);
|
|
2037
2344
|
}
|
|
@@ -2040,17 +2347,21 @@ export class ComponentRunner {
|
|
|
2040
2347
|
}
|
|
2041
2348
|
catch (error) {
|
|
2042
2349
|
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
2043
|
-
|
|
2350
|
+
dataAccess.push({
|
|
2351
|
+
kind: 'RunQuery', target: queryIdentifier.target, identity: queryIdentifier.identity, rowsReturned: 0, capped: false,
|
|
2352
|
+
appliedMaxRows: maxRows.applied, requestedMaxRows: maxRows.requested,
|
|
2353
|
+
durationMs: Date.now() - startedAt, error: errorMessage,
|
|
2354
|
+
});
|
|
2044
2355
|
// Debug logging for errors
|
|
2045
2356
|
if (debug) {
|
|
2046
|
-
console.log(`❌ RunQuery FAILED: Query="${queryIdentifier}"`);
|
|
2357
|
+
console.log(`❌ RunQuery FAILED: Query="${queryIdentifier.target}"`);
|
|
2047
2358
|
console.log(` Error: ${errorMessage}`);
|
|
2048
2359
|
}
|
|
2049
2360
|
else {
|
|
2050
2361
|
console.error('Error in __mjRunQuery:', errorMessage);
|
|
2051
2362
|
}
|
|
2052
2363
|
// Collect this error for the test report
|
|
2053
|
-
dataErrors.push(`RunQuery error: ${errorMessage} (Query: ${queryIdentifier})`);
|
|
2364
|
+
dataErrors.push(`RunQuery error: ${errorMessage} (Query: ${queryIdentifier.target})`);
|
|
2054
2365
|
// Return error result that won't crash the component
|
|
2055
2366
|
return { Success: false, ErrorMessage: errorMessage, Results: [] };
|
|
2056
2367
|
}
|
|
@@ -2116,7 +2427,11 @@ export class ComponentRunner {
|
|
|
2116
2427
|
const VectorService = window.MJReactRuntime?.SimpleVectorService ||
|
|
2117
2428
|
class {
|
|
2118
2429
|
// Stub implementation if not available
|
|
2119
|
-
|
|
2430
|
+
CosineSimilarity(_a, _b) { return 0; }
|
|
2431
|
+
/** @deprecated Use {@link CosineSimilarity}. */
|
|
2432
|
+
cosineSimilarity(_a, _b) {
|
|
2433
|
+
return this.CosineSimilarity(_a, _b);
|
|
2434
|
+
}
|
|
2120
2435
|
};
|
|
2121
2436
|
window.__mjUtilities = {
|
|
2122
2437
|
md: {
|