@ralphralphai/playwright 2.9.0 → 2.11.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.11.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;
@@ -1474,9 +1552,26 @@ var Recorder = class {
1474
1552
  const key = Symbol();
1475
1553
  state.declared.set(key, declaredConditions(declaration));
1476
1554
  await this.recordConditionChange(state);
1477
- return async () => {
1478
- if (state.declared.delete(key)) await this.recordConditionChange(state);
1479
- };
1555
+ return this.undoDeclaration(state, key);
1556
+ }
1557
+ async waitForNode(page, url, declaration = {}) {
1558
+ await this.observe(page.context());
1559
+ const state = this.pages.get(page);
1560
+ if (!state) throw new Error("Cannot wait on a closed browser page.");
1561
+ await page.waitForURL(url, { waitUntil: "commit" });
1562
+ this.urlChanged(state);
1563
+ const generation = state.generation;
1564
+ const key = Symbol();
1565
+ state.declared.set(key, declaredConditions(declaration));
1566
+ await state.tail;
1567
+ if (!this.paused && state.generation === generation && !this.retagVisitNode(state)) await this.recordConditionChange(state);
1568
+ return this.undoDeclaration(state, key);
1569
+ }
1570
+ startFlow(name) {
1571
+ return this.flows.start(name, this.lastRequestedNodeId);
1572
+ }
1573
+ endFlow(name) {
1574
+ this.flows.end(name);
1480
1575
  }
1481
1576
  /**
1482
1577
  * Stops requesting nodes, automatic or explicit, until `resume`. Recordings
@@ -1521,7 +1616,7 @@ var Recorder = class {
1521
1616
  linkPreviousNodes(nodes, (pageId) => this.pageInfo(pageId)?.openerPageId, (nodeId) => this.requestedAtMs.get(nodeId) ?? 0, (pageId) => this.pageState(pageId)?.pauseAnchor);
1522
1617
  this.attachActions(nodes);
1523
1618
  const failures = nodes.filter((item) => item.status === "uncaptured" && item.reason === "error");
1524
- await this.writeRawGraph(this.buildRawGraph(nodes, failures.length));
1619
+ await this.writeRawGraph(this.buildRawGraph(nodes, this.flows.finish(), failures.length));
1525
1620
  const mismatched = nodes.filter((item) => item.tagMismatches?.length);
1526
1621
  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
1622
  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.");
@@ -1565,11 +1660,42 @@ var Recorder = class {
1565
1660
  state.generation++;
1566
1661
  state.lastUrl = url;
1567
1662
  state.declared.clear();
1663
+ state.visitNodeId = void 0;
1568
1664
  if (!this.paused && isHttpUrl(url)) this.enqueueCreateNodeRequest(state, "url-change").catch(() => void 0);
1569
1665
  }
1570
1666
  async recordConditionChange(state) {
1571
1667
  if (!this.paused && isHttpUrl(state.page.url())) await this.enqueueCreateNodeRequest(state, "condition-change");
1572
1668
  }
1669
+ undoDeclaration(state, key) {
1670
+ return async () => {
1671
+ if (state.declared.delete(key)) await this.recordConditionChange(state);
1672
+ };
1673
+ }
1674
+ /**
1675
+ * Gives the URL's automatic capture the conditions declared since. They
1676
+ * describe the visit from its start, and declarations change no app state,
1677
+ * so the screenshot already taken stands for the declared node.
1678
+ */
1679
+ retagVisitNode(state) {
1680
+ const nodeId = state.visitNodeId;
1681
+ if (!nodeId || state.lastRecorded?.nodeId !== nodeId) return false;
1682
+ const index = this.nodes.findIndex((item) => item.nodeId === nodeId);
1683
+ const visit = this.nodes[index];
1684
+ if (visit?.status !== "recorded") return false;
1685
+ const conditions = this.declaredConditions(state);
1686
+ const mismatches = mismatchesOf(conditions, visit.reportedTags);
1687
+ const { conditions: _conditions, tagMismatches: _tagMismatches, ...rest } = visit;
1688
+ this.nodes[index] = {
1689
+ ...rest,
1690
+ ...withConditions(conditions),
1691
+ ...mismatches.length > 0 ? { tagMismatches: mismatches } : {}
1692
+ };
1693
+ state.lastRecorded = {
1694
+ ...state.lastRecorded,
1695
+ node: nodeKey(conditions)
1696
+ };
1697
+ return true;
1698
+ }
1573
1699
  declaredConditions(state) {
1574
1700
  return mergeConditions([...state.declared.values()]);
1575
1701
  }
