@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.
@@ -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 lintComponent(componentCode, componentName, componentSpec, isRootComponent, contextUser, options) {
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
- async executeComponent(options) {
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
- // Try to get HTML content with a reasonable size limit
909
- html = await page.content();
910
- // Check if HTML is excessively large (>100MB is suspicious)
911
- const htmlSizeEstimate = Buffer.byteLength(html, 'utf8');
912
- const maxSafeSize = 100 * 1024 * 1024; // 100MB
913
- if (htmlSizeEstimate > maxSafeSize) {
914
- console.warn(`⚠️ HTML content is very large (${Math.round(htmlSizeEstimate / 1024 / 1024)}MB). Truncating to prevent crashes.`);
915
- // Truncate to a safe size
916
- html = html.substring(0, maxSafeSize) + '\n<!-- TRUNCATED: Content exceeded safe size limit -->';
917
- errors.push(`HTML content too large (${Math.round(htmlSizeEstimate / 1024 / 1024)}MB). This may indicate excessive data or a render issue.`);
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: regularWarnings.map(w => ({
1056
- message: w,
1057
- severity: 'low',
1058
- rule: 'warning',
1059
- line: 0,
1060
- column: 0
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
- consoleLogs.push({ type, text });
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
- if (!warnings.includes(text)) {
1718
- warnings.push(text);
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
- consoleLogs.push({ type: 'error', text: error.message });
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
- // Serialize the entities data (remove functions, keep data)
1898
- entitiesData = JSON.parse(JSON.stringify(util.md.Entities));
1899
- // Serialized entities for browser context
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
- const rowCount = result.Results?.length || 0;
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: Entity="${params.EntityName || 'unknown'}"`);
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} (Entity: ${params.EntityName || 'unknown'})`);
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
- params.forEach((p, i) => {
2004
- const rowCount = results[i]?.Results?.length || 0;
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.EntityName || 'unknown').join(', ');
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
- const queryIdentifier = params.QueryName || params.QueryID || 'unknown';
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
- const queryIdentifier = params.QueryName || params.QueryID || 'unknown';
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
- cosineSimilarity(_a, _b) { return 0; }
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: {