diff --git a/package.json b/package.json
index f108339..a5cd00c 100755
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "snyk-api-and-web-record-sequence",
- "version": "1.2.3",
+ "version": "1.2.4",
"description": "Snyk API & Web Record login/sequence",
"license": "MIT",
"scripts": {
diff --git a/src/manifest-firefox.json b/src/manifest-firefox.json
index 772e6dd..a6d2bf0 100755
--- a/src/manifest-firefox.json
+++ b/src/manifest-firefox.json
@@ -1,6 +1,6 @@
{
"name": "Snyk API & Web Sequence Recorder",
- "version": "1.2.3",
+ "version": "1.2.4",
"browser_specific_settings": {
"gecko": {
"id": "sequence-recorder@probely.com",
diff --git a/src/pages/Content/index.js b/src/pages/Content/index.js
index 2e0327c..aac87f0 100644
--- a/src/pages/Content/index.js
+++ b/src/pages/Content/index.js
@@ -1,6 +1,9 @@
import { interceptEvents } from './modules/collectEvents';
import getNodeSelector from './modules/getNodeSelector';
-import getCustomSelector from './modules/getCustomSelector';
+import getCustomSelector, {
+ dataTestAttrs,
+ isStableClassName,
+} from './modules/getCustomSelector';
(function () {
// console.log('CONTENT SCRIPT HAS BEEN LOADED');
@@ -76,16 +79,11 @@ import getCustomSelector from './modules/getCustomSelector';
try {
frameSelector = getNodeSelector(frame, {
root: window.document,
+ attr: (name) => dataTestAttrs.includes(name),
idName: (name) => {
return !/^[0-9]+.*/i.test(name);
},
- className: (name) => {
- return (
- !name.includes('focus') &&
- !name.includes('highlight') &&
- !/^[0-9]+.*/i.test(name)
- );
- },
+ className: isStableClassName,
// seedMinLength: 3,
// optimizedMinLength: 3,
});
@@ -142,16 +140,11 @@ import getCustomSelector from './modules/getCustomSelector';
try {
frameSelector = getNodeSelector(frame, {
root: window.document,
+ attr: (name) => dataTestAttrs.includes(name),
idName: (name) => {
return !/^[0-9]+.*/i.test(name);
},
- className: (name) => {
- return (
- !name.includes('focus') &&
- !name.includes('highlight') &&
- !/^[0-9]+.*/i.test(name)
- );
- },
+ className: isStableClassName,
});
} catch (ex) {
// ignore
diff --git a/src/pages/Content/modules/collectEvents.js b/src/pages/Content/modules/collectEvents.js
index 51b1f3a..d8eda2c 100644
--- a/src/pages/Content/modules/collectEvents.js
+++ b/src/pages/Content/modules/collectEvents.js
@@ -1,7 +1,12 @@
// import getNodeSelector from './getNodeSelector';
import getNodeSelector from './finder';
import getBestElement from './getBestElement';
-import getCustomSelector from './getCustomSelector';
+import getCustomSelector, {
+ dataTestAttrs,
+ getTestAncestor,
+ getTestAttrSelector,
+ isStableClassName,
+} from './getCustomSelector';
import getXPath from './getXPath';
const oNodes = {};
@@ -24,6 +29,32 @@ let flushedInput = null;
const keystrokeBuffers = new WeakMap();
let pendingCanvasInput = null;
let canvasKeystrokeBuffer = '';
+let lastRecordedClick = null;
+// A checkbox/radio "change" this soon after a recorded click on another
+// element was caused by that click (e.g. custom checkboxes calling
+// input.click()), so replaying both would toggle it twice.
+const SIDE_EFFECT_CHANGE_MS = 100;
+const watchedShadowRoots = new WeakSet();
+
+// "change" is not a composed event, so it never reaches the document
+// listeners from inside a shadow root; listen on each open root instead.
+function watchShadowRoots(composedPath, doc, ifrSelector, callback) {
+ for (const item of composedPath) {
+ if (item instanceof ShadowRoot && !watchedShadowRoots.has(item)) {
+ watchedShadowRoots.add(item);
+ item.addEventListener(
+ 'change',
+ (ev) => {
+ // slotted (light DOM) targets also reach the outer listeners
+ if (ev.target && ev.target.getRootNode() === item) {
+ interceptEvents(ev, doc, ifrSelector, callback);
+ }
+ },
+ true
+ );
+ }
+ }
+}
function getEffectiveValue(element) {
const domValue = element.value;
@@ -55,11 +86,74 @@ function isCanvasSurface(element) {
return false;
}
+const finderOptions = {
+ attr: (name) => dataTestAttrs.includes(name),
+ idName: (name) => {
+ return !/^[0-9]+.*/i.test(name);
+ },
+ className: isStableClassName,
+};
+
+// The same test id can appear more than once (repeated widgets, other tab
+// panes), so anchor it further up when it isn't unique on its own.
+function getTestAncestorSelector(testAncestor, doc) {
+ const attrSelector = getTestAttrSelector(testAncestor);
+ try {
+ if (doc.querySelectorAll(attrSelector).length === 1) {
+ return attrSelector;
+ }
+ } catch (ex) {
+ // ignore
+ }
+ return getFallbackSelector(testAncestor, doc);
+}
+
+// Prefer a selector anchored on the nearest QA/test-attribute ancestor; the
+// plain finder stops at the first unique class and would skip it.
+function getFallbackSelector(element, doc) {
+ const testAncestor = getTestAncestor(element);
+ if (testAncestor) {
+ try {
+ const inner = getNodeSelector(element, {
+ ...finderOptions,
+ root: testAncestor,
+ });
+ const scoped = `${getTestAncestorSelector(testAncestor, doc)} ${inner}`;
+ const foundElms = doc.querySelectorAll(scoped);
+ if (foundElms.length === 1 && foundElms[0] === element) {
+ return scoped;
+ }
+ } catch (ex) {
+ // ignore
+ }
+ }
+ return getNodeSelector(element, { ...finderOptions, root: doc });
+}
+
+function getElementSelector(element, doc) {
+ let selector = null;
+ try {
+ selector = getCustomSelector(element, doc);
+ } catch (ex) {
+ // ignore
+ }
+ if (!selector) {
+ try {
+ selector = getFallbackSelector(element, doc);
+ } catch (ex) {
+ // ignore
+ }
+ }
+ return selector;
+}
+
function getStableInputSelector(element, doc) {
const dynamicPatterns = ['focus', 'highlight', 'editable', 'caret'];
const classes = Array.from(element.classList || []).filter((cls) => {
const lower = cls.toLowerCase();
- return !dynamicPatterns.some((p) => lower.includes(p)) && !/^[0-9]/.test(cls);
+ return (
+ !dynamicPatterns.some((p) => lower.includes(p)) && isStableClassName(cls)
+ );
});
for (const cls of classes) {
const sel = '.' + cls;
@@ -90,9 +184,13 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
}
if (shadowRootIdx > -1) {
tgt = composedPath[0];
+ watchShadowRoots(composedPath, doc, ifrSelector, callback);
} else {
tgt = event.target;
}
+ const isInShadowRoot =
+ shadowRootIdx > -1 ||
+ !!(tgt && tgt.getRootNode && tgt.getRootNode() instanceof ShadowRoot);
const type = event.type;
let nodeName = null;
let nodeType = null;
@@ -102,6 +200,8 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
} else {
return;
}
+ const isToggleInput =
+ nodeName === 'input' && (tgt.type === 'checkbox' || tgt.type === 'radio');
// console.log(
// 'CAPTURED EVENT :: ',
// type,
@@ -112,35 +212,13 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
// ' :: event :: ',
// event
// );
- let selector = null;
let bestElm = null;
try {
bestElm = getBestElement(tgt, doc);
- selector = getCustomSelector(bestElm, doc);
} catch (ex) {
// ignore
}
- if (!selector) {
- try {
- selector = getNodeSelector(bestElm, {
- root: doc,
- idName: (name) => {
- return !/^[0-9]+.*/i.test(name);
- },
- className: (name) => {
- return (
- !name.includes('focus') &&
- !name.includes('highlight') &&
- !/^[0-9]+.*/i.test(name)
- );
- },
- // seedMinLength: 3,
- // optimizedMinLength: 3,
- });
- } catch (ex) {
- // ignore
- }
- }
+ const selector = getElementSelector(bestElm, doc);
if (selector && selector.toLowerCase() === 'html') {
if (!(type === 'keydown' && pendingCanvasInput)) {
return;
@@ -152,7 +230,7 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
} catch (ex) {
// ignore
}
- if (shadowRootIdx > -1) {
+ if (isInShadowRoot) {
xpath = '/html/node/shadow';
}
@@ -212,32 +290,50 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
// ignore this click, already submitted
return;
}
+ if (isToggleInput) {
+ return;
+ }
+ // the resulting "change" records the selection; replaying the click as
+ // well would just reopen the dropdown
if (
- nodeName === 'input' &&
- (nodeType === 'checkbox' || nodeType === 'radio')
+ nodeName === 'select' ||
+ ((nodeName === 'option' || nodeName === 'optgroup') &&
+ tgt.closest('select'))
) {
return;
}
+ let clickedElm = tgt;
const parentLabel = tgt.closest && tgt.closest('label');
if (parentLabel) {
- // Only suppress when the click is actually forwarded to the labeled control.
- // Clicks on interactive descendants (links, buttons, other fields) are NOT
- // forwarded by the browser and must still be recorded.
+ // Clicks on interactive descendants (links, buttons, other fields) are
+ // not forwarded by the browser to the labeled control.
const interactiveHit = tgt.closest('a, button, input, select, textarea');
- const forwardsToControl = !interactiveHit || !parentLabel.contains(interactiveHit);
- if (forwardsToControl) {
- const forAttr = parentLabel.getAttribute('for');
- if (forAttr) {
- const root = tgt.getRootNode();
- const lookup = root && root.getElementById ? root : document; // shadow-DOM safe
- const forEl = lookup.getElementById(forAttr);
- if (forEl && forEl.nodeName.toLowerCase() === 'input' &&
- (forEl.type === 'checkbox' || forEl.type === 'radio')) {
- return;
+ const forwardsToControl =
+ !interactiveHit || !parentLabel.contains(interactiveHit);
+ const control = parentLabel.control;
+ if (
+ forwardsToControl &&
+ control &&
+ control.nodeName.toLowerCase() === 'input' &&
+ (control.type === 'checkbox' || control.type === 'radio')
+ ) {
+ // Record the label click instead of the control's "change": setting
+ // .checked on replay bypasses framework handlers (e.g. React), and
+ // custom toggles often swap the inner icon when the state changes.
+ const labelSelector = getElementSelector(parentLabel, doc);
+ if (labelSelector) {
+ clickedElm = parentLabel;
+ let labelXPath = '';
+ try {
+ labelXPath = getXPath(parentLabel, doc) || '';
+ } catch (ex) {
+ // ignore
}
- }
- if (parentLabel.querySelector('input[type="checkbox"], input[type="radio"]')) {
- return;
+ oEventBase = {
+ ...oEventBase,
+ css: labelSelector,
+ xpath: isInShadowRoot ? oEventBase.xpath : labelXPath,
+ };
}
}
}
@@ -257,12 +353,13 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
oEventToSend = {
...oEventBase,
type: typeStr,
- value: (tgt.value || tgt.textContent || '')
+ value: (clickedElm.value || clickedElm.textContent || '')
.trim()
.substr(0, 20)
.replace(/\n/gi, ''),
frame: ifrSelector,
};
+ lastRecordedClick = { element: clickedElm, timestamp: oEventBase.timestamp };
// Add shadow host CSS selector for bclick events inside shadow DOM
if (typeStr === 'bclick' && shadowRootIdx > -1 && composedPath) {
@@ -277,19 +374,7 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
}
if (!shadowHostSelector) {
try {
- shadowHostSelector = getNodeSelector(shadowHost, {
- root: doc,
- idName: (name) => {
- return !/^[0-9]+.*/i.test(name);
- },
- className: (name) => {
- return (
- !name.includes('focus') &&
- !name.includes('highlight') &&
- !/^[0-9]+.*/i.test(name)
- );
- },
- });
+ shadowHostSelector = getFallbackSelector(shadowHost, doc);
} catch (ex) {
// ignore
}
@@ -375,7 +460,7 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
// };
} else if (type === 'keydown') {
lastNodes.keydown = tgt;
- if (['input', 'textarea'].indexOf(nodeName) > -1) {
+ if (['input', 'textarea'].indexOf(nodeName) > -1 && !isToggleInput) {
oNodes[tgt] = tgt.value;
pendingInput = { element: tgt, oEventBase: { ...oEventBase }, frame: ifrSelector };
if (event.key && event.key.length === 1) {
@@ -405,20 +490,22 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
hasKeyReturn = true;
// lastSelectorWithReturn = selector;
lastNodes.return = tgt;
- const useBuffer = isBufferedValue(tgt);
- oEventToSend = {
- ...oEventBase,
- type: useBuffer ? 'bfill_value' : 'fill_value',
- value: getEffectiveValue(tgt),
- frame: ifrSelector,
- };
- if (useBuffer) {
- const stableSel = getStableInputSelector(tgt, doc);
- if (stableSel) {
- oEventToSend.css = stableSel;
+ if (!isToggleInput) {
+ const useBuffer = isBufferedValue(tgt);
+ oEventToSend = {
+ ...oEventBase,
+ type: useBuffer ? 'bfill_value' : 'fill_value',
+ value: getEffectiveValue(tgt),
+ frame: ifrSelector,
+ };
+ if (useBuffer) {
+ const stableSel = getStableInputSelector(tgt, doc);
+ if (stableSel) {
+ oEventToSend.css = stableSel;
+ }
}
+ keystrokeBuffers.delete(tgt);
}
- keystrokeBuffers.delete(tgt);
} else if (pendingCanvasInput && event.keyCode === 13) {
if (canvasKeystrokeBuffer.length > 0 && callback) {
const canvasFillEvent = {
@@ -454,7 +541,7 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
if (pendingInput && pendingInput.element === tgt) {
pendingInput = null;
}
- if (['input', 'textarea'].indexOf(nodeName) > -1) {
+ if (['input', 'textarea'].indexOf(nodeName) > -1 && !isToggleInput) {
oNodes[tgt] = tgt.value;
if (tgt === lastNodes.return) {
lastNodes.return = null;
@@ -485,6 +572,14 @@ export function interceptEvents(event, doc, ifrSelector, callback) {
lastNodes.change = tgt;
if (nodeName === 'input') {
if (nodeType === 'checkbox' || nodeType === 'radio') {
+ if (
+ lastRecordedClick &&
+ lastRecordedClick.element !== tgt &&
+ oEventBase.timestamp - lastRecordedClick.timestamp <
+ SIDE_EFFECT_CHANGE_MS
+ ) {
+ return;
+ }
oEventToSend = {
...oEventBase,
type: 'change',
diff --git a/src/pages/Content/modules/getBestElement.js b/src/pages/Content/modules/getBestElement.js
index 68e89d4..49d23d5 100644
--- a/src/pages/Content/modules/getBestElement.js
+++ b/src/pages/Content/modules/getBestElement.js
@@ -1,3 +1,5 @@
+import { dataTestAttrs } from './getCustomSelector';
+
export default function getBestElement(node, doc) {
let nodeName = null;
try {
@@ -33,12 +35,6 @@ export default function getBestElement(node, doc) {
node = svgTag;
}
// check if node or parent has some "data-test" attribute
- const dataTestAttrs = [
- 'data-test-id',
- 'data-testid',
- 'data-test',
- 'data-cy',
- ];
if (
node.matches(dataTestAttrs.map((dataAttr) => `[${dataAttr}]`).join(','))
) {
diff --git a/src/pages/Content/modules/getCustomSelector.js b/src/pages/Content/modules/getCustomSelector.js
index 0466510..1fbd069 100644
--- a/src/pages/Content/modules/getCustomSelector.js
+++ b/src/pages/Content/modules/getCustomSelector.js
@@ -1,3 +1,107 @@
+export const dataTestAttrs = [
+ 'data-test-id',
+ 'data-testid',
+ 'data-test',
+ 'data-cy',
+ 'data-qa',
+ 'data-automation-id',
+];
+const MAX_TEST_ANCESTOR_DEPTH = 10;
+// CSS-in-JS generated names (Emotion/react-select "css-1pahdxg-control",
+// styled-components "sc-...", JSS "jss12") change with component state and
+// between builds, so they can't be replayed reliably.
+const generatedClassPattern = /^(css-[a-z0-9]+|sc-[a-z0-9]+|jss\d+)(-|$)/i;
+
+export function isStableClassName(name) {
+ return (
+ !name.includes('focus') &&
+ !name.includes('highlight') &&
+ !/^[0-9]/.test(name) &&
+ !generatedClassPattern.test(name)
+ );
+}
+
+export function getTestAttrSelector(node) {
+ for (const attr of dataTestAttrs) {
+ const value = node.getAttribute(attr);
+ if (value) {
+ return `[${attr}="${CSS.escape(value)}"]`;
+ }
+ }
+ return '';
+}
+
+export function getTestAncestor(node) {
+ let parent = node.parentElement;
+ for (let i = 0; parent && i < MAX_TEST_ANCESTOR_DEPTH; i++) {
+ if (getTestAttrSelector(parent)) {
+ return parent;
+ }
+ parent = parent.parentElement;
+ }
+ return null;
+}
+
+function getUniqueScope(node) {
+ const rootNode = node.getRootNode();
+ //