fix(humanize): support display contents targets

Derive actionable geometry from rendered text and visible descendants when an element has no box of its own. Preserve existing geometry behavior for normal elements and exclude hidden descendant content. Add Python, JavaScript, and .NET unit and browser regressions covering text, nested content, visibility, and humanized clicks.
This commit is contained in:
CloakHQ committed 2026-08-25 01:48:35 +02:00
1 parent 2496cf35e3
commit f7ca311516
8 files changed
+304 -12

No files matched your search

+36 -4
View File
@@ -97,7 +97,7 @@ function __isVisible(el){
if (style.display === 'contents') {
for (let child = el.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 && __isVisible(child)) return true;
if (child.nodeType === 3 && __visibleTextNode(child)) return true;
if (child.nodeType === 3 && style.visibility === 'visible' && __visibleTextNode(child)) return true;
}
return false;
}
@@ -110,6 +110,38 @@ function __isVisible(el){
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function __visBox(el){
const style = getComputedStyle(el);
if (style && style.display === 'contents') {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
let found = false;
for (let child = el.firstChild; child; child = child.nextSibling) {
let box = null;
if (child.nodeType === 1 && __isVisible(child)) {
box = __visBox(child);
} else if (child.nodeType === 3 && style.visibility === 'visible' &&
__normWS(child.nodeValue)) {
const range = child.ownerDocument.createRange();
range.selectNode(child);
const rect = range.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
box = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
}
if (!box) continue;
found = true;
x1 = Math.min(x1, box.x);
y1 = Math.min(y1, box.y);
x2 = Math.max(x2, box.x + box.width);
y2 = Math.max(y2, box.y + box.height);
}
return found ? { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } : null;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: null;
}
const __ARIA_DISABLED_ROLES = new Set([
'application','button','composite','gridcell','group','input','link','menuitem',
'scrollbar','separator','tab','checkbox','columnheader','combobox','grid','listbox',
@@ -513,8 +545,8 @@ _SNAPSHOT_OP = r"""
const __el = __resolve(__SEL);
if (__el === 'UNSUPPORTED') return { v: __V, r: 'unsupported' };
if (!__el) return { v: __V, r: 'not_found' };
const __rc = __el.getBoundingClientRect();
const __hasBox = __rc.width > 0 && __rc.height > 0;
const __box = __visBox(__el);
const __hasBox = __box !== null;
const __tag = __el.tagName.toLowerCase();
const __enabled = __isEnabled(__el);
const __editable = __isEditable(__el, __enabled);
@@ -529,7 +561,7 @@ return {
isInput: __tag === 'input' || __tag === 'textarea' || __el.isContentEditable === true,
focused: __deepActiveElement() === __el,
checked: typeof __el.checked === 'boolean' ? __el.checked : null,
box: __hasBox ? { x: __rc.x, y: __rc.y, width: __rc.width, height: __rc.height } : null
box: __box
};
"""
+34 -2
View File
@@ -53,7 +53,7 @@ function __isVisible(el){
if (style.display === 'contents') {
for (let child = el.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 && __isVisible(child)) return true;
if (child.nodeType === 3 && __visibleTextNode(child)) return true;
if (child.nodeType === 3 && style.visibility === 'visible' && __visibleTextNode(child)) return true;
}
return false;
}
@@ -66,6 +66,38 @@ function __isVisible(el){
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function __visBox(el){
const style = getComputedStyle(el);
if (style && style.display === 'contents') {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
let found = false;
for (let child = el.firstChild; child; child = child.nextSibling) {
let box = null;
if (child.nodeType === 1 && __isVisible(child)) {
box = __visBox(child);
} else if (child.nodeType === 3 && style.visibility === 'visible' &&
__normWS(child.nodeValue)) {
const range = child.ownerDocument.createRange();
range.selectNode(child);
const rect = range.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
box = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
}
if (!box) continue;
found = true;
x1 = Math.min(x1, box.x);
y1 = Math.min(y1, box.y);
x2 = Math.max(x2, box.x + box.width);
y2 = Math.max(y2, box.y + box.height);
}
return found ? { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } : null;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: null;
}
const __ARIA_DISABLED_ROLES = new Set([
'application','button','composite','gridcell','group','input','link','menuitem',
'scrollbar','separator','tab','checkbox','columnheader','combobox','grid','listbox',
@@ -461,7 +493,7 @@ function __resolve(sel){
}
""";
private const string SnapshotOp = """
const __el=__resolve(__SEL);if(__el==='UNSUPPORTED')return {v:__V,r:'unsupported'};if(!__el)return {v:__V,r:'not_found'};const __rc=__el.getBoundingClientRect(),__hasBox=__rc.width>0&&__rc.height>0,__tag=__el.tagName.toLowerCase(),__enabled=__isEnabled(__el),__editable=__isEditable(__el,__enabled);return {v:__V,r:'ok',targetId:__targetId(__el),attached:__el.isConnected===true,visible:__isVisible(__el),enabled:__enabled,editable:__editable,isInput:__tag==='input'||__tag==='textarea'||__el.isContentEditable===true,focused:__deepActiveElement()===__el,checked:typeof __el.checked==='boolean'?__el.checked:null,box:__hasBox?{x:__rc.x,y:__rc.y,width:__rc.width,height:__rc.height}:null};
const __el=__resolve(__SEL);if(__el==='UNSUPPORTED')return {v:__V,r:'unsupported'};if(!__el)return {v:__V,r:'not_found'};const __box=__visBox(__el),__hasBox=__box!==null,__tag=__el.tagName.toLowerCase(),__enabled=__isEnabled(__el),__editable=__isEditable(__el,__enabled);return {v:__V,r:'ok',targetId:__targetId(__el),attached:__el.isConnected===true,visible:__isVisible(__el),enabled:__enabled,editable:__editable,isInput:__tag==='input'||__tag==='textarea'||__el.isContentEditable===true,focused:__deepActiveElement()===__el,checked:typeof __el.checked==='boolean'?__el.checked:null,box:__box};
""";
public const string ViewportJs="(() => ({ width: window.innerWidth, height: window.innerHeight }))()";
private static string Wrap(string s,string op)=>"(() => {\nconst __SEL = "+IsolatedWorld.JsonEncode(s)+";\n"+ResolverBody+"\n"+op+"\n})()";
@@ -77,6 +77,35 @@ public class StealthDomBrowserTests
Assert.Equal(expected, await page.EvaluateAsync<string[]>("() => window.__order"));
}
[Fact]
public async Task Display_contents_text_and_nested_geometry_are_clickable()
{
if (!BrowserAvailable) return;
await using var browser = await CloakLauncher.LaunchAsync(FastLaunchOptions());
var page = await browser.NewPageAsync();
await page.GotoAsync("https://example.com", new PageGotoOptions
{
WaitUntil = WaitUntilState.DOMContentLoaded,
});
await page.EvaluateAsync(@"() => {
document.body.innerHTML = `
<ul><li id='target' style='display:contents'>Hoy</li></ul>
<div id='nested' style='display:contents'><span>nested</span></div>`;
window.__targetClicks = 0;
window.__nestedClicks = 0;
document.querySelector('#target').addEventListener('click', () => window.__targetClicks++);
document.querySelector('#nested').addEventListener('click', () => window.__nestedClicks++);
}");
Assert.Equal(0, await page.EvaluateAsync<double>(
"() => document.querySelector('#target').getBoundingClientRect().width"));
await page.ClickAsync("text=Hoy", new PageClickOptions { Timeout = 3000 });
await page.ClickAsync("#nested", new PageClickOptions { Timeout = 3000 });
Assert.Equal(1, await page.EvaluateAsync<int>("() => window.__targetClicks"));
Assert.Equal(1, await page.EvaluateAsync<int>("() => window.__nestedClicks"));
}
[Fact]
public async Task Text_css_and_actionability_state_match_direct_selector_behavior()
{
@@ -103,6 +103,56 @@ console.log('POINTER', JSON.stringify(run(p, [t], t)));
Assert.Contains("\"hit\":true", outp);
}
[Fact]
public void DisplayContentsGeometry_RunUnderNode()
{
var node = FindNode();
if (node == null) return;
string targetJs = StealthDom.BuildSnapshotJs(".target");
string unionJs = StealthDom.BuildSnapshotJs(".union");
string hiddenJs = StealthDom.BuildSnapshotJs(".hidden-text");
string script = @"
function rect(x,y,width,height){ return {x,y,width,height}; }
function el(tag,text,box,display,visibility){
const e={tagName:tag,nodeType:1,textContent:text||'',childNodes:[],children:[],firstChild:null,
parentElement:null,previousElementSibling:null,nextSibling:null,isConnected:true,disabled:false,
readOnly:false,isContentEditable:false,type:'text',value:'',checked:false,attrs:{},
style:{display:display||'block',visibility:visibility||'visible'},box:box};
if(text){const t={nodeType:3,nodeValue:text,textContent:text,nextSibling:null,box:box};e.childNodes.push(t);e.firstChild=t;}
e.getBoundingClientRect=()=>e.box;e.getAttribute=n=>e.attrs[n]||null;e.hasAttribute=n=>n in e.attrs;
e.closest=()=>null;e.contains=o=>o===e||e.children.some(c=>c.contains(o));e.getRootNode=()=>document;
return e;
}
function append(parent,child){const prev=parent.childNodes[parent.childNodes.length-1];if(prev)prev.nextSibling=child;
child.parentElement=parent;child.previousElementSibling=parent.children[parent.children.length-1]||null;
parent.children.push(child);parent.childNodes.push(child);parent.firstChild=parent.childNodes[0];}
function run(js,target){let selected=null;const document={children:[target],head:{contains:()=>false},activeElement:null,
querySelectorAll:()=>[target],evaluate:()=>({snapshotLength:0,snapshotItem:()=>null}),elementFromPoint:()=>null,
createRange:()=>({selectNode:n=>{selected=n;},getBoundingClientRect:()=>selected.box})};
function wire(n){n.ownerDocument=document;for(const child of n.childNodes||[])wire(child);}wire(target);
return new Function('document','XPathResult','getComputedStyle','globalThis','return '+js)(
document,{ORDERED_NODE_SNAPSHOT_TYPE:7},n=>n.style,{});
}
const target=el('LI','Hoy',rect(0,0,0,0),'contents');target.childNodes[0].box=rect(5,6,25,10);
const union=el('DIV','',rect(0,0,0,0),'contents');
const left=el('SPAN','left',rect(10,20,20,10));const right=el('SPAN','right',rect(40,20,30,10));
const nested=el('SPAN','',rect(0,0,0,0),'contents');append(nested,el('SPAN','nested',rect(75,20,25,10)));
const hidden=el('SPAN','hidden',rect(1000,1000,100,100),'block','hidden');
append(union,left);append(union,right);append(union,nested);append(union,hidden);
const hiddenText=el('DIV','hidden text',rect(0,0,0,0),'contents','hidden');hiddenText.childNodes[0].box=rect(5,6,50,10);
const t=run(" + JsStr(targetJs) + @",target);const u=run(" + JsStr(unionJs) + @",union);const h=run(" + JsStr(hiddenJs) + @",hiddenText);
console.log('TARGET',JSON.stringify({visible:t.visible,box:t.box}));
console.log('UNION',JSON.stringify({visible:u.visible,box:u.box}));
console.log('HIDDEN',JSON.stringify({visible:h.visible,box:h.box}));
";
string outp = RunNode(node, script);
Assert.Contains("TARGET {\"visible\":true,\"box\":{\"x\":5,\"y\":6,\"width\":25,\"height\":10}}", outp);
Assert.Contains("UNION {\"visible\":true,\"box\":{\"x\":10,\"y\":20,\"width\":90,\"height\":10}}", outp);
Assert.Contains("HIDDEN {\"visible\":false,\"box\":null}", outp);
}
private static string JsStr(string s) => JsonSerializer.Serialize(s);
private static string? FindNode()
+34 -2
View File
@@ -51,7 +51,7 @@ function __isVisible(el){
if (style.display === 'contents') {
for (let child = el.firstChild; child; child = child.nextSibling) {
if (child.nodeType === 1 && __isVisible(child)) return true;
if (child.nodeType === 3 && __visibleTextNode(child)) return true;
if (child.nodeType === 3 && style.visibility === 'visible' && __visibleTextNode(child)) return true;
}
return false;
}
@@ -64,6 +64,38 @@ function __isVisible(el){
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
function __visBox(el){
const style = getComputedStyle(el);
if (style && style.display === 'contents') {
let x1 = Infinity, y1 = Infinity, x2 = -Infinity, y2 = -Infinity;
let found = false;
for (let child = el.firstChild; child; child = child.nextSibling) {
let box = null;
if (child.nodeType === 1 && __isVisible(child)) {
box = __visBox(child);
} else if (child.nodeType === 3 && style.visibility === 'visible' &&
__normWS(child.nodeValue)) {
const range = child.ownerDocument.createRange();
range.selectNode(child);
const rect = range.getBoundingClientRect();
if (rect.width > 0 && rect.height > 0) {
box = { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
}
if (!box) continue;
found = true;
x1 = Math.min(x1, box.x);
y1 = Math.min(y1, box.y);
x2 = Math.max(x2, box.x + box.width);
y2 = Math.max(y2, box.y + box.height);
}
return found ? { x: x1, y: y1, width: x2 - x1, height: y2 - y1 } : null;
}
const rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0
? { x: rect.x, y: rect.y, width: rect.width, height: rect.height }
: null;
}
const __ARIA_DISABLED_ROLES = new Set([
'application','button','composite','gridcell','group','input','link','menuitem',
'scrollbar','separator','tab','checkbox','columnheader','combobox','grid','listbox',
@@ -459,7 +491,7 @@ function __resolve(sel){
}
`;
const SNAPSHOT_OP=String.raw`
const __el=__resolve(__SEL);if(__el==='UNSUPPORTED')return {v:__V,r:'unsupported'};if(!__el)return {v:__V,r:'not_found'};const __rc=__el.getBoundingClientRect(),__hasBox=__rc.width>0&&__rc.height>0,__tag=__el.tagName.toLowerCase(),__enabled=__isEnabled(__el),__editable=__isEditable(__el,__enabled);return {v:__V,r:'ok',targetId:__targetId(__el),attached:__el.isConnected===true,visible:__isVisible(__el),enabled:__enabled,editable:__editable,isInput:__tag==='input'||__tag==='textarea'||__el.isContentEditable===true,focused:__deepActiveElement()===__el,checked:typeof __el.checked==='boolean'?__el.checked:null,box:__hasBox?{x:__rc.x,y:__rc.y,width:__rc.width,height:__rc.height}:null};`;
const __el=__resolve(__SEL);if(__el==='UNSUPPORTED')return {v:__V,r:'unsupported'};if(!__el)return {v:__V,r:'not_found'};const __box=__visBox(__el),__hasBox=__box!==null,__tag=__el.tagName.toLowerCase(),__enabled=__isEnabled(__el),__editable=__isEditable(__el,__enabled);return {v:__V,r:'ok',targetId:__targetId(__el),attached:__el.isConnected===true,visible:__isVisible(__el),enabled:__enabled,editable:__editable,isInput:__tag==='input'||__tag==='textarea'||__el.isContentEditable===true,focused:__deepActiveElement()===__el,checked:typeof __el.checked==='boolean'?__el.checked:null,box:__box};`;
export const VIEWPORT_JS='(() => ({ width: window.innerWidth, height: window.innerHeight }))()';
function wrap(selector:string,op:string){return `(() => {\nconst __SEL = ${JSON.stringify(selector)};\n${RESOLVER_BODY}\n${op}\n})()`;}
export function buildSnapshotJs(selector:string){return wrap(selector,SNAPSHOT_OP);}
+30
View File
@@ -1148,6 +1148,36 @@ describeIfSlow("stealth browser: selector parity and target identity", () => {
}
}, 60000);
it("clicks visible display:contents text and nested content", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, humanize: true, humanConfig: fastHumanConfig });
try {
const page = await browser.newPage();
await page.goto("https://example.com", { waitUntil: "domcontentloaded" });
await page.evaluate(() => {
document.body.innerHTML = `
<ul><li id="target" style="display:contents">Hoy</li></ul>
<div id="nested" style="display:contents"><span>nested</span></div>`;
(window as any).__targetClicks = 0;
(window as any).__nestedClicks = 0;
document.querySelector("#target")!.addEventListener("click", () => (window as any).__targetClicks++);
document.querySelector("#nested")!.addEventListener("click", () => (window as any).__nestedClicks++);
});
expect(await page.locator("#target").evaluate((element) => {
const rect = element.getBoundingClientRect();
return { width: rect.width, height: rect.height };
})).toEqual({ width: 0, height: 0 });
await page.click("text=Hoy", { timeout: 3000 });
await page.click("#nested", { timeout: 3000 });
expect(await page.evaluate(() => ({
target: (window as any).__targetClicks,
nested: (window as any).__nestedClicks,
}))).toEqual({ target: 1, nested: 1 });
} finally {
await browser.close();
}
}, 60000);
it("matches Playwright ordering across mixed and nested open Shadow DOM", async () => {
const { launch } = await import("../src/index.js");
const browser = await launch({ headless: true, humanize: true, humanConfig: fastHumanConfig });
+10 -4
View File
@@ -2,11 +2,17 @@ import { describe, it, expect } from 'vitest';
import { OK, NOT_FOUND, UNSUPPORTED, STALE, EVALUATION_FAILED, buildSnapshotJs, buildValidateJs, buildPointerJs, parseResult, evalParsed } from '../src/human/stealthDom.js';
type E = any;
function node(tag:string, text='', cls=''):E { const e:any={tagName:tag.toUpperCase(),nodeType:1,childNodes:[],children:[],parentElement:null,previousElementSibling:null,textContent:text,isConnected:true,disabled:false,readOnly:false,isContentEditable:false,type:'text',value:'',attrs:{},checked:false}; if(text)e.childNodes.push({nodeType:3,nodeValue:text}); e.getAttribute=(n:string)=>e.attrs[n]??null;e.hasAttribute=(n:string)=>n in e.attrs;e.contains=(x:E)=>x===e||e.children.some((c:E)=>c.contains(x));e.closest=()=>null;e.getRootNode=()=>e.parentElement?e.parentElement.getRootNode():doc;e.getBoundingClientRect=()=>({x:1,y:2,width:10,height:10});e.matches=(s:string)=>s==='*'||s.split('.').every((x,i)=>i?cls.split(' ').includes(x):!x||x===tag);return e; }
const defaultRect = () => ({ x: 1, y: 2, width: 10, height: 10 });
function node(tag:string, text='', cls=''):E {
const e:any={tagName:tag.toUpperCase(),nodeType:1,childNodes:[],children:[],firstChild:null,parentElement:null,previousElementSibling:null,nextSibling:null,textContent:text,isConnected:true,disabled:false,readOnly:false,isContentEditable:false,type:'text',value:'',attrs:{},checked:false,style:{display:'block',visibility:'visible'},rect:defaultRect()};
if(text){const t:any={nodeType:3,nodeValue:text,textContent:text,nextSibling:null,rect:defaultRect()};e.childNodes.push(t);e.firstChild=t;}
e.getAttribute=(n:string)=>e.attrs[n]??null;e.hasAttribute=(n:string)=>n in e.attrs;e.contains=(x:E)=>x===e||e.children.some((c:E)=>c.contains(x));e.closest=()=>null;e.getRootNode=()=>e.parentElement?e.parentElement.getRootNode():doc;e.getBoundingClientRect=()=>e.rect;e.matches=(s:string)=>s==='*'||s.split('.').every((x,i)=>i?cls.split(' ').includes(x):!x||x===tag);return e;
}
let doc:any;
function append(p:E,c:E){c.parentElement=p;c.previousElementSibling=p.children.at(-1)??null;p.children.push(c);p.childNodes.push(c);}
function run(js:string, roots:E[], point?:E, active?:E){doc={children:roots,head:{contains:()=>false},activeElement:active??null,querySelectorAll:()=>[],elementFromPoint:()=>point??null,evaluate:()=>({snapshotLength:0,snapshotItem:()=>null})}; for(const r of roots)r.getRootNode=()=>doc; const f=new Function('document','XPathResult','getComputedStyle','globalThis','return '+js);return f(doc,{ORDERED_NODE_SNAPSHOT_TYPE:7},()=>({display:'block',visibility:'visible'}),{});}
function append(p:E,c:E){const prev=p.childNodes.at(-1);if(prev)prev.nextSibling=c;c.nextSibling=null;c.parentElement=p;c.previousElementSibling=p.children.at(-1)??null;p.children.push(c);p.childNodes.push(c);p.firstChild=p.childNodes[0]??null;}
function wireOwner(n:E){n.ownerDocument=doc;for(const child of n.childNodes??[])wireOwner(child);}
function run(js:string, roots:E[], point?:E, active?:E){let selected:any=null;doc={children:roots,head:{contains:()=>false},activeElement:active??null,querySelectorAll:()=>[],elementFromPoint:()=>point??null,evaluate:()=>({snapshotLength:0,snapshotItem:()=>null}),createRange:()=>({selectNode:(n:any)=>{selected=n;},getBoundingClientRect:()=>selected?.rect??{x:0,y:0,width:0,height:0}})};for(const r of roots){r.getRootNode=()=>doc;wireOwner(r);}const f=new Function('document','XPathResult','getComputedStyle','globalThis','return '+js);return f(doc,{ORDERED_NODE_SNAPSHOT_TYPE:7},(e:E)=>e.style??{display:'block',visibility:'visible'},{});}
describe('protocol',()=>{it('strictly rejects malformed payloads and evaluator failures',async()=>{expect(parseResult({v:1,r:OK,targetId:1})).toMatchObject({status:OK});for(const x of [null,{}, {v:2,r:OK,targetId:1},{v:1,r:OK},{v:1,r:STALE,targetId:1.5}])expect(parseResult(x)).toEqual({status:EVALUATION_FAILED});expect(parseResult({v:1,r:NOT_FOUND})).toEqual({status:NOT_FOUND});expect(parseResult({v:1,r:UNSUPPORTED})).toEqual({status:UNSUPPORTED});expect(await evalParsed({evaluate:async()=>{throw Error()}},'x')).toEqual({status:EVALUATION_FAILED});});});
describe('shipped resolver',()=>{it('executes descendant, child, adjacent, sibling, lists and structural has-text',()=>{const root=node('div');const a=node('span','one','a'), b=node('button','Save','b'), c=node('button','Other','c');append(root,a);append(root,b);append(root,c);for(const s of ['div button','div > button','.a + button','.a ~ button','.no, button:has-text("Save")'])expect(run(buildSnapshotJs(s),[root]).r).toBe(OK);});it('executes exact/lax/regex text and excludes script text (#512)',()=>{const r=node('div'), s=node('span',' Hello World '), script=node('script','secret');append(r,s);append(r,script);expect(run(buildSnapshotJs('text="Hello World"'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=hello world'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=/world/i'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=secret'),[r]).r).toBe(NOT_FOUND);});it('executes native and ARIA state',()=>{const root=node('div'),disabled=node('button','','disabled'),ariaDisabled=node('button','','aria-disabled'),readonly=node('div','','readonly');disabled.attrs.disabled='';ariaDisabled.attrs.role='button';ariaDisabled.attrs['aria-disabled']='true';readonly.attrs.role='textbox';readonly.attrs['aria-readonly']='true';append(root,disabled);append(root,ariaDisabled);append(root,readonly);expect(run(buildSnapshotJs('.disabled'),[root]).enabled).toBe(false);expect(run(buildSnapshotJs('.aria-disabled'),[root]).enabled).toBe(false);expect(run(buildSnapshotJs('.readonly'),[root]).editable).toBe(false);});it('executes deep shadow ordering, focus, hit testing and stale validation',()=>{const host=node('div'), light=node('button','light'), shadow=node('button','shadow');append(host,light);const disabled=node('button');disabled.attrs.disabled='';append(host,disabled);host.shadowRoot={children:[shadow],elementFromPoint:()=>shadow,activeElement:shadow};shadow.getRootNode=()=>host.shadowRoot;shadow.parentElement=null;expect(run(buildSnapshotJs('button >> nth=2'),[host],host,host).focused).toBe(true);expect(run(buildSnapshotJs('button >> nth=1'),[host]).enabled).toBe(false);expect(run(buildSnapshotJs('button'),[host],host,host).focused).toBe(false);expect(run(buildPointerJs('button >> nth=2',1,2),[host],host).hit).toBe(true);expect(run(buildValidateJs('button',999,1,2),[host]).r).toBe(STALE);});});
describe('shipped resolver',()=>{it('unions only visible display:contents geometry, including nested content',()=>{const target=node('li','Hoy','target');target.style.display='contents';target.rect={x:0,y:0,width:0,height:0};target.childNodes[0].rect={x:5,y:6,width:25,height:10};const targetResult=run(buildSnapshotJs('.target'),[target]);expect(targetResult).toMatchObject({r:OK,visible:true,box:{x:5,y:6,width:25,height:10}});const union=node('div','','union');union.style.display='contents';union.rect={x:0,y:0,width:0,height:0};const left=node('span','left');left.rect={x:10,y:20,width:20,height:10};const right=node('span','right');right.rect={x:40,y:20,width:30,height:10};const nested=node('span','','nested');nested.style.display='contents';nested.rect={x:0,y:0,width:0,height:0};const nestedChild=node('span','nested');nestedChild.rect={x:75,y:20,width:25,height:10};append(nested,nestedChild);const hidden=node('span','hidden');hidden.style.visibility='hidden';hidden.rect={x:1000,y:1000,width:100,height:100};append(union,left);append(union,right);append(union,nested);append(union,hidden);expect(run(buildSnapshotJs('.union'),[union])).toMatchObject({r:OK,visible:true,box:{x:10,y:20,width:90,height:10}});const hiddenText=node('div','hidden text','hidden-text');hiddenText.style={display:'contents',visibility:'hidden'};hiddenText.rect={x:0,y:0,width:0,height:0};hiddenText.childNodes[0].rect={x:5,y:6,width:50,height:10};expect(run(buildSnapshotJs('.hidden-text'),[hiddenText])).toMatchObject({r:OK,visible:false,box:null});});it('executes descendant, child, adjacent, sibling, lists and structural has-text',()=>{const root=node('div');const a=node('span','one','a'), b=node('button','Save','b'), c=node('button','Other','c');append(root,a);append(root,b);append(root,c);for(const s of ['div button','div > button','.a + button','.a ~ button','.no, button:has-text("Save")'])expect(run(buildSnapshotJs(s),[root]).r).toBe(OK);});it('executes exact/lax/regex text and excludes script text (#512)',()=>{const r=node('div'), s=node('span',' Hello World '), script=node('script','secret');append(r,s);append(r,script);expect(run(buildSnapshotJs('text="Hello World"'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=hello world'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=/world/i'),[r]).r).toBe(OK);expect(run(buildSnapshotJs('text=secret'),[r]).r).toBe(NOT_FOUND);});it('executes native and ARIA state',()=>{const root=node('div'),disabled=node('button','','disabled'),ariaDisabled=node('button','','aria-disabled'),readonly=node('div','','readonly');disabled.attrs.disabled='';ariaDisabled.attrs.role='button';ariaDisabled.attrs['aria-disabled']='true';readonly.attrs.role='textbox';readonly.attrs['aria-readonly']='true';append(root,disabled);append(root,ariaDisabled);append(root,readonly);expect(run(buildSnapshotJs('.disabled'),[root]).enabled).toBe(false);expect(run(buildSnapshotJs('.aria-disabled'),[root]).enabled).toBe(false);expect(run(buildSnapshotJs('.readonly'),[root]).editable).toBe(false);});it('executes deep shadow ordering, focus, hit testing and stale validation',()=>{const host=node('div'), light=node('button','light'), shadow=node('button','shadow');append(host,light);const disabled=node('button');disabled.attrs.disabled='';append(host,disabled);host.shadowRoot={children:[shadow],elementFromPoint:()=>shadow,activeElement:shadow};shadow.getRootNode=()=>host.shadowRoot;shadow.parentElement=null;expect(run(buildSnapshotJs('button >> nth=2'),[host],host,host).focused).toBe(true);expect(run(buildSnapshotJs('button >> nth=1'),[host]).enabled).toBe(false);expect(run(buildSnapshotJs('button'),[host],host,host).focused).toBe(false);expect(run(buildPointerJs('button >> nth=2',1,2),[host],host).hit).toBe(true);expect(run(buildValidateJs('button',999,1,2),[host]).r).toBe(STALE);});});
+81
View File
@@ -70,6 +70,87 @@ def test_text_selector_ignores_hidden_head_script_and_clicks_visible_target():
browser.close()
@pytest.mark.slow
def test_display_contents_uses_rendered_content_geometry():
"""A boxless display:contents target uses its rendered text/child union."""
from cloakbrowser import launch
from cloakbrowser.human.stealth_dom import build_snapshot_js
browser = launch(headless=False, humanize=True, release_channel="preview")
try:
page = browser.new_page()
page.goto("https://example.com", wait_until="domcontentloaded")
page.evaluate(
"""() => {
document.body.innerHTML = `
<ul><li id="target" style="display:contents">Hoy</li></ul>
<div id="union" style="display:contents">
<span id="left">left</span><span id="right">right</span>
<span id="nested" style="display:contents">
<span id="nested-child">nested</span>
</span>
<span id="hidden" style="visibility:hidden;position:absolute;
left:1000px;top:1000px;font-size:100px">hidden</span>
</div>
<div id="hidden-text" style="display:contents;visibility:hidden">
hidden text
</div>`;
window.targetClicks = 0;
window.nestedClicks = 0;
document.querySelector('#target').addEventListener(
'click', () => window.targetClicks++
);
document.querySelector('#nested').addEventListener(
'click', () => window.nestedClicks++
);
}"""
)
own_rect = page.evaluate(
"""() => {
const rect = document.querySelector('#target').getBoundingClientRect();
return {width: rect.width, height: rect.height};
}"""
)
assert own_rect == {"width": 0, "height": 0}
target = page._stealth_world.evaluate(build_snapshot_js("text=Hoy"))
assert target["visible"] is True
assert target["box"]["width"] > 0
assert target["box"]["height"] > 0
union = page._stealth_world.evaluate(build_snapshot_js("#union"))
assert union["visible"] is True
rects = page.evaluate(
"""() => Object.fromEntries(
['left', 'right', 'nested-child', 'hidden'].map(id => {
const r = document.querySelector('#' + id).getBoundingClientRect();
return [id, {x: r.x, y: r.y, width: r.width, height: r.height}];
})
)"""
)
union_right = union["box"]["x"] + union["box"]["width"]
union_bottom = union["box"]["y"] + union["box"]["height"]
for child_id in ("left", "right", "nested-child"):
child = rects[child_id]
assert union["box"]["x"] <= child["x"]
assert union["box"]["y"] <= child["y"]
assert union_right >= child["x"] + child["width"]
assert union_bottom >= child["y"] + child["height"]
assert union_right < rects["hidden"]["x"]
hidden_text = page._stealth_world.evaluate(build_snapshot_js("#hidden-text"))
assert hidden_text["visible"] is False
assert hidden_text["box"] is None
page.click("text=Hoy", timeout=3000)
page.click("#nested", timeout=3000)
assert page.evaluate("window.targetClicks") == 1
assert page.evaluate("window.nestedClicks") == 1
finally:
browser.close()
@pytest.mark.slow
def test_text_selector_semantics_match_playwright():
"""Text regex, exact fragments, input values, and normalization stay in parity."""