Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ All notable changes to the full browser extension will be documented in this fil

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).

## Umreleased
## Unreleased
Comment thread
kingthorin marked this conversation as resolved.
### Added
- Report `textarea` and `select` elements.
- Report interactable state of elements.

## 0.1.9 - 2026-05-15

Expand Down
88 changes: 82 additions & 6 deletions source/ContentScript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,10 @@ import {
ReportedObject,
ReportedStorage,
ReportedEvent,
InteractableState,
} from '../types/ReportedModel';
import Recorder from './recorder';
import {hasPointerStyle, getInteractableState} from './util';
import {
IS_FULL_EXTENSION,
LOCAL_STORAGE,
Expand All @@ -43,6 +45,10 @@ const reportedObjects = new Set<string>();

const reportedEvents: {[key: string]: ReportedEvent} = {};

const elementInteractableState = new WeakMap<Element, InteractableState>();

let trackedElementRefs: WeakRef<Element>[] = [];

const recorder = new Recorder();

function reportStorage(
Expand Down Expand Up @@ -141,13 +147,25 @@ function reportEvent(event: ReportedEvent): void {
}
}

function trackInteractableElement(re: ReportedElement, element: Element): void {
const state = getInteractableState(element);
re.interactable = state;

if (!elementInteractableState.has(element)) {
elementInteractableState.set(element, state);
trackedElementRefs.push(new WeakRef(element));
}
}

function reportPageForms(
doc: Document,
fn: (re: ReportedObject) => void
): void {
const url = window.location.href;
Array.prototype.forEach.call(doc.forms, (form: HTMLFormElement) => {
fn(new ReportedElement(form, url));
const re = new ReportedElement(form, url);
trackInteractableElement(re, form);
fn(re);
});
}

Expand All @@ -159,7 +177,9 @@ function reportPageLinks(
Array.prototype.forEach.call(
doc.links,
(link: HTMLAnchorElement | HTMLAreaElement) => {
fn(new ReportedElement(link, url));
const re = new ReportedElement(link, url);
trackInteractableElement(re, link);
fn(re);
}
);
}
Expand All @@ -170,7 +190,9 @@ function reportElements(
): void {
const url = window.location.href;
Array.prototype.forEach.call(collection, (element: Element) => {
fn(new ReportedElement(element, url));
const re = new ReportedElement(element, url);
trackInteractableElement(re, element);
fn(re);
});
}

Expand Down Expand Up @@ -199,9 +221,10 @@ function reportPointerElements(
tagName !== 'a' &&
element instanceof Element
) {
const compStyles = window.getComputedStyle(element, 'hover');
if (compStyles.getPropertyValue('cursor') === 'pointer') {
fn(new ReportedElement(element, url));
if (hasPointerStyle(element)) {
const re = new ReportedElement(element, url);
trackInteractableElement(re, element);
fn(re);
}
}
});
Expand Down Expand Up @@ -275,6 +298,59 @@ function enableExtension(): void {
subtree: true,
});

const attributeObserver = new MutationObserver(() => {
withZapEnableSetting(async () => {
const pendingAnimations = document.getAnimations().map((a) => a.finished);
if (pendingAnimations.length > 0) {
await Promise.race([
Promise.all(pendingAnimations),
new Promise<void>((resolve) => {
setTimeout(resolve, 2000);
}),
]);
}

const alive: WeakRef<Element>[] = [];
for (const ref of trackedElementRefs) {
const el = ref.deref();
if (el) {
alive.push(ref);
const newState = getInteractableState(el);
const prevState = elementInteractableState.get(el);
if (
prevState !== undefined &&
(prevState.visible !== newState.visible ||
prevState.enabled !== newState.enabled ||
prevState.pointer !== newState.pointer)
) {
elementInteractableState.set(el, newState);
const re = new ReportedElement(
el,
window.location.href,
'nodeChanged'
);
re.interactable = newState;
sendObjectToZAP(re);
}
}
}
trackedElementRefs = alive;
});
});
attributeObserver.observe(document, {
attributes: true,
attributeFilter: [
'aria-disabled',
'aria-hidden',
'class',
'disabled',
'hidden',
'href',
'style',
],
subtree: true,
});

setInterval(() => {
// Have to poll to pickup storage changes in a timely fashion
reportAllStorage();
Expand Down
54 changes: 53 additions & 1 deletion source/ContentScript/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
*/
import {ElementLocator} from '../types/zestScript/ZestStatement';
import {ZAP_FLOATING_DIV} from '../utils/constants';
import {InteractableState} from '../types/ReportedModel';

const dynamicClassElements = new WeakSet<Element>();
const inputClassSnapshots = new WeakMap<Element, string>();
Expand Down Expand Up @@ -173,4 +174,55 @@ function getPath(
return path;
}

export {getPath, markClassAsDynamic, snapshotInputClass};
function hasPointerStyle(el: Element): boolean {
const compStyles = window.getComputedStyle(el, 'hover');
return compStyles.getPropertyValue('cursor') === 'pointer';
}

function isHiddenByParent(el: HTMLElement): boolean {
let node: HTMLElement | null = el;
while (node) {
const nodeStyle = node.ownerDocument.defaultView?.getComputedStyle(node);
if (
nodeStyle?.display === 'none' ||
nodeStyle?.opacity === '0' ||
(nodeStyle as CSSStyleDeclaration & {contentVisibility?: string})
?.contentVisibility === 'hidden'
) {
return true;
}
node = node.parentElement;
}
return false;
}

function getInteractableState(el: Element): InteractableState {
if (!(el instanceof HTMLElement)) {
return {visible: false, enabled: false, pointer: false};
}

const enabled =
!(el as HTMLElement & {disabled?: boolean}).disabled &&
el.getAttribute('aria-disabled') !== 'true';

const s = window.getComputedStyle(el);
const visible =
el.getAttribute('aria-hidden') !== 'true' &&
s.display !== 'none' &&
s.visibility !== 'hidden' &&
s.visibility !== 'collapse' &&
s.opacity !== '0' &&
(el.offsetWidth > 0 || el.offsetHeight > 0) &&
!isHiddenByParent(el);
const pointer = hasPointerStyle(el);

return {visible, enabled, pointer};
}

export {
getPath,
hasPointerStyle,
getInteractableState,
markClassAsDynamic,
snapshotInputClass,
};
15 changes: 12 additions & 3 deletions source/types/ReportedModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
interface InteractableState {
visible: boolean;
enabled: boolean;
pointer: boolean;
}

class ReportedObject {
public timestamp: number;

Expand Down Expand Up @@ -100,9 +106,11 @@ class ReportedElement extends ReportedObject {

public formId: number | null;

public constructor(element: Element, url: string) {
public interactable: InteractableState | undefined;

public constructor(element: Element, url: string, type = 'nodeAdded') {
super(
'nodeAdded',
type,
element.tagName,
element.id,
element.nodeName,
Expand Down Expand Up @@ -146,7 +154,7 @@ class ReportedElement extends ReportedObject {

public toShortString(): string {
return JSON.stringify(this, function replacer(k: string, v: string) {
if (k === 'timestamp') {
if (k === 'timestamp' || k === 'interactable') {
// No point reporting the same element lots of times
return undefined;
}
Expand Down Expand Up @@ -177,3 +185,4 @@ class ReportedEvent {
}

export {ReportedElement, ReportedObject, ReportedStorage, ReportedEvent};
export type {InteractableState};
Loading
Loading