diff --git a/docs/en/deep-dive/architecture/tab-domain.md b/docs/en/deep-dive/architecture/tab-domain.md index 474c1574..d444fac1 100644 --- a/docs/en/deep-dive/architecture/tab-domain.md +++ b/docs/en/deep-dive/architecture/tab-domain.md @@ -222,7 +222,7 @@ except KeyError: raise TopLevelTargetRequired(...) # Guide users to WebElement.take_screenshot() ``` -**Design implication:** IFrame tabs (created via `get_frame()`) inherit all Tab methods but screenshots fail by design. Alternative: `WebElement.take_screenshot()` works for iframe content. +**Design implication:** Historically, Pydoll created dedicated Tab instances for iframes. The new model keeps iframe interaction inside `WebElement`, so screenshots and other helpers should target elements within the frame (for example, `await iframe_element.find(...).take_screenshot()`). **PDF Generation:** `Page.printToPDF` returns base64-encoded data. Pydoll abstracts file I/O, but underlying data is always base64 (CDP spec). diff --git a/docs/en/deep-dive/fundamentals/iframes-and-contexts.md b/docs/en/deep-dive/fundamentals/iframes-and-contexts.md new file mode 100644 index 00000000..cf81673a --- /dev/null +++ b/docs/en/deep-dive/fundamentals/iframes-and-contexts.md @@ -0,0 +1,1292 @@ +# Iframes, OOPIFs and Execution Contexts (Deep Dive) + +Understanding how browser automation handles iframes is critical for building robust automation tools. This comprehensive guide explores the technical foundations of iframe handling in Pydoll, covering the Document Object Model (DOM), Chrome DevTools Protocol (CDP) mechanics, execution contexts, isolated worlds, and the sophisticated resolution pipeline that makes iframe interaction seamless. + +!!! info "Practical usage first" + If you just need to use iframes in your automation scripts, start with the feature guide: **Features → Automation → IFrames**. + This deep dive explains the architectural decisions, protocol nuances, and internal implementation details. + +--- + +## Table of Contents + +1. [Foundation: The Document Object Model (DOM)](#foundation-the-document-object-model-dom) +2. [What are Iframes and Why They Matter](#what-are-iframes-and-why-they-matter) +3. [The Challenge: Out-of-Process Iframes (OOPIFs)](#the-challenge-out-of-process-iframes-oopifs) +4. [Chrome DevTools Protocol and Frame Management](#chrome-devtools-protocol-and-frame-management) +5. [Execution Contexts and Isolated Worlds](#execution-contexts-and-isolated-worlds) +6. [CDP Identifiers Reference](#cdp-identifiers-reference) +7. [Pydoll's Resolution Pipeline](#pydolls-resolution-pipeline) +8. [Session Routing and Flattened Mode](#session-routing-and-flattened-mode) +9. [Implementation Deep Dive](#implementation-deep-dive) +10. [Performance Considerations](#performance-considerations) +11. [Failure Modes and Debugging](#failure-modes-and-debugging) + +--- + +## Foundation: The Document Object Model (DOM) + +Before diving into iframes, we must understand the DOM—the tree structure that represents an HTML document in memory. + +### What is the DOM? + +The **Document Object Model** is a programming interface for HTML and XML documents. It represents the page structure as a tree of nodes, where each node corresponds to a part of the document: + +- **Element nodes**: HTML tags like `
`, ` +

More parent content

+ +``` + +### Common Use Cases + +| Use Case | Description | Example | +|----------|-------------|---------| +| **Third-party widgets** | Embed external content safely | Payment forms, social media feeds, chat widgets | +| **Content isolation** | Sandbox untrusted content | User-generated HTML, advertisements | +| **Modular architecture** | Reusable components | Dashboard widgets, plugin systems | +| **Cross-origin content** | Load resources from different domains | Maps, video players, analytics dashboards | + +### Security Model: Same-Origin Policy + +The browser enforces a **Same-Origin Policy** for iframes: + +- **Same-origin iframes**: Parent can access iframe's DOM via JavaScript (`iframe.contentDocument`) +- **Cross-origin iframes**: Parent cannot access iframe's DOM directly (security restriction) + +This security boundary is why automation tools need special mechanisms (like CDP) to interact with iframe content. + +!!! warning "Important for automation" + Traditional JavaScript-based automation (like Selenium's early approaches) cannot directly access cross-origin iframe content due to browser security. CDP operates at a lower level, bypassing this limitation for debugging purposes. + +--- + +## The Challenge: Out-of-Process Iframes (OOPIFs) + +### What are OOPIFs? + +Modern Chromium uses **site isolation** for security and stability. This means different origins may be rendered in separate OS processes. An iframe from a different origin becomes an **Out-of-Process Iframe (OOPIF)**. + +```mermaid +graph LR + subgraph "Process 1: example.com" + MainPage[Main Page DOM] + end + + subgraph "Process 2: widget.com" + IframeDOM[Iframe DOM] + end + + MainPage -.Process boundary.-> IframeDOM +``` + +### Why OOPIFs Complicate Automation + +| Aspect | In-Process Iframe | Out-of-Process Iframe (OOPIF) | +|--------|-------------------|-------------------------------| +| **DOM access** | Shared document tree in memory | Separate target with own document | +| **Command routing** | Single connection | Requires target attachment and session routing | +| **Frame tree** | All frames in one tree | Root frame + separate targets for OOPIFs | +| **JavaScript context** | Same execution context | Different execution context per process | +| **CDP communication** | Direct commands | Commands must include `sessionId` | + +### The Traditional Approach (Manual Context Switching) + +Without sophisticated handling, automating OOPIFs requires: + +```python +# Traditional (manual) approach with other tools +main_page = browser.get_page() +iframe_element = main_page.find_element_by_id("iframe-id") + +# Must manually switch context +driver.switch_to.frame(iframe_element) + +# Now commands target the iframe +button = driver.find_element_by_id("button-in-iframe") +button.click() + +# Must manually switch back +driver.switch_to.default_content() +``` + +**Problems with this approach:** + +1. **Developer burden**: Every iframe requires explicit context management +2. **Nested iframes**: Each level needs another switch +3. **OOPIF detection**: Hard to know when manual attachment is needed +4. **Error-prone**: Forget to switch back → subsequent commands fail +5. **Not composable**: Helper functions must know their iframe context + +### Pydoll's Solution: Transparent Context Resolution + +Pydoll eliminates manual context switching by resolving iframe contexts automatically: + +```python +# Pydoll approach (no manual switching) +iframe = await tab.find(id="iframe-id") +button = await iframe.find(id="button-in-iframe") +await button.click() + +# Nested iframes? Same pattern +outer = await tab.find(id="outer-iframe") +inner = await outer.find(tag_name="iframe") +button = await inner.find(text="Submit") +await button.click() +``` + +The complexity is handled internally. Let's explore how. + +--- + +## Chrome DevTools Protocol and Frame Management + +As discussed in [Deep Dive → Fundamentals → Chrome DevTools Protocol](./cdp.md), CDP provides comprehensive browser control via WebSocket communication. Frame management is spread across multiple CDP domains. + +### Relevant CDP Domains + +#### 1. **Page Domain** + +Manages page lifecycle, frames, and navigation. + +**Key methods:** + +- `Page.getFrameTree()`: Returns the hierarchical structure of all frames in a page + ```json + { + "frameTree": { + "frame": { + "id": "main-frame-id", + "url": "https://example.com", + "securityOrigin": "https://example.com", + "mimeType": "text/html" + }, + "childFrames": [ + { + "frame": { + "id": "child-frame-id", + "parentId": "main-frame-id", + "url": "https://widget.com/embed" + } + } + ] + } + } + ``` + +- `Page.createIsolatedWorld(frameId, worldName)`: Creates a new JavaScript execution context in a specific frame + ```json + { + "executionContextId": 42 + } + ``` + +**Pydoll usage:** + +```python +# From pydoll/elements/web_element.py +@staticmethod +async def _get_frame_tree_for( + handler: ConnectionHandler, session_id: Optional[str] +) -> FrameTree: + """Get the Page frame tree for the given connection/target.""" + command = PageCommands.get_frame_tree() + if session_id: + command['sessionId'] = session_id + response: GetFrameTreeResponse = await handler.execute_command(command) + return response['result']['frameTree'] +``` + +#### 2. **DOM Domain** + +Provides access to the DOM structure. + +**Key methods:** + +- `DOM.describeNode(objectId)`: Returns detailed information about a DOM node + ```json + { + "node": { + "nodeId": 123, + "backendNodeId": 456, + "nodeName": "IFRAME", + "frameId": "parent-frame-id", + "contentDocument": { + "frameId": "iframe-frame-id", + "documentURL": "https://embedded.com/page.html" + } + } + } + ``` + +- `DOM.getFrameOwner(frameId)`: Returns the `backendNodeId` of the ` - - +asyncio.run(interact_with_iframe()) ``` -Each iframe: - -- Has its own **separate DOM** (Document Object Model) -- Loads its own HTML, CSS, and JavaScript -- Can be from a different domain (cross-origin) -- Operates in an isolated browsing context +### Nested iframes -### Why You Can't Interact Directly - -This code **won't work** as you might expect: +Need to reach a frame inside another frame? Chain your searches: ```python -# ❌ This will NOT find elements inside the iframe -button = await tab.find(id='button-inside-iframe') -``` +outer = await tab.find(id='outer-frame') +inner = await outer.find(tag_name='iframe') # Search inside the outer iframe -**Why?** Because `tab.find()` searches the **main page's DOM**. The iframe has a **completely separate DOM** that isn't accessible from the parent. - -Think of it like this: - -``` -Main Page DOM IFrame DOM (Separate!) -├── ├── -│ ├── │ ├── -│ │ ├──
│ │ ├──
-│ │ └── +

Mais conteúdo do pai

