@ralphralphai/playwright 2.9.0 → 2.10.0

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.
@@ -45,7 +45,7 @@ function snapshot(value) {
45
45
  }
46
46
  //#endregion
47
47
  //#region package.json
48
- var version = "2.9.0";
48
+ var version = "2.10.0";
49
49
  //#endregion
50
50
  //#region src/actions.ts
51
51
  const BINDING = "__ralphAction";
@@ -664,7 +664,12 @@ async function captureAriaSnapshot(page, { viewport, scroll }, ignore = []) {
664
664
  });
665
665
  const ignoreSelector = ignore.length === 0 ? void 0 : ignore.join(", ");
666
666
  const facts = /* @__PURE__ */ new Map();
667
- const normalized = (await Promise.all(visible.map((node) => readTree(page, node, ignoreSelector, facts)))).filter((node) => node !== void 0);
667
+ const detached = [];
668
+ const normalized = (await Promise.all(visible.map((node) => readTree(page, node, ignoreSelector, facts, detached)))).filter((node) => node !== void 0);
669
+ if (detached.length > 0) {
670
+ const listed = detached.slice(0, 5).map(({ role, name }) => name ? `${role} "${name}"` : role).join(", ");
671
+ console.warn(`Ralph: ${detached.length} element(s) left ${page.url()} during capture and were dropped from its aria snapshot: ${listed}${detached.length > 5 ? ", ..." : ""}`);
672
+ }
668
673
  const described = await Promise.all(normalized.map((node) => describe(page, node, {}, false, facts)));
669
674
  const json = Buffer.from(JSON.stringify(fillMissingRefs(described)));
670
675
  return json.byteLength > 4194304 ? void 0 : json;
@@ -879,8 +884,11 @@ function fillMissingRefs(nodes) {
879
884
  return nodes.map(fill);
880
885
  }
