Skip to content

Suite de Text Zoom — Dev ​

Actualizado: 10/7/25

Ticket: https://github.com/a11ysolutions/a11y-issue-detector/issues/407

Estructura de la suite ​

python
TEXT_ZOOM_TEST_SUITE = [
    TestCase(
        [
            IsZoomeable(),
            SetViewport(width=1366, height=768),
            TakeScreenshot("before_zoom", full_page_on_missing_element=True),
            GetAllTextElements(store_key="text_elements_before_zoom"),
            CaptureAccessibilityTree(
                store_key="ax_tree_before_zoom", save_to_file="ax_tree_before_zoom.json"
            ),
            ApplyRealZoom(zoom_percent=200),
            TakeScreenshot("after_zoom", full_page_on_missing_element=True),
            GetAllTextElements(store_key="text_elements_after_zoom"),
            CaptureAccessibilityTree(
                store_key="ax_tree_after_zoom", save_to_file="ax_tree_after_zoom.json"
            ),
            DetectMissingTextElements(
                store_key="text_elements_before_zoom",
                store_key_2="text_elements_after_zoom",
            ),
        ],
        "Text zoom test using real 200% browser zoom (extension) at 1366x768 with content and functionality verification (WCAG 1.4.4) - Complete accessibility approach",
    ),
]

Core ​

  • Usa el enfoque del árbol de accesibilidad
  • Emula el zoom del navegador mediante una miniextensión
  • Firmas, deduplicación y sistema semántico
  • Contexto persistente solo para los tests relacionados con zoom

Cómo funciona ​

1. Comprobar si la página admite zoom y fijar el viewport ​

As described at:

2. Obtener todos los elementos de texto ​

Filters all text nodes using _is_text_element(element) and then the node is mapped into element_data and captured in text_elements::

2.1 Estructura del nodo del árbol AX en bruto ​

json
{
  "nodeId": "3969",
  "backendDOMnodeId": 3969,
  "role": {"type": "role", "value": "button"},
  "name": {
    "type": "computedString",
    "value": "Seleccionar divisa",
    "sources": [
      {"type": "relatedElement", "attribute": "aria-labelledby"},
      {
        "type": "attribute",
        "attribute": "aria-label",
        "attributeValue": {"type": "string", "value": "Seleccionar divisa"}
      },
      {"type": "contents", "value": {"value": "$ USD"}, "superseded": true}
    ]
  }
}

2.2 Datos del nodo ya mapeados ​

python
element_data = {
  "nodeId": node.get("nodeId"),
  "backendNodeId": backend_id,
  "backendDOMNodeId": node.get("backendDOMNodeId"),
  "name": node.get("name", {}).get("value", ""),
  "role": node.get("role", {}).get("value", ""),
  "description": node.get("description", {}).get("value", ""),
  "value": node.get("value", {}).get("value", ""),
  "properties": node.get("properties", []),
  "text_content": self._get_text_content_from_ax_data(node),
  "name_sources": node.get("name", {}).get("sources", []),
}
text_elements.append(element_data)

2.3 element_data en bruto ​

python
{
    "nodeId": "3969",
    "backendNodeId": 3969,
    "backendDOMNodeId": 3969,
    "name": "Seleccionar divisa",
    "role": "button",
    "name_sources": [
        {
            "type": "relatedElement",
            "attribute": "aria-labelledby"
        },
        {
            "type": "attribute",
            "value": {
                "type": "computedString",
                "value": "Seleccionar divisa"
            },
            "attribute": "aria-label",
            "attributeValue": {
                "type": "string",
                "value": "Seleccionar divisa"
            }
        },
        {
            "type": "relatedElement",
            "superseded": True,
            "nativeSource": "label"
        },
        {
            "type": "contents",
            "value": {
                "type": "computedString",
                "value": "$ USD"
            },
            "superseded": True
        },
        {
            "type": "attribute",
            "attribute": "title",
            "superseded": True
        }
    ],
    "description": "",
    "value": "",
    "properties": [...],
    "text_content": "Seleccionar divisa"
}

3. Deduplicación antes del zoom ​

3.1 Primera pasada — agrupación por nombre ​

python
elements_by_name = {
    "Seleccionar divisa": [element0],  
    "Search": [element1, element2],        # 2 elements: button and StaticText
    # ... other groups
}
python
# Just 1 element, no duplicateds
if len(element_group) == 1:  # True
    first_pass_elements.extend(element_group)  # Keep it equal

# first_pass_elements:
first_pass_elements = [
    {
        "nodeId": "3969",
        "name": "Seleccionar divisa",
        "role": "button",
        "name_sources": [...],
        "backendDOMNodeId": 3969
    },
    # ... other elements
]

3.2 Segunda pasada — deduplicación por firma ​

python
# Create signature
signature = self._create_text_element_signature(element_data)
# Signature: "role:button|name:Seleccionar divisa|bid:3969"

if signature not in signature_to_element:  # True (first time)
    signature_to_element[signature] = element_data
    final_elements.append(element_data)  # ← Add it

# final_elements now contains the element:
final_elements = [
    {
        "nodeId": "3969",
        "name": "Seleccionar divisa",
        "role": "button",
        "name_sources": [...],
        "backendDOMNodeId": 3969
    },
    # ... another elements
]

4. Aplicar el zoom del navegador ​