+ +''' + +### Casos de Uso Comuns + +| Caso de Uso | Descrição | Exemplo | +|----------|-------------|---------| +| **Widgets de terceiros** | Incorpora conteúdo externo com segurança | Formulários de pagamento, feeds de mídia social, widgets de chat | +| **Isolamento de conteúdo** | Coloca conteúdo não confiável em sandbox | HTML gerado por usuário, anúncios | +| **Arquitetura modular** | Componentes reutilizáveis | Widgets de dashboard, sistemas de plugins | +| **Conteúdo de origem cruzada** | Carrega recursos de domínios diferentes | Mapas, players de vídeo, dashboards de analytics | + +### Modelo de Segurança: Política de Mesma Origem (Same-Origin Policy) + +O navegador impõe uma **Política de Mesma Origem** para iframes: + +- **Iframes de mesma origem**: O pai pode acessar o DOM do iframe via JavaScript (`iframe.contentDocument`) +- **Iframes de origem cruzada**: O pai não pode acessar o DOM do iframe diretamente (restrição de segurança) + +Essa barreira de segurança é o motivo pelo qual ferramentas de automação precisam de mecanismos especiais (como o CDP) para interagir com o conteúdo do iframe. + +!!! warning "Importante para automação" + Automação tradicional baseada em JavaScript (como as primeiras abordagens do Selenium) não pode acessar diretamente o conteúdo de iframes de origem cruzada devido à segurança do navegador. O CDP opera em um nível mais baixo, contornando essa limitação para fins de depuração. + +--- + +## O Desafio: Iframes Fora de Processo (OOPIFs) + +### O que são OOPIFs? + +O Chromium moderno usa **isolamento de site** (site isolation) para segurança e estabilidade. Isso significa que origens diferentes podem ser renderizadas em processos separados do SO. Um iframe de uma origem diferente torna-se um **Iframe Fora de Processo (OOPIF)**. + +'''mermaid +graph LR + subgraph "Processo 1: example.com" + MainPage[DOM da Página Principal] + end + + subgraph "Processo 2: widget.com" + IframeDOM[DOM do Iframe] + end + + MainPage -.Fronteira do Processo.-> IframeDOM +''' + +### Por que OOPIFs Complicam a Automação + +| Aspecto | Iframe no Mesmo Processo | Iframe Fora de Processo (OOPIF) | +|--------|-------------------|-------------------------------| +| **Acesso ao DOM** | Árvore de documento compartilhada na memória | Alvo (target) separado com seu próprio documento | +| **Roteamento de comandos** | Conexão única | Requer anexação ao alvo e roteamento de sessão | +| **Árvore de frames** | Todos os frames em uma árvore | Frame raiz + alvos separados para OOPIFs | +| **Contexto JavaScript** | Mesmo contexto de execution | Contexto de execução diferente por processo | +| **Comunicação CDP** | Comandos diretos | Comandos devem incluir `sessionId` | + +### A Abordagem Tradicional (Troca Manual de Contexto) + +Sem um manuseio sofisticado, automatizar OOPIFs requer: + +'''python +# Abordagem tradicional (manual) com outras ferramentas +main_page = browser.get_page() +iframe_element = main_page.find_element_by_id("iframe-id") + +# Deve trocar manualmente o contexto +driver.switch_to.frame(iframe_element) + +# Agora os comandos miram o iframe +button = driver.find_element_by_id("button-in-iframe") +button.click() + +# Deve trocar manualmente de volta +driver.switch_to.default_content() +''' + +**Problemas com esta abordagem:** + +1. **Carga para o desenvolvedor**: Todo iframe requer gerenciamento explícito de contexto +2. **Iframes aninhados**: Cada nível precisa de outra troca +3. **Detecção de OOPIF**: Difícil saber quando a anexação manual é necessária +4. **Propenso a erros**: Esquecer de trocar de volta → comandos subsequentes falham +5. **Não componentizável**: Funções auxiliares precisam saber seu contexto de iframe + +### A Solução do Pydoll: Resolução Transparente de Contexto + +O Pydoll elimina a troca manual de contexto resolvendo os contextos de iframe automaticamente: + +'''python +# Abordagem Pydoll (sem troca manual) +iframe = await tab.find(id="iframe-id") +button = await iframe.find(id="button-in-iframe") +await button.click() + +# Iframes aninhados? Mesmo padrão +outer = await tab.find(id="outer-iframe") +inner = await outer.find(tag_name="iframe") +button = await inner.find(text="Submit") +await button.click() +''' + +A complexidade é tratada internamente. Vamos explorar como. + +--- + +## Protocolo Chrome DevTools e Gerenciamento de Frames + +Como discutido em [Análise Aprofundada → Fundamentos → Protocolo Chrome DevTools](./cdp.md), o CDP fornece controle abrangente do navegador via comunicação WebSocket. O gerenciamento de frames é distribuído por múltiplos domínios do CDP. + +### Domínios CDP Relevantes + +#### 1. **Domínio Page** + +Gerencia o ciclo de vida da página, frames e navegação. + +**Métodos principais:** + +- `Page.getFrameTree()`: Retorna a estrutura hierárquica de todos os frames em uma página + '''json + { + "frameTree": { + "frame": { + "id": "main-frame-id", + "url": "https://example.com", + "securityOrigin": "https://example.com", + "mimeType": "text/html" + }, + "childFrames": [ + { + "frame": { + "id": "child-frame-id", + "parentId": "main-frame-id", + "url": "https://widget.com/embed" + } + } + ] + } + } + ''' + +- `Page.createIsolatedWorld(frameId, worldName)`: Cria um novo contexto de execução JavaScript em um frame específico + '''json + { + "executionContextId": 42 + } + ''' + +**Uso no Pydoll:** + +'''python +# De pydoll/elements/web_element.py +@staticmethod +async def _get_frame_tree_for( + handler: ConnectionHandler, session_id: Optional[str] +) -> FrameTree: + """Pega a árvore de frames da Página para a conexão/alvo dados.""" + command = PageCommands.get_frame_tree() + if session_id: + command['sessionId'] = session_id + response: GetFrameTreeResponse = await handler.execute_command(command) + return response['result']['frameTree'] +''' + +#### 2. **Domínio DOM** + +Fornece acesso à estrutura do DOM. + +**Métodos principais:** + +- `DOM.describeNode(objectId)`: Retorna informação detalhada sobre um nó DOM + '''json + { + "node": { + "nodeId": 123, + "backendNodeId": 456, + "nodeName": "IFRAME", + "frameId": "parent-frame-id", + "contentDocument": { + "frameId": "iframe-frame-id", + "documentURL": "https://embedded.com/page.html" + } + } + } + ''' + +- `DOM.getFrameOwner(frameId)`: Retorna o `backendNodeId` do elemento ` - - +asyncio.run(interagir_iframe()) ``` -Cada iframe: - -- Tem seu próprio **DOM (Document Object Model) separado** -- Carrega seu próprio HTML, CSS e JavaScript -- Pode ser de um domínio diferente (cross-origin) -- Opera em um contexto de navegação isolado +### Iframes aninhados -### Por que Você Não Pode Interagir Diretamente - -Este código **não funcionará** como você espera: +Basta encadear as buscas: ```python -# Isso NÃO encontrará elementos dentro do iframe -button = await tab.find(id='button-inside-iframe') -``` - -**Por quê?** Porque `tab.find()` pesquisa no **DOM da página principal**. O iframe tem um **DOM completamente separado** que não é acessível a partir do pai. +outer = await tab.find(id='outer-frame') +inner = await outer.find(tag_name='iframe') # procura dentro do primeiro iframe -Pense desta forma: - -``` -DOM da Página Principal DOM do IFrame (Separado!) -├── ├── -│ ├── │ ├── -│ │ ├──
│ │ ├──
-│ │ └── +

更多父页面内容

+ +''' + +### 常见用例 + +| 用例 | 描述 | 示例 | +|----------|-------------|---------| +| **第三方小部件** | 安全地嵌入外部内容 | 支付表单、社交媒体 feeds、聊天窗口 | +| **内容隔离** | 沙盒化不受信任的内容 | 用户生成的 HTML、广告 | +| **模块化架构** | 可重用组件 | 仪表盘小部件、插件系统 | +| **跨域内容** | 从不同域加载资源 | 地图、视频播放器、分析仪表盘 | + +### 安全模型:同源策略 (Same-Origin Policy) + +浏览器对 iframes 强制执行**同源策略**: + +- **同源 iframes**:父级可以通过 JavaScript 访问 iframe 的 DOM (`iframe.contentDocument`) +- **跨域 iframes**:父级不能直接访问 iframe 的 DOM(安全限制) + +这个安全边界就是为什么自动化工具需要特殊机制(如 CDP)来与 iframe 内容交互。 + +!!! warning "对自动化很重要" + 由于浏览器安全限制,传统的基于 JavaScript 的自动化(如 Selenium 的早期方法)无法直接访问跨域 iframe 的内容。CDP 在更底层运行,出于调试目的绕过了这个限制。 + +--- + +## 挑战:跨进程 Iframes (OOPIFs) + +### 什么是 OOPIFs? + +现代 Chromium 使用**站点隔离 (site isolation)** 来提高安全性和稳定性。这意味着不同的源 (origin) 可能会在单独的操作系统进程中渲染。来自不同源的 iframe 会成为**跨进程 Iframe (OOPIF)**。 + +'''mermaid +graph LR + subgraph "进程 1: example.com" + MainPage[主页面 DOM] + end + + subgraph "进程 2: widget.com" + IframeDOM[Iframe DOM] + end + + MainPage -.进程边界.-> IframeDOM +''' + +### 为什么 OOPIFs 使自动化复杂化 + +| 方面 | 进程内 Iframe | 跨进程 Iframe (OOPIF) | +|--------|-------------------|-------------------------------| +| **DOM 访问** | 内存中共享的文档树 | 拥有自己文档的独立目标 (target) | +| **命令路由** | 单个连接 | 需要目标附加 (target attachment) 和会话路由 (session routing) | +| **Frame 树** | 所有 frames 在一棵树中 | 根 frame + OOPIFs 的独立目标 | +| **JavaScript 上下文** | 相同的执行上下文 | 每个进程有不同的执行上下文 | +| **CDP 通信** | 直接命令 | 命令必须包含 `sessionId` | + +### 传统方法(手动切换上下文) + +没有复杂的处理,自动化 OOPIFs 需要: + +'''python +# 其他工具的传统(手动)方法 +main_page = browser.get_page() +iframe_element = main_page.find_element_by_id("iframe-id") + +# 必须手动切换上下文 +driver.switch_to.frame(iframe_element) + +# 现在命令指向 iframe +button = driver.find_element_by_id("button-in-iframe") +button.click() + +# 必须手动切换回来 +driver.switch_to.default_content() +''' + +**这种方法的问题:** + +1. **开发者负担**:每个 iframe 都需要显式的上下文管理 +2. **嵌套 iframes**:每一层都需要再次切换 +3. **OOPIF 检测**:很难知道何时需要手动附加 +4. **容易出错**:忘记切换回来 → 后续命令失败 +5. **不可组合**:辅助函数必须知道它们所处的 iframe 上下文 + +### Pydoll 的解决方案:透明的上下文解析 + +Pydoll 通过自动解析 iframe 上下文来消除手动上下文切换: + +'''python +# Pydoll 方法(无手动切换) +iframe = await tab.find(id="iframe-id") +button = await iframe.find(id="button-in-iframe") +await button.click() + +# 嵌套 iframes?同样的模式 +outer = await tab.find(id="outer-iframe") +inner = await outer.find(tag_name="iframe") +button = await inner.find(text="Submit") +await button.click() +''' + +复杂性在内部处理。让我们来探究一下是如何做到的。 + +--- + +## Chrome DevTools 协议和 Frame 管理 + +正如在 [深度解析 → 基础 → Chrome DevTools 协议](./cdp.md) 中讨论的,CDP 通过 WebSocket 通信提供全面的浏览器控制。Frame 管理分散在多个 CDP 域中。 + +### 相关的 CDP 域 + +#### 1. **Page 域** + +管理页面生命周期、frames 和导航。 + +**关键方法:** + +- `Page.getFrameTree()`:返回页面中所有 frames 的层级结构 + '''json + { + "frameTree": { + "frame": { + "id": "main-frame-id", + "url": "https://example.com", + "securityOrigin": "https://example.com", + "mimeType": "text/html" + }, + "childFrames": [ + { + "frame": { + "id": "child-frame-id", + "parentId": "main-frame-id", + "url": "https://widget.com/embed" + } + } + ] + } + } + ''' + +- `Page.createIsolatedWorld(frameId, worldName)`:在特定 frame 中创建一个新的 JavaScript 执行上下文 + '''json + { + "executionContextId": 42 + } + ''' + +**Pydoll 用法:** + +'''python +# 源自 pydoll/elements/web_element.py +@staticmethod +async def _get_frame_tree_for( + handler: ConnectionHandler, session_id: Optional[str] +) -> FrameTree: + """获取给定连接/目标的 Page frame 树。""" + command = PageCommands.get_frame_tree() + if session_id: + command['sessionId'] = session_id + response: GetFrameTreeResponse = await handler.execute_command(command) + return response['result']['frameTree'] +''' + +#### 2. **DOM 域** + +提供对 DOM 结构的访问。 + +**关键方法:** + +- `DOM.describeNode(objectId)`:返回有关 DOM 节点的详细信息 + '''json + { + "node": { + "nodeId": 123, + "backendNodeId": 456, + "nodeName": "IFRAME", + "frameId": "parent-frame-id", + "contentDocument": { + "frameId": "iframe-frame-id", + "documentURL": "https://embedded.com/page.html" + } + } + } + ''' + +- `DOM.getFrameOwner(frameId)`:返回拥有某个 frame 的 ` - - +submit_button = await inner.find(id='submit') +await submit_button.click() ``` -每个iframe: - -- 拥有自己**独立的DOM**(文档对象模型) -- 加载自己的HTML、CSS和JavaScript -- 可以来自不同域(跨域) -- 在隔离的浏览上下文中运行 +流程始终相同: -### 为什么无法直接交互 +1. 找到需要的 iframe 元素。 +2. 使用该 `WebElement` 作为新的查找范围。 +3. 如果还有内层 iframe,重复以上步骤。 -这段代码**无法按预期工作**: +### 在 iframe 中执行 JavaScript ```python -# ❌ 这无法找到iframe内部的元素 -button = await tab.find(id='button-inside-iframe') +iframe = await tab.find(tag_name='iframe') +result = await iframe.execute_script('return document.title', return_by_value=True) +print(result['result']['result']['value']) ``` -**为什么?** 因为`tab.find()`搜索的是**主页面的DOM**。iframe拥有**完全独立的DOM**,无法从父页面访问。 +Pydoll 会自动在 iframe 的隔离上下文中执行脚本,同样适用于跨域 iframe。 -可以这样理解: +## 为什么这样更好? -``` -主页面DOM IFrame DOM(独立!) -├── ├── -│ ├── │ ├── -│ │ ├──
│ │ ├──
-│ │ └── + +
+