@@ -1594,6 +1720,7 @@ var Recorder = class {
1594
1720
  this.requestedAtMs.set(request.nodeId, Date.now());
1595
1721
  this.requestOrder.set(request.nodeId, ++this.order);
1596
1722
  this.lastRequestedNodeId = request.nodeId;
1723
+ this.flows.nodeRequested(request.nodeId);
1597
1724
  const generation = state.generation;
1598
1725
  const task = state.tail.then(() => this.createNode(state, generation, request));
1599
1726
  state.tail = task.catch(() => void 0);
@@ -1626,7 +1753,10 @@ var Recorder = class {
1626
1753
  const mismatches = mismatchesOf(conditions, reported);
1627
1754
  const node = nodeKey(conditions);
1628
1755
  const sameNode = state.lastRecorded?.url === request.url && state.lastRecorded.node === node;
1629
- if (sameNode && request.trigger !== "explicit") return;
1756
+ if (sameNode && request.trigger !== "explicit") {
1757
+ this.flows.nodeReused(request.nodeId, state.lastRecorded.nodeId);
1758
+ return;
1759
+ }
1630
1760
  if (reported === void 0 && compactConditions(conditions) !== void 0 && !warnedNoTracker) {
1631
1761
  warnedNoTracker = true;
1632
1762
  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.");
@@ -1673,6 +1803,7 @@ var Recorder = class {
1673
1803
  node,
1674
1804
  nodeId: request.nodeId
1675
1805
  };
1806
+ if (request.trigger === "url-change" && state.generation === generation) state.visitNodeId = request.nodeId;
1676
1807
  } else {
1677
1808
  const { nodeId, sequence, requestedAt, name, edgeLabel } = this.nodes[retaken];
1678
1809
  this.nodes[retaken] = {
@@ -1683,6 +1814,7 @@ var Recorder = class {
1683
1814
  sequence,
1684
1815
  requestedAt
1685
1816
  };
1817
+ this.flows.nodeReused(request.nodeId, nodeId);
1686
1818
  }
1687
1819
  } catch (error) {
1688
1820
  this.nodes.push({
@@ -1762,7 +1894,7 @@ var Recorder = class {
1762
1894
  pageInfo(pageId) {
1763
1895
  return this.pageState(pageId)?.info;
1764
1896
  }
1765
- buildRawGraph(nodes, failureCount) {
1897
+ buildRawGraph(nodes, flowRuns, failureCount) {
1766
1898
  const { testInfo } = this;
1767
1899
  const testPassed = testInfo.status === "passed";
1768
1900
  return {
@@ -1790,6 +1922,7 @@ var Recorder = class {
1790
1922
  finishedAt: (/* @__PURE__ */ new Date()).toISOString(),
1791
1923
  pages: [...this.pages.values()].map((state) => state.info),
1792
1924
  nodes,
1925
+ ...flowRuns.length ? { flowRuns } : {},
1793
1926
  complete: testPassed && nodes.length > 0 && nodes.every((item) => item.status === "recorded" && item.geometryStable)
1794
1927
  }
1795
1928
  };
@@ -2082,4 +2215,4 @@ async function responseDetail(response) {
2082
2215
  //#endregion
2083
2216
  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
2217
 
2085
- //# sourceMappingURL=upload-DKeVoDQ2.mjs.map
2218
+ //# sourceMappingURL=upload-C_KLRH9y.mjs.map