DOM/AX Tree re-organized, framework re-renders . . .

5. Captura después del zoom ​

json
{
    "nodeId": "3875",                    // ← it changes
    "backendDOMNodeId": 3875,            // ← it changes
    "role": {"type": "role", "value": "button"},
    "name": {
        "type": "computedString",
        "value": "Seleccionar divisa",   // ← same
        "sources": [                      // ← same content
            {"type": "relatedElement", "attribute": "aria-labelledby"},
            {
                "type": "attribute",
                "attribute": "aria-label",
                "attributeValue": {"type": "string", "value": "Seleccionar divisa"}
            },
            {"type": "contents", "value": {"value": "$ USD"}, "superseded": true}
        ]
    }
}

5.1 Procesado del elemento (igual que antes) ​

python
element_data_after = {
    "nodeId": "3875",                    # ← it changes
    "backendNodeId": 3875,               # ← it changes
    "backendDOMNodeId": 3875,            # ← it changes
    "name": "Seleccionar divisa",        # ← same
    "role": "button",                    # ← same
    "name_sources": [                    # ← same content
        {"type": "relatedElement", "attribute": "aria-labelledby"},
        {"type": "attribute", "attribute": "aria-label",
         "attributeValue": {"type": "string", "value": "Seleccionar divisa"}},
        {"type": "contents", "value": {"value": "$ USD"}, "superseded": True}
    ],
    "description": "",
    "value": "",
    "properties": [...],
    "text_content": "Seleccionar divisa"
}

5.2 Deduplicación (igual que antes) ​

python
# generated signature:
signature_after = "role:button|name:Seleccionar divisa|bid:3875"  # ← Diferent ID

elements_after_deduplicated = [
    element_data_after,
    # ... another elements 
]

6. Comparación y detección ​

6.1 Conjuntos de firmas ​

python
elements_before_set = {
    "role:button|name:Seleccionar divisa|bid:3969",  # ← BEFORE
    "role:textbox|name:Search|bid:6",
    # ... another signatures
}

elements_after_set = {
    "role:button|name:Seleccionar divisa|bid:3875",  # ← AFTER (diferent ID)
    "role:textbox|name:Search|bid:6",
    # ... otras firmas
}

# Missing elements:
missing_signatures = elements_before_set - elements_after_set
# missing_signatures = {"role:button|name:Seleccionar divisa|bid:3969"}

6.2 Iterar sobre los elementos ausentes ​

python
for elem in elements_before_deduplicated:
    if self._create_text_element_signature(elem) in missing_signatures:
        # elem structure:
        elem = {
            "nodeId": "3969",
            "name": "Seleccionar divisa",
            "role": "button",
            "name_sources": [...],  # ← TODAVÍA AQUÍ
            "backendDOMNodeId": 3969
        }
        
        backend_id = elem.get("backendNodeId") or elem.get("backendDOMNodeId")
        # backend_id = 3969

6.3 Existencia por backend ID ​

python
exists_after = any(
    (e.get("backendNodeId") or e.get("backendDOMNodeId")) == 3969
    for e in elements_after_deduplicated
)
# exists_after = False (because now it is 3875, not 3969)

6.4 Coincidencia semántica ​

python
semantic_match = self._find_semantic_match(elem, elements_after_deduplicated)

# Inisde _find_semantic_match:
# elem = {
#     "name": "Seleccionar divisa",
#     "role": "button",
#     "name_sources": [...],
#     "backendDOMNodeId": 3969
# }

# Extract values
role_before = elem.get("role", "")  # "button"
name_before = elem.get("name", "")  # "Seleccionar divisa"
aria_before = self._extract_aria_label(elem)  # "Seleccionar divisa"

# Iterar sobre elements_after_deduplicated
for element_after in elements_after_deduplicated:
    # element_after = {
    #     "name": "Seleccionar divisa",
    #     "role": "button",
    #     "name_sources": [...],
    #     "backendDOMNodeId": 3875
    # }
    
    role_after = element_after.get("role", "")  # "button"
    name_after = element_after.get("name", "")  # "Seleccionar divisa"
    aria_after = self._extract_aria_label(element_after)  # "Seleccionar divisa"
    
    if (role_after == role_before           # True
        and name_after == name_before       # True
        and aria_after == aria_before):     # True
        
        return element_after  # ✅ FINDED MATCH!

# semantic_match = {
#     "nodeId": "3875",
#     "name": "Seleccionar divisa",
#     "role": "button",
#     "name_sources": [...],
#     "backendDOMNodeId": 3875  # ← NEW ID
# }

6.5 Update Backend ID: ​

python
if semantic_match:
    new_backend_id = semantic_match.get("backendNodeId") or semantic_match.get("backendDOMNodeId")
    # new_backend_id = 3875
    
    if new_backend_id:
        backend_id = new_backend_id  # backend_id = 3875 (updated)
        exists_after = True           # ✅ now it exists

6.6 Verify visibility: ​

python
if exists_after:  # True
    logger.log("Element Seleccionar divisa still exists after zoom (backend_id: 3875), checking visibility")
    
    # Resolving with New backend_id (3875)
    dom_element = context.page.resolve_backend_node_to_element(3875)
    
    if dom_element and dom_element.is_visible():
        logger.log("Element Seleccionar divisa is visible after zoom - not reporting as lost")
        continue  # ✅ It is not reported