Content after parent iframe

+
+ + + diff --git a/tests/pages/test_iframe_nested_level.html b/tests/pages/test_iframe_nested_level.html new file mode 100644 index 00000000..b77f9ad9 --- /dev/null +++ b/tests/pages/test_iframe_nested_level.html @@ -0,0 +1,35 @@ + + + + + + Nested Iframe Level + + + +

Nested Iframe Content

+ +
+

This is the nested iframe level (child of parent iframe).

+ + + + + +
+ + + +
+
+ + + diff --git a/tests/pages/test_iframe_parent_level.html b/tests/pages/test_iframe_parent_level.html new file mode 100644 index 00000000..1beca329 --- /dev/null +++ b/tests/pages/test_iframe_parent_level.html @@ -0,0 +1,33 @@ + + + + + + Parent Iframe Level + + + +

Parent Iframe Content

+ +
+

This is the parent iframe level.

+ + +
+ +
+

Nested Iframe Below:

+ +
+ + + + + diff --git a/tests/pages/test_iframe_simple.html b/tests/pages/test_iframe_simple.html new file mode 100644 index 00000000..a45b31a4 --- /dev/null +++ b/tests/pages/test_iframe_simple.html @@ -0,0 +1,23 @@ + + + + + + Test Simple Iframe + + +

Main Page

+
+

This is the main page content.

+ + +
+ + + +
+

Content after iframe