881
886
  async function readElement(page, ref, isImage, ignoreSelector) {
887
+ const locator = page.locator(`aria-ref=${ref}`);
888
+ const isDetached = async () => await locator.count().catch(() => 0) === 0;
882
889
  try {
883
- return await page.locator(`aria-ref=${ref}`).evaluate((element, { properties, isImage, dynamicAttribute, ignoreSelector }) => {
890
+ if (await isDetached()) return "detached";
891
+ return await locator.evaluate((element, { properties, isImage, dynamicAttribute, ignoreSelector }) => {
884
892
  const computed = getComputedStyle(element);
885
893
  const typed = "computedStyleMap" in element ? element.computedStyleMap() : void 0;
886
894
  const style = Object.fromEntries(properties.map((property) => [property, property.startsWith("margin-") && typed?.get(property)?.toString() === "auto" ? "auto" : computed.getPropertyValue(property)]));
@@ -925,7 +933,7 @@ async function readElement(page, ref, isImage, ignoreSelector) {
925
933
  ignoreSelector
926
934
  }, { timeout: ELEMENT_LOOKUP_TIMEOUT_MS });
927
935
  } catch {
928
- return;
936
+ return await isDetached() ? "detached" : void 0;
929
937
  }
930
938
  }
931
939
  async function hashImage(page, image) {
@@ -944,12 +952,16 @@ function distinctStyle(style, inherited) {
944
952
  });
945
953
  return distinct.length === 0 ? void 0 : Object.fromEntries(distinct);
946
954
  }
947
- async function readTree(page, child, ignoreSelector, factsByRef) {
955
+ async function readTree(page, child, ignoreSelector, factsByRef, detached) {
948
956
  if (typeof child === "string") return child;
949
957
  const facts = child.ref === void 0 ? void 0 : await readElement(page, child.ref, child.role === "img", ignoreSelector);
958
+ if (facts === "detached") {
959
+ detached.push(child);
960
+ return;
961
+ }
950
962
  if (facts?.ignored) return;
951
963
  if (child.ref !== void 0 && facts !== void 0) factsByRef.set(child.ref, facts);
952
- const children = child.children === void 0 ? void 0 : (await Promise.all(child.children.map((grandchild) => readTree(page, grandchild, ignoreSelector, factsByRef)))).filter((grandchild) => grandchild !== void 0);
964
+ const children = child.children === void 0 ? void 0 : (await Promise.all(child.children.map((grandchild) => readTree(page, grandchild, ignoreSelector, factsByRef, detached)))).filter((grandchild) => grandchild !== void 0);
953
965
  if (children !== void 0) {
954
966
  if (children.length === 0 && (child.ref === void 0 || child.role === "generic" && !child.name && (child.children?.length ?? 0) > 0)) return;
955
967
  const only = loneChild(child, children);
@@ -1204,6 +1216,71 @@ function nodeKey(conditions) {
1204
1216
  return JSON.stringify([sorted(conditions.visitorTags), sorted(conditions.pageTags)]);
1205
1217
  }
1206
1218
  //#endregion
1219
+ //#region src/flows.ts
1220
+ const MAX_FLOW_NAME_LENGTH = 64;
1221
+ var FlowRuns = class {
1222
+ runs = [];
1223
+ retainedNodeIds = /* @__PURE__ */ new Map();
1224
+ /** `currentNodeId` is the node on screen, the run's first step. */
1225
+ start(name, currentNodeId) {
1226
+ const flowName = flowNameOf(name);
1227
+ if (this.openRun(flowName)) throw new Error(`Ralph flow "${flowName}" is already open.`);
1228
+ const run = {
1229
+ name: flowName,
1230
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1231
+ nodeIds: currentNodeId === void 0 ? [] : [currentNodeId]
1232
+ };
1233
+ this.runs.push(run);
1234
+ return {
1235
+ name: flowName,
1236
+ end: () => {
1237
+ if (run.ended) throw new Error(`This run of Ralph flow "${flowName}" has already ended.`);
1238
+ endRun(run, "endFlow");
1239
+ }
1240
+ };
1241
+ }
1242
+ end(name) {
1243
+ const flowName = flowNameOf(name);
1244
+ const run = this.openRun(flowName);
1245
+ if (!run) throw new Error(`Ralph flow "${flowName}" is not open.`);
1246
+ endRun(run, "endFlow");
1247
+ }
1248
+ nodeRequested(nodeId) {
1249
+ for (const run of this.runs) if (!run.ended) run.nodeIds.push(nodeId);
1250
+ }
1251
+ /** A retake or unchanged condition update kept an existing node's ID. */
1252
+ nodeReused(requestId, retainedNodeId) {
1253
+ this.retainedNodeIds.set(requestId, retainedNodeId);
1254
+ }
1255
+ /** Ends the runs still open, as the test has, and returns every run. */
1256
+ finish() {
1257
+ return this.runs.map((run) => {
1258
+ if (!run.ended) endRun(run, "test-end");
1259
+ return {
1260
+ name: run.name,
1261
+ startedAt: run.startedAt,
1262
+ endedAt: run.ended.at,
1263
+ endedBy: run.ended.by,
1264
+ nodeIds: run.nodeIds.map((nodeId) => this.retainedNodeIds.get(nodeId) ?? nodeId)
1265
+ };
1266
+ });
1267
+ }
1268
+ openRun(name) {
1269
+ return this.runs.find((run) => run.name === name && !run.ended);
1270
+ }
1271
+ };
1272
+ function endRun(run, by) {
1273
+ run.ended = {
1274
+ at: (/* @__PURE__ */ new Date()).toISOString(),
1275
+ by
1276
+ };
1277
+ }
1278
+ function flowNameOf(name) {
1279
+ const trimmed = name.trim();
1280
+ if (!trimmed || trimmed.length > MAX_FLOW_NAME_LENGTH) throw new Error(`A Ralph flow name must be 1 to ${MAX_FLOW_NAME_LENGTH} characters.`);
1281
+ return trimmed;
1282
+ }
1283
+ //#endregion
1207
1284
  //#region src/image.ts
1208
1285
  /**
1209
1286
  * Matches the crawler's full-page screenshots: lossless measured larger than this
@@ -1430,6 +1507,7 @@ var Recorder = class {
1430
1507
  */
1431
1508
  order = 0;
1432
1509
  requestOrder = /* @__PURE__ */ new Map();
1510
+ flows = new FlowRuns();
1433
1511
  actions = new ActionLog(() => ++this.order, () => !this.paused && !this.closed, (page) => this.pages.get(page)?.info.pageId, () => [...this.pages.values()].filter((state) => !state.page.isClosed()).map((state) => state.info.pageId));
1434
1512
  constructor(options, config, testInfo, mode, playwright) {
1435
1513
  this.options = options;
@@ -1478,6 +1556,12 @@ var Recorder = class {
1478
1556
  if (state.declared.delete(key)) await this.recordConditionChange(state);
1479
1557
  };
1480
1558
  }
1559
+ startFlow(name) {
1560
+ return this.flows.start(name, this.lastRequestedNodeId);
1561
+ }
1562
+ endFlow(name) {
1563
+ this.flows.end(name);
1564
+ }
1481
1565
  /**
1482
1566
  * Stops requesting nodes, automatic or explicit, until `resume`. Recordings
1483
1567
  * already requested still complete. The next node after `resume` links to
@@ -1521,7 +1605,7 @@ var Recorder = class {
1521
1605
  linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0, (pageId) => this.pageState(pageId)?.pauseAnchor);
1522
1606
  this.attachActions(nodes);
1523
1607
  const failures = nodes.filter((item) => item.status === "uncaptured" && item.reason === "error");
1524
- await this.writeRawGraph(this.buildRawGraph(nodes, failures.length));
1608
+ await this.writeRawGraph(this.buildRawGraph(nodes, this.flows.finish(), failures.length));
1525
1609
  const mismatched = nodes.filter((item) => item.tagMismatches?.length);
1526
1610
  if (this.options.strictNodeTags && mismatched.length && this.testInfo.status === "passed") throw new Error("Ralph: the app reported tags outside what the test declared, on " + mismatched.map((item) => item.url + " (" + item.tagMismatches.join(", ") + ")").join("; ") + ".");
1527
1611
  if (failures.length && this.testInfo.status === "passed") throw new Error("Ralph could not record " + failures.length + " page state(s). See the ralph-raw-graph attachment.");
@@ -1594,6 +1678,7 @@ var Recorder = class {
1594
1678
  this.requestedAtMs.set(request.nodeId, Date.now());
1595
1679
  this.requestOrder.set(request.nodeId, ++this.order);
1596
1680
  this.lastRequestedNodeId = request.nodeId;
1681
+ this.flows.nodeRequested(request.nodeId);
1597
1682
  const generation = state.generation;
1598
1683
  const task = state.tail.then(() => this.createNode(state, generation, request));
1599
1684
  state.tail = task.catch(() => void 0);
@@ -1626,7 +1711,10 @@ var Recorder = class {
1626
1711
  const mismatches = mismatchesOf(conditions, reported);
1627
1712
  const node = nodeKey(conditions);
1628
1713
  const sameNode = state.lastRecorded?.url === request.url && state.lastRecorded.node === node;
1629
- if (sameNode && request.trigger !== "explicit") return;
1714
+ if (sameNode && request.trigger !== "explicit") {
1715
+ this.flows.nodeReused(request.nodeId, state.lastRecorded.nodeId);
1716
+ return;
1717
+ }
1630
1718
  if (reported === void 0 && compactConditions(conditions) !== void 0 && !warnedNoTracker) {
1631
1719
  warnedNoTracker = true;
1632
1720
  console.warn("Ralph: this page runs no tracker, so the node conditions the test declares cannot be checked against the tags the app reports. Production events must carry the same tags, or these nodes will match no visitors.");
@@ -1683,6 +1771,7 @@ var Recorder = class {
1683
1771
  sequence,
1684
1772
  requestedAt
1685
1773
  };
1774
+ this.flows.nodeReused(request.nodeId, nodeId);
1686
1775
  }
1687
1776
  } catch (error) {
1688
1777
  this.nodes.push({
@@ -1762,7 +1851,7 @@ var Recorder = class {
1762
1851
  pageInfo(pageId) {
1763
1852
  return this.pageState(pageId)?.info;
1764
1853
  }
1765
- buildRawGraph(nodes, failureCount) {
1854
+ buildRawGraph(nodes, flowRuns, failureCount) {
1766
1855
  const { testInfo } = this;
1767
1856
  const testPassed = testInfo.status === "passed";
1768
1857
  return {
@@ -1790,6 +1879,7 @@ var Recorder = class {
1790
1879
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1791
1880
  pages: [...this.pages.values()].map((state) => state.info),
1792
1881
  nodes,
1882
+ ...flowRuns.length ? { flowRuns } : {},
1793
1883
  complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "recorded" && item.geometryStable)
1794
1884
  }
1795
1885
  };
@@ -2082,4 +2172,4 @@ async function responseDetail(response) {
2082
2172
  //#endregion
2083
2173
  export { resolveMode as _, prepareArtifactUpload as a, resultLink as c, REPORTER_ENV as d, Recorder as f, loadConfigSync as g, loadConfig as h, createRawGraphBundle as i, uploadRawGraph as l, version as m, bundleImagePath as n, registerDeployment as o, visitAllLinks as p, bundleSnapshotPath as r, resolveUploadOptions as s, RAW_GRAPH_BUNDLE_INDEX as t, MANIFEST_ATTACHMENT as u };
2084
2174
 
2085
- //# sourceMappingURL=upload-DKeVoDQ2.mjs.map
2175
+ //# sourceMappingURL=upload-sjRLrwTO.mjs.map