+
+ + + diff --git a/tests/test_browser/test_browser_tab.py b/tests/test_browser/test_browser_tab.py index 6c07b644..ad794da4 100644 --- a/tests/test_browser/test_browser_tab.py +++ b/tests/test_browser/test_browser_tab.py @@ -1469,7 +1469,8 @@ async def test_get_frame_success(self, tab, mock_browser): {'targetId': 'iframe-target-id', 'url': 'https://example.com/iframe'} ]) - frame = await tab.get_frame(mock_iframe_element) + with pytest.warns(DeprecationWarning): + frame = await tab.get_frame(mock_iframe_element) assert isinstance(frame, Tab) mock_browser.get_targets.assert_called_once() @@ -1489,9 +1490,11 @@ async def test_get_frame_uses_cache_on_subsequent_calls(self, tab, mock_browser) tab._browser._tabs_opened = {} with patch('pydoll.browser.tab.ConnectionHandler', autospec=True): - frame1 = await tab.get_frame(mock_iframe_element) + with pytest.warns(DeprecationWarning): + frame1 = await tab.get_frame(mock_iframe_element) # Second call should reuse from cache and not create a new Tab - frame2 = await tab.get_frame(mock_iframe_element) + with pytest.warns(DeprecationWarning): + frame2 = await tab.get_frame(mock_iframe_element) assert isinstance(frame1, Tab) assert frame1 is frame2 @@ -1503,8 +1506,9 @@ async def test_get_frame_not_iframe(self, tab): mock_element = MagicMock() mock_element.tag_name = 'div' # Mock the property directly - with pytest.raises(NotAnIFrame): - await tab.get_frame(mock_element) + with pytest.warns(DeprecationWarning): + with pytest.raises(NotAnIFrame): + await tab.get_frame(mock_element) @pytest.mark.asyncio async def test_get_frame_no_frame_id(self, tab, mock_browser): @@ -1516,8 +1520,9 @@ async def test_get_frame_no_frame_id(self, tab, mock_browser): mock_browser.get_targets = AsyncMock(return_value=[]) - with pytest.raises(IFrameNotFound): - await tab.get_frame(mock_iframe_element) + with pytest.warns(DeprecationWarning): + with pytest.raises(IFrameNotFound): + await tab.get_frame(mock_iframe_element) class TestTabUtilityMethods: diff --git a/tests/test_core_integration.py b/tests/test_core_integration.py new file mode 100644 index 00000000..bec7a1a1 --- /dev/null +++ b/tests/test_core_integration.py @@ -0,0 +1,150 @@ +"""Integration tests for core WebElement/Tab behaviors (non-iframe).""" + +import asyncio +from pathlib import Path + +import pytest + +from pydoll.browser.chromium import Chrome +from pydoll.elements.web_element import WebElement + + +class TestCoreFindQuery: + """Find and query basics on a simple page.""" + + @pytest.mark.asyncio + async def test_find_by_common_selectors(self, ci_chrome_options): + test_file = Path(__file__).parent / 'pages' / 'test_core_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(0.5) + + # id + heading = await tab.find(id='main-heading') + assert heading is not None + assert isinstance(heading, WebElement) + assert heading.get_attribute('id') == 'main-heading' + + # class_name (first occurrence) + first_item = await tab.find(class_name='item') + assert first_item is not None + assert 'item' in (first_item.get_attribute('class') or '') + + # name + name_input = await tab.find(name='username') + assert name_input is not None + assert name_input.get_attribute('id') == 'text-input' + + # tag_name (first button) + button = await tab.find(tag_name='button') + assert button is not None + assert button.get_attribute('id') == 'btn-1' + + @pytest.mark.asyncio + async def test_query_css_and_xpath(self, ci_chrome_options): + test_file = Path(__file__).parent / 'pages' / 'test_core_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(0.5) + + # CSS: list items + items = await tab.query('.list-item', find_all=True) + assert items is not None + assert len(items) == 3 + + # XPath absolute + deep_span = await tab.query('//*[@id="deep-section"]//span[@id="deep-span"]') + assert deep_span is not None + text = await deep_span.text + assert 'Deep nested element' in text + + # XPath relative from container + container = await tab.find(id='deep-section') + rel_span = await container.find(xpath='.//span[@id="deep-span"]') + assert rel_span is not None + text2 = await rel_span.text + assert 'Deep nested element' in text2 + + +class TestCoreClickAndInput: + """Click and text insertion behaviors.""" + + @pytest.mark.asyncio + async def test_click_increments_counter(self, ci_chrome_options): + test_file = Path(__file__).parent / 'pages' / 'test_core_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(0.5) + + button = await tab.find(id='btn-1') + counter = await tab.find(id='btn-1-count') + + # before + before_text = await counter.text + assert before_text.strip() == '0' + + await button.click() + await asyncio.sleep(0.2) + after_text = await counter.text + assert after_text.strip() == '1' + + await button.click() + await asyncio.sleep(0.2) + after_text2 = await counter.text + assert after_text2.strip() == '2' + + @pytest.mark.asyncio + async def test_insert_text_input_and_textarea(self, ci_chrome_options): + test_file = Path(__file__).parent / 'pages' / 'test_core_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(0.5) + + # input + input_el = await tab.find(id='text-input') + await input_el.insert_text('Hello') + await asyncio.sleep(0.1) + assert 'Hello' in (input_el.get_attribute('value') or '') + + # textarea + textarea = await tab.find(id='text-area') + await textarea.insert_text('World') + await asyncio.sleep(0.1) + assert 'World' in (textarea.get_attribute('value') or '') + + @pytest.mark.asyncio + async def test_select_option_click(self, ci_chrome_options): + test_file = Path(__file__).parent / 'pages' / 'test_core_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(0.5) + + select_el = await tab.find(id='simple-select') + assert select_el is not None + + # click on option 'beta' + opt_beta = await select_el.find(xpath='.//option[@value="beta"]') + await opt_beta.click() + await asyncio.sleep(0.2) + + # verify using JS value read + prop = await select_el.execute_script('return this.value', return_by_value=True) + current_value = prop['result']['result']['value'] + assert current_value == 'beta' + + diff --git a/tests/test_iframe_integration.py b/tests/test_iframe_integration.py new file mode 100644 index 00000000..cf8b2d06 --- /dev/null +++ b/tests/test_iframe_integration.py @@ -0,0 +1,727 @@ +"""Integration tests for iframe functionality in WebElement. + +These tests use real HTML files and Chrome browser to test iframe interactions, +element finding, and DOM manipulation within iframes. +""" + +import asyncio +from pathlib import Path + +import pytest + +from pydoll.browser.chromium import Chrome +from pydoll.elements.web_element import WebElement +from pydoll.exceptions import ElementNotFound, InvalidIFrame + + +class TestSimpleIframeIntegration: + """Integration tests for simple iframe operations.""" + + @pytest.mark.asyncio + async def test_find_element_in_iframe_by_id(self, ci_chrome_options): + """Test finding an element inside an iframe by id.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + + # Wait for iframe to load + await asyncio.sleep(1) + + # Find the iframe element + iframe_element = await tab.find(id='simple-iframe') + assert iframe_element is not None + assert iframe_element.is_iframe + + # Get iframe context + iframe_context = await iframe_element.iframe_context + assert iframe_context is not None + assert iframe_context.frame_id is not None + assert iframe_context.execution_context_id is not None + + # Find element inside iframe + heading_in_iframe = await iframe_element.find(id='iframe-heading') + assert heading_in_iframe is not None + assert isinstance(heading_in_iframe, WebElement) + + # Verify the element text + text = await heading_in_iframe.text + assert 'Iframe Content' in text + + @pytest.mark.asyncio + async def test_find_multiple_elements_in_iframe(self, ci_chrome_options): + """Test finding multiple elements inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find all links inside iframe + links = await iframe_element.query('.iframe-link', find_all=True) + assert len(links) == 3 + + # Verify each link + for i, link in enumerate(links, 1): + link_id = link.get_attribute('id') + assert link_id == f'iframe-link{i}' + + @pytest.mark.asyncio + async def test_find_element_in_iframe_by_css_selector(self, ci_chrome_options): + """Test finding elements in iframe using CSS selectors.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find by class + action_buttons = await iframe_element.query('.action-btn', find_all=True) + assert len(action_buttons) >= 2 # At least 2 visible buttons + + # Find by tag + inputs = await iframe_element.query('input[type="text"]', find_all=True) + assert len(inputs) >= 1 + + @pytest.mark.asyncio + async def test_find_element_in_iframe_by_xpath(self, ci_chrome_options): + """Test finding elements in iframe using XPath.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find by XPath + paragraph = await iframe_element.find(xpath='//p[@id="iframe-paragraph"]') + assert paragraph is not None + + text = await paragraph.text + assert 'content inside the iframe' in text + + @pytest.mark.asyncio + async def test_insert_text_in_iframe_input(self, ci_chrome_options): + """Test inserting text into an input field inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find input inside iframe + input_element = await iframe_element.find(id='iframe-input') + assert input_element is not None + + # Insert text + test_text = 'Test User Name' + await input_element.insert_text(test_text) + + # Verify text was inserted + value = input_element.get_attribute('value') + assert test_text in value + + @pytest.mark.asyncio + async def test_insert_text_in_iframe_textarea(self, ci_chrome_options): + """Test inserting text into a textarea inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find textarea inside iframe + textarea = await iframe_element.find(id='iframe-textarea') + assert textarea is not None + + # Insert new text (textarea initially empty) + new_message = 'This is a new test message' + await textarea.insert_text(new_message) + + # Verify text was inserted + value = textarea.get_attribute('value') + assert new_message in value + + @pytest.mark.asyncio + async def test_click_button_in_iframe(self, ci_chrome_options): + """Test clicking a button inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find button inside iframe + button = await iframe_element.find(id='iframe-button1') + assert button is not None + + # Click the button (should not raise exception) + await button.click() + await asyncio.sleep(0.5) + + @pytest.mark.asyncio + async def test_get_inner_html_of_iframe(self, ci_chrome_options): + """Test getting inner HTML of an iframe element.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Get inner HTML of the iframe + inner_html = await iframe_element.inner_html + assert inner_html is not None + assert len(inner_html) > 0 + assert 'iframe-heading' in inner_html + assert 'Iframe Content' in inner_html + + @pytest.mark.asyncio + async def test_get_inner_html_of_element_in_iframe(self, ci_chrome_options): + """Test getting inner HTML of an element inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find container inside iframe + container = await iframe_element.find(id='iframe-container') + assert container is not None + + # Get inner HTML + inner_html = await container.inner_html + assert inner_html is not None + assert 'iframe-paragraph' in inner_html + assert 'iframe-form' in inner_html + + @pytest.mark.asyncio + async def test_get_children_elements_in_iframe(self, ci_chrome_options): + """Test getting children elements of an element inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find list inside iframe + list_element = await iframe_element.find(id='iframe-list') + assert list_element is not None + + # Get list items using tag filter to avoid relying on class attributes + list_items = await list_element.get_children_elements(max_depth=2, tag_filter=['li']) + assert len(list_items) == 3 + + @pytest.mark.asyncio + async def test_element_visibility_in_iframe(self, ci_chrome_options): + """Test checking element visibility inside an iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find visible button + visible_button = await iframe_element.find(id='iframe-button1') + is_visible = await visible_button.is_visible() + assert is_visible is True + + # Find hidden button + hidden_button = await iframe_element.find(id='iframe-button3') + is_hidden = await hidden_button.is_visible() + assert is_hidden is False + + +class TestNestedIframeIntegration: + """Integration tests for nested iframe operations.""" + + @pytest.mark.asyncio + async def test_find_element_in_parent_iframe(self, ci_chrome_options): + """Test finding an element in parent iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1.5) + + # Find parent iframe + parent_iframe = await tab.find(id='parent-iframe') + assert parent_iframe is not None + assert parent_iframe.is_iframe + + # Find element in parent iframe + parent_heading = await parent_iframe.find(id='parent-iframe-heading') + assert parent_heading is not None + + text = await parent_heading.text + assert 'Parent Iframe Content' in text + + @pytest.mark.asyncio + async def test_find_nested_iframe_element(self, ci_chrome_options): + """Test finding the nested iframe element inside parent iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1.5) + + # Find parent iframe + parent_iframe = await tab.find(id='parent-iframe') + + # Find nested iframe inside parent iframe + nested_iframe = await parent_iframe.find(id='nested-iframe') + assert nested_iframe is not None + assert nested_iframe.is_iframe + + @pytest.mark.asyncio + async def test_find_element_in_nested_iframe(self, ci_chrome_options): + """Test finding an element in nested iframe (iframe within iframe).""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(2) + + # Find parent iframe + parent_iframe = await tab.find(id='parent-iframe') + + # Find nested iframe inside parent + nested_iframe = await parent_iframe.find(id='nested-iframe') + assert nested_iframe is not None + + # Find element in nested iframe + nested_heading = await nested_iframe.find(id='nested-iframe-heading') + assert nested_heading is not None + + text = await nested_heading.text + assert 'Nested Iframe Content' in text + + @pytest.mark.asyncio + async def test_insert_text_in_nested_iframe(self, ci_chrome_options): + """Test inserting text into input field in nested iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(2) + + # Navigate to nested iframe + parent_iframe = await tab.find(id='parent-iframe') + nested_iframe = await parent_iframe.find(id='nested-iframe') + + # Find input in nested iframe + nested_input = await nested_iframe.find(id='nested-input') + assert nested_input is not None + + # Insert text + test_text = 'Nested Input Test' + await nested_input.insert_text(test_text) + + # Verify + value = nested_input.get_attribute('value') + assert test_text in value + + @pytest.mark.asyncio + async def test_find_multiple_elements_in_nested_iframe(self, ci_chrome_options): + """Test finding multiple elements in nested iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(2) + + # Navigate to nested iframe + parent_iframe = await tab.find(id='parent-iframe') + nested_iframe = await parent_iframe.find(id='nested-iframe') + + # Find all links in nested iframe + links = await nested_iframe.query('a', find_all=True) + assert len(links) == 2 + + # Verify link IDs + link_ids = [link.get_attribute('id') for link in links] + assert 'nested-link1' in link_ids + assert 'nested-link2' in link_ids + + @pytest.mark.asyncio + async def test_submit_form_in_nested_iframe(self, ci_chrome_options): + """Test interacting with form elements in nested iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_nested.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(2) + + # Navigate to nested iframe + parent_iframe = await tab.find(id='parent-iframe') + nested_iframe = await parent_iframe.find(id='nested-iframe') + + # Fill form fields + username_input = await nested_iframe.find(id='nested-form-input') + await username_input.insert_text('testuser') + + password_input = await nested_iframe.find(id='nested-form-password') + await password_input.insert_text('password123') + + # Verify values + assert 'testuser' in username_input.get_attribute('value') + assert 'password123' in password_input.get_attribute('value') + + # Click submit button + submit_button = await nested_iframe.find(id='nested-form-submit') + await submit_button.click() + await asyncio.sleep(0.5) + + +class TestIframeElementInteraction: + """Integration tests for various element interactions within iframes.""" + + @pytest.mark.asyncio + async def test_select_option_in_iframe(self, ci_chrome_options): + """Test selecting an option in a select element inside iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find select element + select_element = await iframe_element.find(id='iframe-select') + assert select_element is not None + + # Select option2 by clicking the option element + option2 = await select_element.find(xpath='.//option[@value="option2"]') + await option2.click() + await asyncio.sleep(0.2) + # Verify via property read (execute_script) + prop_val = await select_element.execute_script('return this.value', return_by_value=True) + current_value = prop_val['result']['result']['value'] + assert current_value == 'option2' + + # Select different option (option3) by clicking it + option3 = await select_element.find(xpath='.//option[@value="option3"]') + await option3.click() + await asyncio.sleep(0.2) + prop_val2 = await select_element.execute_script('return this.value', return_by_value=True) + new_value = prop_val2['result']['result']['value'] + assert new_value == 'option3' + + @pytest.mark.asyncio + async def test_get_attributes_from_iframe_elements(self, ci_chrome_options): + """Test getting various attributes from elements in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Get link attributes + link = await iframe_element.find(id='iframe-link1') + href = link.get_attribute('href') + assert href is not None + assert '#link1' in href + + link_class = link.get_attribute('class') + assert 'iframe-link' in link_class + + # Get input attributes + input_elem = await iframe_element.find(id='iframe-input') + input_type = input_elem.get_attribute('type') + assert input_type == 'text' + + placeholder = input_elem.get_attribute('placeholder') + assert 'name' in placeholder.lower() + + @pytest.mark.asyncio + async def test_deep_nested_element_search_in_iframe(self, ci_chrome_options): + """Test finding deeply nested elements inside iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find deeply nested element + deep_span = await iframe_element.find(id='deep-span') + assert deep_span is not None + + text = await deep_span.text + assert 'Deep nested element' in text + + + @pytest.mark.asyncio + async def test_wait_for_element_in_iframe(self, ci_chrome_options): + """Test waiting for element to appear in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Wait for element (should already exist) + element = await iframe_element.find( + id='iframe-paragraph', timeout=5 + ) + assert element is not None + + text = await element.text + assert 'content inside the iframe' in text + + @pytest.mark.asyncio + async def test_element_not_found_in_iframe(self, ci_chrome_options): + """Test that ElementNotFound is raised for non-existent elements in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Try to find non-existent element + with pytest.raises(ElementNotFound): + await iframe_element.find(id='non-existent-element') + + @pytest.mark.asyncio + async def test_clear_input_in_iframe(self, ci_chrome_options): + """Test clearing input field in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Find input and add text + input_elem = await iframe_element.find(id='iframe-input') + await input_elem.insert_text('Test text to clear') + await asyncio.sleep(0.3) + + await input_elem.insert_text('') + await asyncio.sleep(0.3) + value = input_elem.get_attribute('value') + assert value in ('', None) + + @pytest.mark.asyncio + async def test_multiple_iframes_on_same_page(self, ci_chrome_options): + """Test handling multiple iframes on the same page.""" + # Create a test page with multiple iframes + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + # Find main content (not in iframe) + main_heading = await tab.find(id='main-heading') + assert main_heading is not None + main_text = await main_heading.text + assert 'Main Page' in main_text + + # Find content in iframe + iframe_element = await tab.find(id='simple-iframe') + iframe_heading = await iframe_element.find(id='iframe-heading') + iframe_text = await iframe_heading.text + assert 'Iframe Content' in iframe_text + + # Verify they are different + assert main_text != iframe_text + + @pytest.mark.asyncio + async def test_iframe_context_persistence(self, ci_chrome_options): + """Test that iframe context persists across multiple operations.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Get context first time + context1 = await iframe_element.iframe_context + assert context1 is not None + + # Perform some operations + element1 = await iframe_element.find(id='iframe-heading') + await element1.text + + # Get context again + context2 = await iframe_element.iframe_context + assert context2 is not None + + # Verify contexts are consistent + assert context1.frame_id == context2.frame_id + assert context1.execution_context_id == context2.execution_context_id + + @pytest.mark.asyncio + async def test_get_text_from_multiple_elements_in_iframe(self, ci_chrome_options): + """Test getting text from multiple elements in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Get all list items + list_items = await iframe_element.query('.list-item', find_all=True) + assert len(list_items) == 3 + + # Get text from each + texts = [] + for item in list_items: + text = await item.text + texts.append(text) + + # Verify texts + assert 'Item 1' in texts[0] + assert 'Item 2' in texts[1] + assert 'Item 3' in texts[2] + + +class TestIframeEdgeCases: + """Integration tests for edge cases in iframe handling.""" + + @pytest.mark.asyncio + async def test_dynamic_content_in_iframe(self, ci_chrome_options): + """Test finding dynamically added content in iframe.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + iframe_element = await tab.find(id='simple-iframe') + + # Add dynamic content via JavaScript + iframe_context = await iframe_element.iframe_context + await tab.execute_script( + """ + const div = document.createElement('div'); + div.id = 'dynamic-element'; + div.textContent = 'Dynamic Content'; + document.body.appendChild(div); + """, + context_id=iframe_context.execution_context_id, + ) + await asyncio.sleep(0.5) + + # Find dynamically added element + dynamic_element = await iframe_element.find(id='dynamic-element') + assert dynamic_element is not None + + text = await dynamic_element.text + assert 'Dynamic Content' in text + + @pytest.mark.asyncio + async def test_iframe_reload_handling(self, ci_chrome_options): + """Test that iframe context is properly handled after page reload.""" + test_file = Path(__file__).parent / 'pages' / 'test_iframe_simple.html' + file_url = f'file://{test_file.absolute()}' + + async with Chrome(options=ci_chrome_options) as browser: + tab = await browser.start() + await tab.go_to(file_url) + await asyncio.sleep(1) + + # Find iframe and element + iframe_element = await tab.find(id='simple-iframe') + element_before = await iframe_element.find(id='iframe-heading') + assert element_before is not None + + # Reload page + await tab.refresh() + await asyncio.sleep(1) + + # Find iframe again (new instance) + iframe_element_after = await tab.find(id='simple-iframe') + element_after = await iframe_element_after.find(id='iframe-heading') + assert element_after is not None + + # Verify element is accessible + text = await element_after.text + assert 'Iframe Content' in text + diff --git a/tests/test_managers/test_browser_managers.py b/tests/test_managers/test_browser_managers.py index 02bb1611..13a1d895 100644 --- a/tests/test_managers/test_browser_managers.py +++ b/tests/test_managers/test_browser_managers.py @@ -169,13 +169,11 @@ def test_handle_cleanup_error(temp_manager): temp_manager.handle_cleanup_error(func_mock, path, (PermissionError, PermissionError(), None)) temp_manager.retry_process_file.assert_called_once_with(func_mock, path) - # matched permission error + # matched permission error - should not raise, only log and continue temp_manager.retry_process_file = Mock() temp_manager.retry_process_file.side_effect = PermissionError path = "/tmp/CrashpadMetrics-active.pma" - - with pytest.raises(PermissionError): - temp_manager.handle_cleanup_error(func_mock, path, (PermissionError, PermissionError(), None)) + temp_manager.handle_cleanup_error(func_mock, path, (PermissionError, PermissionError(), None)) # unmatched permission error temp_manager.retry_process_file = Mock() diff --git a/tests/test_web_element.py b/tests/test_web_element.py index efff65f9..f365c0b8 100644 --- a/tests/test_web_element.py +++ b/tests/test_web_element.py @@ -117,27 +117,18 @@ def disabled_element(mock_connection_handler): @pytest.fixture -def ci_chrome_options(): - """Chrome options optimized for CI environments.""" - options = Options() - options.headless = True - options.start_timeout = 30 - - # CI-specific arguments - options.add_argument('--no-sandbox') - options.add_argument('--disable-dev-shm-usage') - options.add_argument('--disable-gpu') - options.add_argument('--disable-extensions') - options.add_argument('--disable-background-timer-throttling') - options.add_argument('--disable-backgrounding-occluded-windows') - options.add_argument('--disable-renderer-backgrounding') - options.add_argument('--disable-default-apps') +def iframe_element(mock_connection_handler): + """Iframe element fixture for iframe-related tests.""" + attributes_list = ['id', 'iframe-id', 'tag_name', 'iframe'] + return WebElement( + object_id='iframe-object-id', + connection_handler=mock_connection_handler, + method='css', + selector='iframe#iframe-id', + attributes_list=attributes_list, + ) - # Memory optimization - options.add_argument('--memory-pressure-off') - options.add_argument('--max_old_space_size=4096') - return options class TestWebElementInitialization: @@ -262,6 +253,13 @@ async def test_inner_html_property(self, web_element): html = await web_element.inner_html assert html == expected_html + @pytest.mark.asyncio + async def test_iframe_context_non_iframe_returns_none(self, web_element): + """Non-iframe elements should not produce iframe context.""" + result = await web_element.iframe_context + assert result is None + web_element._connection_handler.execute_command.assert_not_awaited() + class TestWebElementMethods: """Test WebElement methods and interactions.""" @@ -324,6 +322,185 @@ async def test_type_text_default_interval(self, input_element): mock_sleep.assert_called_with(0.1) # Default interval assert input_element.click.call_count == 1 + +class TestWebElementIFrame: + """Tests for iframe-specific WebElement behaviour.""" + + @pytest.mark.asyncio + async def test_iframe_context_initialization(self, iframe_element): + """Iframe context should be created via CDP commands.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'DOM.describeNode': + return { + 'result': { + 'node': { + 'frameId': 'frame-123', + 'contentDocument': { + 'frameId': 'frame-123', + 'documentURL': 'https://example.com/frame.html', + 'baseURL': 'https://example.com/frame.html', + }, + } + } + } + if method == 'Page.createIsolatedWorld': + return {'result': {'executionContextId': 42}} + if method == 'Runtime.evaluate': + return { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'document-object-id', + } + } + } + raise AssertionError(f'Unexpected method {method}') + + iframe_element._connection_handler.execute_command.side_effect = side_effect + + ctx = await iframe_element.iframe_context + assert ctx is not None + assert ctx.frame_id == 'frame-123' + assert ctx.document_url == 'https://example.com/frame.html' + assert ctx.execution_context_id == 42 + assert ctx.document_object_id == 'document-object-id' + + # Subsequent access should not trigger additional CDP calls + iframe_element._connection_handler.execute_command.reset_mock() + cached_ctx = await iframe_element.iframe_context + assert cached_ctx is ctx + iframe_element._connection_handler.execute_command.assert_not_awaited() + + @pytest.mark.asyncio + async def test_iframe_inner_html_uses_runtime_evaluate(self, iframe_element): + """inner_html should read from iframe execution context.""" + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'DOM.describeNode': + return { + 'result': { + 'node': { + 'frameId': 'frame-123', + 'contentDocument': { + 'frameId': 'frame-123', + 'documentURL': 'https://example.com/frame.html', + 'baseURL': 'https://example.com/frame.html', + }, + } + } + } + if method == 'Page.createIsolatedWorld': + return {'result': {'executionContextId': 77}} + if method == 'Runtime.evaluate': + expression = command['params']['expression'] + if expression == 'document.documentElement': + return { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'document-object-id', + } + } + } + if expression == 'document.documentElement.outerHTML': + assert command['params']['contextId'] == 77 + return { + 'result': { + 'result': { + 'type': 'string', + 'value': 'iframe content', + } + } + } + raise AssertionError(f'Unexpected method {method}') + + iframe_element._connection_handler.execute_command.side_effect = side_effect + + html = await iframe_element.inner_html + assert html == 'iframe content' + + methods = [ + call.args[0]['method'] + for call in iframe_element._connection_handler.execute_command.await_args_list + ] + assert methods.count('DOM.describeNode') == 1 + assert methods.count('Page.createIsolatedWorld') == 1 + assert methods.count('Runtime.evaluate') == 2 + + @pytest.mark.asyncio + async def test_find_within_iframe_uses_document_context(self, iframe_element): + """find() should query against the iframe's document element.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'DOM.describeNode': + object_id = command['params'].get('objectId') + if object_id == 'iframe-object-id': + return { + 'result': { + 'node': { + 'frameId': 'frame-123', + 'contentDocument': { + 'frameId': 'frame-123', + 'documentURL': 'https://example.com/frame.html', + 'baseURL': 'https://example.com/frame.html', + }, + } + } + } + if object_id == 'element-object-id': + return { + 'result': { + 'node': { + 'nodeName': 'DIV', + 'attributes': ['id', 'child', 'data-test', 'value'], + } + } + } + raise AssertionError('Unexpected objectId in describeNode') + if method == 'Page.createIsolatedWorld': + return {'result': {'executionContextId': 88}} + if method == 'Runtime.evaluate': + expression = command['params']['expression'] + if expression == 'document.documentElement': + return { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'document-object-id', + } + } + } + raise AssertionError(f'Unexpected evaluate expression: {expression}') + if method == 'Runtime.callFunctionOn': + assert command['params']['objectId'] == 'document-object-id' + return { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'element-object-id', + } + } + } + raise AssertionError(f'Unexpected method {method}') + + iframe_element._connection_handler.execute_command.side_effect = side_effect + + result = await iframe_element.find(tag_name='div') + + assert isinstance(result, WebElement) + assert result._object_id == 'element-object-id' + + runtime_calls = [ + call.args[0] + for call in iframe_element._connection_handler.execute_command.await_args_list + if call.args[0]['method'] == 'Runtime.callFunctionOn' + ] + assert runtime_calls, 'Runtime.callFunctionOn should be used for iframe queries' + assert runtime_calls[0]['params']['objectId'] == 'document-object-id' + @pytest.mark.asyncio async def test_get_parent_element_success(self, web_element): """Test successful parent element retrieval.""" @@ -914,6 +1091,52 @@ async def test_execute_script_basic(self, web_element): expected_command, timeout=60 ) +class TestBuildTextExpression: + """Unit tests for WebElement._build_text_expression.""" + + def test_build_text_expression_with_xpath(self): + expr = WebElement._build_text_expression('//p[@id="x"]', 'xpath') + assert isinstance(expr, str) + assert 'XPathResult.FIRST_ORDERED_NODE_TYPE' in expr + assert '@id' in expr + assert 'p' in expr + + def test_build_text_expression_with_name(self): + expr = WebElement._build_text_expression('fieldName', 'name') + assert isinstance(expr, str) + assert '//*[@name="fieldName"]' in expr + + def test_build_text_expression_with_id_css(self): + expr = WebElement._build_text_expression('main', 'id') + assert 'document.querySelector' in expr + assert '#main' in expr + + def test_build_text_expression_with_class_css(self): + expr = WebElement._build_text_expression('item', 'class_name') + assert 'document.querySelector' in expr + assert '.item' in expr + + def test_build_text_expression_with_tag_css(self): + expr = WebElement._build_text_expression('button', 'tag_name') + assert 'document.querySelector' in expr + assert 'button' in expr + +class TestIsOptionElementHeuristics: + """Unit tests for heuristics inside WebElement._is_option_element.""" + + @pytest.mark.asyncio + async def test_is_option_element_by_tag_attribute(self, option_element): + assert await option_element._is_option_element() is True + + @pytest.mark.asyncio + async def test_is_option_element_by_method_and_selector_tag_name(self, mock_connection_handler): + dummy = WebElement('dummy', mock_connection_handler, method='tag_name', selector='option', attributes_list=[]) + assert await dummy._is_option_element() is True + + @pytest.mark.asyncio + async def test_is_option_element_by_xpath_selector_contains_option(self, mock_connection_handler): + dummy = WebElement('dummy', mock_connection_handler, method='xpath', selector='//OPTION[@value=\"x\"]', attributes_list=[]) + assert await dummy._is_option_element() is True @pytest.mark.asyncio async def test_execute_script_with_this_syntax(self, web_element): """Test execute_script method with 'this' syntax.""" @@ -1601,3 +1824,1143 @@ async def test_get_siblings_elements_element_not_found_exception(self): # Should raise ElementNotFound when script returns no objectId with pytest.raises(ElementNotFound): await element.get_siblings_elements(raise_exc=True) + + +""" +Tests for WebElement iframe edge cases and uncovered code paths. + +This test suite focuses on covering edge cases in iframe resolution and context handling, +including: +- inner_html edge cases for iframes and iframe context elements +- Frame tree traversal and owner resolution +- OOPIF resolution scenarios +- Isolated world creation failures +- Document object resolution failures +""" + +import pytest +import pytest_asyncio +from unittest.mock import AsyncMock, patch + +from pydoll.elements.web_element import WebElement, _IFrameContext +from pydoll.connection import ConnectionHandler +from pydoll.exceptions import InvalidIFrame + + +@pytest_asyncio.fixture +async def mock_connection_handler(): + """Mock connection handler for WebElement tests.""" + with patch('pydoll.connection.ConnectionHandler', autospec=True) as mock: + handler = mock.return_value + handler.execute_command = AsyncMock() + handler._connection_port = 9222 + yield handler + + +@pytest.fixture +def iframe_element(mock_connection_handler): + """Iframe element fixture for iframe-related tests.""" + attributes_list = ['id', 'test-iframe', 'tag_name', 'iframe'] + return WebElement( + object_id='iframe-object-id', + connection_handler=mock_connection_handler, + method='css', + selector='iframe#test-iframe', + attributes_list=attributes_list, + ) + + +@pytest.fixture +def element_in_iframe(mock_connection_handler): + """Element inside an iframe (has _iframe_context set).""" + attributes_list = ['id', 'button-in-iframe', 'tag_name', 'button'] + element = WebElement( + object_id='button-object-id', + connection_handler=mock_connection_handler, + method='css', + selector='button', + attributes_list=attributes_list, + ) + # Set iframe context to simulate element inside iframe + element._iframe_context = _IFrameContext( + frame_id='frame-123', + document_url='https://example.com/iframe.html', + execution_context_id=42, + document_object_id='doc-obj-id', + ) + return element + + +class TestInnerHtmlEdgeCases: + """Test inner_html property edge cases for iframe scenarios.""" + + @pytest.mark.asyncio + async def test_inner_html_iframe_element_with_context(self, iframe_element): + """Test inner_html on iframe element uses Runtime.evaluate in iframe context.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'DOM.describeNode': + return { + 'result': { + 'node': { + 'frameId': 'parent-frame', + 'backendNodeId': 999, + 'contentDocument': { + 'frameId': 'iframe-123', + 'documentURL': 'https://example.com/frame.html', + }, + } + } + } + if method == 'Page.createIsolatedWorld': + return {'result': {'executionContextId': 77}} + if method == 'Runtime.evaluate': + expression = command['params']['expression'] + if expression == 'document.documentElement': + return { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'doc-element-id', + } + } + } + if expression == 'document.documentElement.outerHTML': + return { + 'result': { + 'result': { + 'type': 'string', + 'value': 'Iframe content', + } + } + } + raise AssertionError(f'Unexpected method {method}') + + iframe_element._connection_handler.execute_command.side_effect = side_effect + + # Get inner HTML of iframe element + html = await iframe_element.inner_html + + # Should return iframe's document HTML + assert html == 'Iframe content' + + # Verify Runtime.evaluate was called with correct context + evaluate_calls = [ + call + for call in iframe_element._connection_handler.execute_command.await_args_list + if call.args[0]['method'] == 'Runtime.evaluate' + ] + # Should have two calls: one for document.documentElement, one for outerHTML + assert len(evaluate_calls) == 2 + outer_html_call = evaluate_calls[1] + assert ( + outer_html_call.args[0]['params']['expression'] + == 'document.documentElement.outerHTML' + ) + assert outer_html_call.args[0]['params']['contextId'] == 77 + + @pytest.mark.asyncio + async def test_inner_html_element_in_iframe_uses_call_function_on(self, element_in_iframe): + """Test inner_html on element inside iframe uses Runtime.callFunctionOn.""" + element_in_iframe._connection_handler.execute_command.return_value = { + 'result': { + 'result': { + 'type': 'string', + 'value': '', + } + } + } + + html = await element_in_iframe.inner_html + + # Should use callFunctionOn with this.outerHTML + assert html == '' + element_in_iframe._connection_handler.execute_command.assert_called_once() + call_args = element_in_iframe._connection_handler.execute_command.call_args[0][0] + assert call_args['method'] == 'Runtime.callFunctionOn' + assert call_args['params']['objectId'] == 'button-object-id' + assert 'this.outerHTML' in call_args['params']['functionDeclaration'] + + @pytest.mark.asyncio + async def test_inner_html_element_in_iframe_empty_response(self, element_in_iframe): + """Test inner_html on element inside iframe when response is empty.""" + element_in_iframe._connection_handler.execute_command.return_value = { + 'result': {} # Empty result + } + + html = await element_in_iframe.inner_html + + # Should return empty string when result is missing + assert html == '' + + @pytest.mark.asyncio + async def test_inner_html_regular_element_fallback(self, mock_connection_handler): + """Test inner_html falls back to DOM.getOuterHTML for regular elements.""" + attributes_list = ['id', 'regular-div', 'tag_name', 'div'] + element = WebElement( + object_id='div-object-id', + connection_handler=mock_connection_handler, + attributes_list=attributes_list, + ) + mock_connection_handler.execute_command.return_value = { + 'result': {'outerHTML': '
Content
'} + } + + html = await element.inner_html + + # Should use DOM.getOuterHTML for regular elements + assert html == '
Content
' + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert call_args['method'] == 'DOM.getOuterHTML' + + +class TestFrameTreeHelpers: + """Test frame tree traversal and helper methods.""" + + @pytest.mark.asyncio + async def test_get_frame_tree_for_without_session_id(self, mock_connection_handler): + """Test _get_frame_tree_for without session_id.""" + mock_connection_handler.execute_command.return_value = { + 'result': { + 'frameTree': { + 'frame': { + 'id': 'main-frame', + 'url': 'https://example.com', + }, + 'childFrames': [], + } + } + } + + frame_tree = await WebElement._get_frame_tree_for(mock_connection_handler, None) + + assert frame_tree['frame']['id'] == 'main-frame' + # Should NOT have sessionId in command when session_id is None + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert 'sessionId' not in call_args + + @pytest.mark.asyncio + async def test_get_frame_tree_for_with_session_id(self, mock_connection_handler): + """Test _get_frame_tree_for WITH session_id (coverage for line 718).""" + mock_connection_handler.execute_command.return_value = { + 'result': { + 'frameTree': { + 'frame': { + 'id': 'oopif-frame', + 'url': 'https://other-origin.com', + }, + 'childFrames': [], + } + } + } + + frame_tree = await WebElement._get_frame_tree_for( + mock_connection_handler, 'session-abc-123' + ) + + assert frame_tree['frame']['id'] == 'oopif-frame' + # Should HAVE sessionId in command + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert call_args['sessionId'] == 'session-abc-123' + + def test_walk_frames_single_frame(self): + """Test _walk_frames with single frame (no children).""" + frame_tree = { + 'frame': { + 'id': 'single-frame', + 'url': 'https://example.com', + }, + 'childFrames': [], + } + + frames = list(WebElement._walk_frames(frame_tree)) + + assert len(frames) == 1 + assert frames[0]['id'] == 'single-frame' + + def test_walk_frames_nested_frames(self): + """Test _walk_frames with nested frames.""" + frame_tree = { + 'frame': { + 'id': 'main-frame', + 'url': 'https://example.com', + }, + 'childFrames': [ + { + 'frame': { + 'id': 'child-frame-1', + 'url': 'https://example.com/child1', + }, + 'childFrames': [ + { + 'frame': { + 'id': 'nested-frame', + 'url': 'https://example.com/nested', + }, + 'childFrames': [], + } + ], + }, + { + 'frame': { + 'id': 'child-frame-2', + 'url': 'https://example.com/child2', + }, + 'childFrames': [], + }, + ], + } + + frames = list(WebElement._walk_frames(frame_tree)) + + assert len(frames) == 4 + frame_ids = [f['id'] for f in frames] + assert 'main-frame' in frame_ids + assert 'child-frame-1' in frame_ids + assert 'nested-frame' in frame_ids + assert 'child-frame-2' in frame_ids + + def test_walk_frames_empty_tree(self): + """Test _walk_frames with empty/None tree.""" + frames = list(WebElement._walk_frames(None)) + assert len(frames) == 0 + + frames = list(WebElement._walk_frames({})) + assert len(frames) == 0 + + def test_walk_frames_filters_none_frames(self): + """Test _walk_frames filters out None frames.""" + frame_tree = { + 'frame': { + 'id': 'valid-frame', + 'url': 'https://example.com', + }, + 'childFrames': [ + None, # Should be filtered out + { + 'frame': None, # Should be filtered out + 'childFrames': [], + }, + { + 'frame': { + 'id': 'another-valid-frame', + 'url': 'https://example.com/valid', + }, + 'childFrames': [], + }, + ], + } + + frames = list(WebElement._walk_frames(frame_tree)) + + # Should only include valid frames + frame_ids = [f['id'] for f in frames if f] + assert 'valid-frame' in frame_ids + assert 'another-valid-frame' in frame_ids + assert len(frame_ids) == 2 + + @pytest.mark.asyncio + async def test_owner_backend_for_without_session_id(self, mock_connection_handler): + """Test _owner_backend_for without session_id.""" + mock_connection_handler.execute_command.return_value = { + 'result': {'backendNodeId': 456} + } + + backend_id = await WebElement._owner_backend_for( + mock_connection_handler, None, 'frame-123' + ) + + assert backend_id == 456 + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert 'sessionId' not in call_args + + @pytest.mark.asyncio + async def test_owner_backend_for_with_session_id(self, mock_connection_handler): + """Test _owner_backend_for WITH session_id (coverage for line 757).""" + mock_connection_handler.execute_command.return_value = { + 'result': {'backendNodeId': 789} + } + + backend_id = await WebElement._owner_backend_for( + mock_connection_handler, 'session-xyz-789', 'frame-456' + ) + + assert backend_id == 789 + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert call_args['sessionId'] == 'session-xyz-789' + + @pytest.mark.asyncio + async def test_owner_backend_for_missing_result(self, mock_connection_handler): + """Test _owner_backend_for when result is missing.""" + mock_connection_handler.execute_command.return_value = {} + + backend_id = await WebElement._owner_backend_for( + mock_connection_handler, None, 'frame-999' + ) + + assert backend_id is None + + +class TestFindFrameByOwner: + """Test _find_frame_by_owner method.""" + + @pytest.mark.asyncio + async def test_find_frame_by_owner_found(self, iframe_element, mock_connection_handler): + """Test _find_frame_by_owner successfully finds matching frame.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'target-frame', 'url': 'https://other.com'}, + 'childFrames': [], + } + ], + } + } + } + if method == 'DOM.getFrameOwner': + frame_id = command['params']['frameId'] + if frame_id == 'target-frame': + return {'result': {'backendNodeId': 999}} # Matches + return {'result': {'backendNodeId': 111}} # Doesn't match + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + + frame_id, frame_url = await iframe_element._find_frame_by_owner( + mock_connection_handler, None, 999 + ) + + assert frame_id == 'target-frame' + assert frame_url == 'https://other.com' + + @pytest.mark.asyncio + async def test_find_frame_by_owner_not_found(self, iframe_element, mock_connection_handler): + """Test _find_frame_by_owner when no matching frame exists.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'frame-1', 'url': 'https://other.com'}, + 'childFrames': [], + } + ], + } + } + } + if method == 'DOM.getFrameOwner': + return {'result': {'backendNodeId': 111}} # Never matches + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + + frame_id, frame_url = await iframe_element._find_frame_by_owner( + mock_connection_handler, None, 999 + ) + + assert frame_id is None + assert frame_url is None + + @pytest.mark.asyncio + async def test_find_frame_by_owner_skips_frames_without_id( + self, iframe_element, mock_connection_handler + ): + """Test _find_frame_by_owner skips frames without ID.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': {'url': 'https://example.com'}, # Missing 'id' + 'childFrames': [ + { + 'frame': {'id': '', 'url': 'https://other.com'}, # Empty ID + 'childFrames': [], + }, + { + 'frame': {'id': 'valid-frame', 'url': 'https://valid.com'}, + 'childFrames': [], + }, + ], + } + } + } + if method == 'DOM.getFrameOwner': + return {'result': {'backendNodeId': 999}} + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + + frame_id, frame_url = await iframe_element._find_frame_by_owner( + mock_connection_handler, None, 999 + ) + + # Should find the valid frame + assert frame_id == 'valid-frame' + assert frame_url == 'https://valid.com' + + +class TestFindChildByParent: + """Test _find_child_by_parent static method.""" + + def test_find_child_by_parent_direct_child(self): + """Test finding direct child by parent ID.""" + frame_tree = { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'child-1', 'parentId': 'main', 'url': 'https://child1.com'}, + 'childFrames': [], + }, + { + 'frame': {'id': 'child-2', 'parentId': 'main', 'url': 'https://child2.com'}, + 'childFrames': [], + }, + ], + } + + child_id = WebElement._find_child_by_parent(frame_tree, 'main') + + # Should find first matching child + assert child_id == 'child-1' + + def test_find_child_by_parent_nested_child(self): + """Test finding nested child by parent ID.""" + frame_tree = { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'child-1', 'parentId': 'main', 'url': 'https://child1.com'}, + 'childFrames': [ + { + 'frame': { + 'id': 'nested', + 'parentId': 'child-1', + 'url': 'https://nested.com', + }, + 'childFrames': [], + } + ], + } + ], + } + + nested_id = WebElement._find_child_by_parent(frame_tree, 'child-1') + + assert nested_id == 'nested' + + def test_find_child_by_parent_not_found(self): + """Test when no child matches parent ID.""" + frame_tree = { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'child-1', 'parentId': 'other', 'url': 'https://child1.com'}, + 'childFrames': [], + } + ], + } + + child_id = WebElement._find_child_by_parent(frame_tree, 'nonexistent') + + assert child_id is None + + def test_find_child_by_parent_empty_tree(self): + """Test with empty tree.""" + child_id = WebElement._find_child_by_parent(None, 'any-id') + assert child_id is None + + child_id = WebElement._find_child_by_parent({}, 'any-id') + assert child_id is None + + +class TestResolveOOPIFByParent: + """Test _resolve_oopif_by_parent method.""" + + @pytest.mark.asyncio + async def test_resolve_oopif_direct_child_success(self, iframe_element): + """Test OOPIF resolution via direct child target.""" + with patch('pydoll.elements.web_element.ConnectionHandler') as mock_handler_class: + browser_handler = AsyncMock() + browser_handler.execute_command = AsyncMock() + browser_handler._connection_port = 9222 + mock_handler_class.return_value = browser_handler + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Target.getTargets': + return { + 'result': { + 'targetInfos': [ + { + 'targetId': 'main-target', + 'type': 'page', + 'url': 'https://example.com', + }, + { + 'targetId': 'iframe-target', + 'type': 'iframe', + 'url': 'https://other-origin.com', + 'parentFrameId': 'parent-frame-123', # Matches + }, + ] + } + } + if method == 'Target.attachToTarget': + return {'result': {'sessionId': 'session-oopif-456'}} + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': { + 'id': 'oopif-root-frame', + 'url': 'https://other-origin.com', + }, + 'childFrames': [], + } + } + } + raise AssertionError(f'Unexpected method {method}') + + browser_handler.execute_command.side_effect = side_effect + + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_by_parent( + 'parent-frame-123', 999 + ) + + assert handler == browser_handler + assert session_id == 'session-oopif-456' + assert frame_id == 'oopif-root-frame' + assert url == 'https://other-origin.com' + + @pytest.mark.asyncio + async def test_resolve_oopif_scan_all_targets_find_child(self, iframe_element): + """Test OOPIF resolution by scanning all targets and finding child.""" + with patch('pydoll.elements.web_element.ConnectionHandler') as mock_handler_class: + browser_handler = AsyncMock() + browser_handler.execute_command = AsyncMock() + browser_handler._connection_port = 9222 + mock_handler_class.return_value = browser_handler + + call_count = 0 + + async def side_effect(command, timeout=60): + nonlocal call_count + method = command['method'] + + if method == 'Target.getTargets': + return { + 'result': { + 'targetInfos': [ + { + 'targetId': 'target-1', + 'type': 'page', + 'url': 'https://example.com', + # No parentFrameId - will trigger scan + }, + ] + } + } + if method == 'Target.attachToTarget': + call_count += 1 + return {'result': {'sessionId': f'session-{call_count}'}} + if method == 'Page.getFrameTree': + # Return tree with child matching parent + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'root', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': { + 'id': 'matching-child', + 'parentId': 'parent-frame-123', # Matches + 'url': 'https://child.com', + }, + 'childFrames': [], + } + ], + } + } + } + raise AssertionError(f'Unexpected method {method}') + + browser_handler.execute_command.side_effect = side_effect + + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_by_parent( + 'parent-frame-123', 999 + ) + + assert handler == browser_handler + assert session_id == 'session-1' + assert frame_id == 'matching-child' + assert url is None # URL not resolved in this path + + @pytest.mark.asyncio + async def test_resolve_oopif_scan_all_targets_match_root_owner(self, iframe_element): + """Test OOPIF resolution by matching root frame owner.""" + with patch('pydoll.elements.web_element.ConnectionHandler') as mock_handler_class: + browser_handler = AsyncMock() + browser_handler.execute_command = AsyncMock() + browser_handler._connection_port = 9222 + mock_handler_class.return_value = browser_handler + + call_count = 0 + + async def side_effect(command, timeout=60): + nonlocal call_count + method = command['method'] + + if method == 'Target.getTargets': + return { + 'result': { + 'targetInfos': [ + { + 'targetId': 'target-1', + 'type': 'iframe', + 'url': 'https://oopif.com', + }, + ] + } + } + if method == 'Target.attachToTarget': + call_count += 1 + return {'result': {'sessionId': f'session-{call_count}'}} + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': { + 'id': 'oopif-root', + 'url': 'https://oopif.com', + }, + 'childFrames': [], + } + } + } + if method == 'DOM.getFrameOwner': + # Return matching backend node ID + return {'result': {'backendNodeId': 999}} # Matches + raise AssertionError(f'Unexpected method {method}') + + browser_handler.execute_command.side_effect = side_effect + # Mock também o _connection_handler do iframe_element para DOM.getFrameOwner + iframe_element._connection_handler.execute_command.side_effect = side_effect + + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_by_parent( + 'parent-frame-123', 999 + ) + + assert handler == browser_handler + assert session_id == 'session-1' + assert frame_id == 'oopif-root' + assert url is None + + @pytest.mark.asyncio + async def test_resolve_oopif_not_found(self, iframe_element): + """Test OOPIF resolution when no matching target found.""" + with patch('pydoll.elements.web_element.ConnectionHandler') as mock_handler_class: + browser_handler = AsyncMock() + browser_handler.execute_command = AsyncMock() + browser_handler._connection_port = 9222 + mock_handler_class.return_value = browser_handler + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Target.getTargets': + return {'result': {'targetInfos': []}} # No targets + raise AssertionError(f'Unexpected method {method}') + + browser_handler.execute_command.side_effect = side_effect + + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_by_parent( + 'parent-frame-123', 999 + ) + + assert handler is None + assert session_id is None + assert frame_id is None + assert url is None + + +class TestResolveFrameByOwner: + """Test _resolve_frame_by_owner method.""" + + @pytest.mark.asyncio + async def test_resolve_frame_by_owner_success(self, iframe_element, mock_connection_handler): + """Test successful frame resolution by owner.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [ + { + 'frame': {'id': 'target', 'url': 'https://resolved.com'}, + 'childFrames': [], + } + ], + } + } + } + if method == 'DOM.getFrameOwner': + if command['params']['frameId'] == 'target': + return {'result': {'backendNodeId': 456}} + return {'result': {'backendNodeId': 999}} + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + + frame_id, doc_url = await iframe_element._resolve_frame_by_owner( + mock_connection_handler, None, 456, 'https://fallback.com' + ) + + assert frame_id == 'target' + assert doc_url == 'https://resolved.com' + + @pytest.mark.asyncio + async def test_resolve_frame_by_owner_not_found_returns_fallback( + self, iframe_element, mock_connection_handler + ): + """Test frame resolution returns fallback URL when not found.""" + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'Page.getFrameTree': + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [], + } + } + } + if method == 'DOM.getFrameOwner': + # Return non-matching backend node ID + return {'result': {'backendNodeId': 999}} + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + + frame_id, doc_url = await iframe_element._resolve_frame_by_owner( + mock_connection_handler, None, 456, 'https://fallback.com' + ) + + assert frame_id is None + assert doc_url == 'https://fallback.com' # Falls back to provided URL + + +class TestResolveOOPIFIfNeeded: + """Test _resolve_oopif_if_needed method.""" + + @pytest.mark.asyncio + async def test_resolve_oopif_if_needed_already_has_frame_id(self, iframe_element): + """Test OOPIF resolution skipped when frame_id already exists.""" + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_if_needed( + 'existing-frame-id', 'parent-frame', 999, 'https://example.com' + ) + + # Should return early without resolution + assert handler is None + assert session_id is None + assert frame_id == 'existing-frame-id' + assert url == 'https://example.com' + + @pytest.mark.asyncio + async def test_resolve_oopif_if_needed_no_parent_frame_id(self, iframe_element): + """Test OOPIF resolution skipped when no parent frame ID.""" + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_if_needed( + None, None, 999, 'https://example.com' + ) + + # Should return early without resolution + assert handler is None + assert session_id is None + assert frame_id is None + assert url == 'https://example.com' + + @pytest.mark.asyncio + async def test_resolve_oopif_if_needed_triggers_resolution(self, iframe_element): + """Test OOPIF resolution is triggered when frame_id missing but parent exists.""" + with patch.object( + iframe_element, '_resolve_oopif_by_parent', new=AsyncMock() + ) as mock_resolve: + mock_resolve.return_value = ( + 'mock-handler', + 'mock-session', + 'resolved-frame', + 'https://resolved.com', + ) + + handler, session_id, frame_id, url = await iframe_element._resolve_oopif_if_needed( + None, # No frame_id + 'parent-frame-123', # Has parent + 999, + 'https://original.com', + ) + + # Should call resolution + mock_resolve.assert_called_once_with('parent-frame-123', 999) + assert handler == 'mock-handler' + assert session_id == 'mock-session' + assert frame_id == 'resolved-frame' + assert url == 'https://resolved.com' + + +class TestInitIframeContext: + """Test _init_iframe_context method.""" + + def test_init_iframe_context_basic(self, iframe_element): + """Test basic iframe context initialization.""" + iframe_element._init_iframe_context( + 'frame-123', 'https://example.com', None, None + ) + + assert iframe_element._iframe_context is not None + assert iframe_element._iframe_context.frame_id == 'frame-123' + assert iframe_element._iframe_context.document_url == 'https://example.com' + assert iframe_element._iframe_context.session_handler is None + assert iframe_element._iframe_context.session_id is None + + def test_init_iframe_context_with_oopif_routing(self, iframe_element): + """Test iframe context initialization with OOPIF routing.""" + mock_handler = AsyncMock() + + iframe_element._init_iframe_context( + 'frame-456', 'https://oopif.com', mock_handler, 'session-789' + ) + + assert iframe_element._iframe_context is not None + assert iframe_element._iframe_context.frame_id == 'frame-456' + assert iframe_element._iframe_context.session_handler == mock_handler + assert iframe_element._iframe_context.session_id == 'session-789' + + def test_init_iframe_context_cleans_routing_attributes(self, iframe_element): + """Test iframe context init removes routing attributes.""" + # Set routing attributes + iframe_element._routing_session_handler = AsyncMock() + iframe_element._routing_session_id = 'old-session' + + iframe_element._init_iframe_context( + 'frame-new', 'https://example.com', None, None + ) + + # Routing attributes should be removed + assert not hasattr(iframe_element, '_routing_session_handler') + assert not hasattr(iframe_element, '_routing_session_id') + + +class TestCreateIsolatedWorldForFrame: + """Test _create_isolated_world_for_frame method.""" + + @pytest.mark.asyncio + async def test_create_isolated_world_without_session_id(self, mock_connection_handler): + """Test isolated world creation without session_id.""" + mock_connection_handler.execute_command.return_value = { + 'result': {'executionContextId': 42} + } + + context_id = await WebElement._create_isolated_world_for_frame( + 'frame-123', mock_connection_handler, None + ) + + assert context_id == 42 + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert 'sessionId' not in call_args + assert call_args['params']['frameId'] == 'frame-123' + assert 'pydoll::iframe::frame-123' in call_args['params']['worldName'] + + @pytest.mark.asyncio + async def test_create_isolated_world_with_session_id(self, mock_connection_handler): + """Test isolated world creation WITH session_id (coverage for line 1040).""" + mock_connection_handler.execute_command.return_value = { + 'result': {'executionContextId': 99} + } + + context_id = await WebElement._create_isolated_world_for_frame( + 'frame-456', mock_connection_handler, 'session-abc-789' + ) + + assert context_id == 99 + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert call_args['sessionId'] == 'session-abc-789' + assert call_args['params']['frameId'] == 'frame-456' + + @pytest.mark.asyncio + async def test_create_isolated_world_missing_execution_context_id( + self, mock_connection_handler + ): + """Test isolated world creation failure (no executionContextId).""" + mock_connection_handler.execute_command.return_value = { + 'result': {} # Missing executionContextId + } + + with pytest.raises( + InvalidIFrame, match='Unable to create isolated world for iframe' + ): + await WebElement._create_isolated_world_for_frame( + 'frame-fail', mock_connection_handler, None + ) + + +class TestSetIframeDocumentObjectId: + """Test _set_iframe_document_object_id method.""" + + @pytest.mark.asyncio + async def test_set_iframe_document_object_id_with_session_id(self, iframe_element): + """Test document object ID setting with session_id (coverage for line 1062).""" + # Set up iframe context with session_id + mock_session_handler = AsyncMock() + iframe_element._iframe_context = _IFrameContext( + frame_id='frame-123', + execution_context_id=42, + session_handler=mock_session_handler, + session_id='session-abc', + ) + + mock_session_handler.execute_command.return_value = { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'doc-object-123', + } + } + } + + await iframe_element._set_iframe_document_object_id(42) + + # Should use session_handler and add sessionId to command + mock_session_handler.execute_command.assert_called_once() + call_args = mock_session_handler.execute_command.call_args[0][0] + assert call_args['sessionId'] == 'session-abc' + assert call_args['params']['expression'] == 'document.documentElement' + assert call_args['params']['contextId'] == 42 + + # Should set document_object_id + assert iframe_element._iframe_context.document_object_id == 'doc-object-123' + + @pytest.mark.asyncio + async def test_set_iframe_document_object_id_without_session_id( + self, iframe_element, mock_connection_handler + ): + """Test document object ID setting without session_id.""" + # Set up iframe context without session_id + iframe_element._iframe_context = _IFrameContext( + frame_id='frame-456', + execution_context_id=77, + ) + + mock_connection_handler.execute_command.return_value = { + 'result': { + 'result': { + 'type': 'object', + 'objectId': 'doc-object-456', + } + } + } + + await iframe_element._set_iframe_document_object_id(77) + + # Should use main connection_handler + mock_connection_handler.execute_command.assert_called_once() + call_args = mock_connection_handler.execute_command.call_args[0][0] + assert 'sessionId' not in call_args + assert iframe_element._iframe_context.document_object_id == 'doc-object-456' + + @pytest.mark.asyncio + async def test_set_iframe_document_object_id_missing_object_id( + self, iframe_element, mock_connection_handler + ): + """Test document object ID setting failure (no objectId).""" + iframe_element._iframe_context = _IFrameContext( + frame_id='frame-fail', + execution_context_id=99, + ) + + mock_connection_handler.execute_command.return_value = { + 'result': {'result': {}} # Missing objectId + } + + with pytest.raises( + InvalidIFrame, match='Unable to obtain document reference for iframe' + ): + await iframe_element._set_iframe_document_object_id(99) + + +class TestEnsureIframeContext: + """Test _ensure_iframe_context method.""" + + @pytest.mark.asyncio + async def test_ensure_iframe_context_fails_without_frame_id( + self, iframe_element, mock_connection_handler + ): + """Test _ensure_iframe_context raises error when frame_id cannot be resolved.""" + from unittest.mock import AsyncMock, patch + + # Create a mock browser_handler + mock_browser_handler = AsyncMock() + mock_browser_handler._connection_port = 9222 + + async def side_effect(command, timeout=60): + method = command['method'] + if method == 'DOM.describeNode': + # Return node info without frame_id and with backend_node_id + return { + 'result': { + 'node': { + 'frameId': 'parent-frame', + 'backendNodeId': 789, + 'contentDocument': {}, # No frameId here + } + } + } + if method == 'Page.getFrameTree': + # Return empty tree (no frames to match) + return { + 'result': { + 'frameTree': { + 'frame': {'id': 'main', 'url': 'https://example.com'}, + 'childFrames': [], + } + } + } + if method == 'DOM.getFrameOwner': + # Return non-matching backend node ID so frame resolution fails + return {'result': {'backendNodeId': 999}} + if method == 'Target.getTargets': + # Return empty targets list so OOPIF resolution fails + return {'result': {'targetInfos': []}} + raise AssertionError(f'Unexpected method {method}') + + mock_connection_handler.execute_command.side_effect = side_effect + mock_browser_handler.execute_command.side_effect = side_effect + + # Mock ConnectionHandler instantiation to return our mock + with patch( + 'pydoll.elements.web_element.ConnectionHandler', + return_value=mock_browser_handler, + ): + # Should raise InvalidIFrame when frame_id cannot be resolved + with pytest.raises( + InvalidIFrame, match='Unable to resolve frameId for the iframe element' + ): + await iframe_element._ensure_iframe_context() +