Chapter 08Lesson 02~180 minutes

Navigation, Windows, Tabs, Frames, and Iframes: Guided Hands-On Workflow

Build one disposable loopback fixture and operate it deliberately. Every step records the current handle, URL/title, frame-visible state, and handle set so learners can see exactly which context each Selenium command reads or changes.

Local labHistoryTabs/windowsNested iframesCleanup

Learning objectives

  • Create a reproducible local multi-page, multi-window, nested-frame fixture without paid services or real accounts.
  • Use back, forward, and refresh while verifying URL/title and document-visible state.
  • Discover an application-opened child context by set difference rather than handle order.
  • Create an explicit Selenium tab, switch safely, close only the intended context, and restore the original handle.
  • Enter outer and nested iframes, use parent/default restoration, and verify frame-local state.
  • Finish with exactly one known top-level handle and a failure-safe quit().

1. Setup and preflight: isolated local fixture

Create a new empty directory such as selenium-ch08-lab. Use Python 3.10+ and an isolated virtual environment. The mandatory path pins Selenium 4.47.0 and assumes one supported local Chromium-family browser.

python -m venv .venv
# Linux/macOS:
. .venv/bin/activate
# Windows PowerShell alternative:
# .\.venv\Scripts\Activate.ps1
python -m pip install --upgrade pip
python -m pip install selenium==4.47.0
python -c "import selenium; print(selenium.__version__)"
Safety boundary: all pages are synthetic and served only from 127.0.0.1. Do not substitute an employer/customer site, real account, or production browser profile.

2. Generate the five-page context fixture

The following example makes the Generate the five-page context fixture behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from pathlib import Path

root = Path("site")
root.mkdir(exist_ok=True)

(root / "index.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Context Lab Home</title></head>
<body data-page="home">
<h1 id="home-title">Context Lab Home</h1>
<p id="home-state">top-level-home</p>
<a id="page2-link" href="/page2.html">Go to page 2</a>
<a id="child-link" href="/child.html" target="_blank" rel="noopener">Open child context</a>
<iframe id="outer-frame" name="outer-frame" title="Outer training frame" src="/outer.html"></iframe>
</body></html>""", encoding="utf-8")

(root / "page2.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Context Lab Page 2</title></head>
<body data-page="page2"><h1 id="page2-title">Page 2</h1><p id="load-count"></p>
<a id="home-link" href="/index.html">Home</a>
<script>
const n = Number(sessionStorage.getItem('page2Loads') || '0') + 1;
sessionStorage.setItem('page2Loads', String(n));
document.querySelector('#load-count').textContent = `page2-load:${n}`;
</script></body></html>""", encoding="utf-8")

(root / "child.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Child Context</title></head>
<body data-page="child"><h1 id="child-title">Child Context</h1><p id="child-state">child-ready</p></body></html>""", encoding="utf-8")

(root / "outer.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Outer Frame</title></head>
<body data-frame="outer"><p id="outer-state">outer-ready</p>
<iframe id="inner-frame" name="inner-frame" title="Inner training frame" src="/inner.html"></iframe>
</body></html>""", encoding="utf-8")

(root / "inner.html").write_text("""<!doctype html>
<html lang="en"><head><meta charset="utf-8"><title>Inner Frame</title></head>
<body data-frame="inner"><button id="inner-action">Inner action</button><p id="inner-state">inner-ready</p>
<script>document.querySelector('#inner-action').addEventListener('click',()=>document.querySelector('#inner-state').textContent='inner-clicked')</script>
</body></html>""", encoding="utf-8")

print(root.resolve())

Save this as make_fixture.py and run python make_fixture.py. The home page owns the top-level iframe, the outer frame owns the nested iframe, and the child link opens a second top-level browsing context.

python make_fixture.py
python -m http.server 8772 --bind 127.0.0.1 --directory site

Keep that terminal open. In a second terminal use the same virtual environment for the Selenium script.

3. Complete guided workflow: history → child handle → explicit tab → nested frames

The following example makes the Complete guided workflow: history → child handle → explicit tab → nested frames behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

from pathlib import Path
from urllib.parse import urlparse
import json
import selenium
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC

BASE = "http://127.0.0.1:8772/"
if (urlparse(BASE).hostname or "") not in {"127.0.0.1", "localhost", "::1"}:
    raise RuntimeError("Chapter 08 lab requires a loopback AUT")

Path("evidence").mkdir(exist_ok=True)
driver = webdriver.Chrome()
wait = WebDriverWait(driver, 5)
trace = []

def snapshot(label):
    row = {
        "label": label,
        "handle": driver.current_window_handle,
        "handles": sorted(driver.window_handles),
        "url": driver.current_url,
        "title": driver.title,
    }
    trace.append(row)
    print(row)

try:
    original = driver.current_window_handle
    driver.get(BASE + "index.html")
    snapshot("home")
    assert driver.find_element(By.ID, "home-state").text == "top-level-home"

    # History navigation in the same top-level context.
    driver.find_element(By.ID, "page2-link").click()
    wait.until(EC.title_is("Context Lab Page 2"))
    snapshot("page2")
    first_load = driver.find_element(By.ID, "load-count").text

    driver.back()
    wait.until(EC.title_is("Context Lab Home"))
    assert driver.current_window_handle == original
    snapshot("after-back")

    driver.forward()
    wait.until(EC.title_is("Context Lab Page 2"))
    snapshot("after-forward")

    driver.refresh()
    wait.until(EC.presence_of_element_located((By.ID, "load-count")))
    second_load = driver.find_element(By.ID, "load-count").text
    print("refresh evidence", first_load, "->", second_load)

    # Return home, then let the AUT open a child top-level context.
    driver.get(BASE + "index.html")
    before = set(driver.window_handles)
    driver.find_element(By.ID, "child-link").click()
    wait.until(EC.number_of_windows_to_be(len(before) + 1))
    child = (set(driver.window_handles) - before).pop()
    driver.switch_to.window(child)
    wait.until(EC.title_is("Child Context"))
    assert driver.find_element(By.ID, "child-state").text == "child-ready"
    snapshot("child")

    driver.close()                       # closes child only
    driver.switch_to.window(original)   # explicit restoration
    snapshot("restored-after-child")

    # Test-controlled top-level context. new_window creates AND switches.
    driver.switch_to.new_window("tab")
    explicit_tab = driver.current_window_handle
    driver.get(BASE + "child.html")
    snapshot("explicit-tab")
    driver.close()
    driver.switch_to.window(original)
    assert explicit_tab not in driver.window_handles

    # Nested frame context.
    driver.get(BASE + "index.html")
    wait.until(EC.frame_to_be_available_and_switch_to_it((By.ID, "outer-frame")))
    assert driver.find_element(By.ID, "outer-state").text == "outer-ready"
    wait.until(EC.frame_to_be_available_and_switch_to_it((By.ID, "inner-frame")))
    inner = driver.find_element(By.ID, "inner-state")
    assert inner.text == "inner-ready"
    driver.find_element(By.ID, "inner-action").click()
    assert inner.text == "inner-clicked"

    driver.switch_to.parent_frame()
    assert driver.find_element(By.ID, "outer-state").text == "outer-ready"
    driver.switch_to.default_content()
    assert driver.find_element(By.ID, "home-state").text == "top-level-home"

    assert driver.current_window_handle == original
    assert driver.window_handles == [original] or set(driver.window_handles) == {original}
    snapshot("final-one-handle")
    driver.save_screenshot("evidence/final.png")
    Path("evidence/context-trace.json").write_text(json.dumps({
        "selenium": selenium.__version__,
        "session": driver.session_id,
        "capabilities": {
            "browserName": driver.capabilities.get("browserName"),
            "browserVersion": driver.capabilities.get("browserVersion"),
            "platformName": driver.capabilities.get("platformName"),
        },
        "trace": trace,
    }, indent=2), encoding="utf-8")
finally:
    driver.quit()

The final handle assertion intentionally uses set identity as the semantic check. A list may be convenient for display, but the test contract is “only the original handle remains,” not “it appears at list index zero.”

4. What each operation reads or changes

The following table organizes the key choices and evidence for What each operation reads or changes. Use it together with the surrounding prose so the rows serve as a comparison aid rather than standalone rules.

Operation Context/session state DOM/AUT state Evidence to verify
back/forward/refresh same current top-level handle; history/document changes document reload/route-visible state may change URL, title, load marker, stale references
child link with target=_blank session gains another top-level handle child document loads handle-set difference + child title/state
switch_to.window changes current top-level context does not itself mutate AUT current handle + URL/title
new_window("tab") creates and switches to new top-level context blank document initially new current handle + handle set
switch_to.frame changes current frame context inside same top-level handle does not itself mutate app data frame-local element becomes findable
close() closes current top-level context browser state for that context ends closed handle disappears; restore another handle
quit() ends the entire WebDriver session all controlled contexts close process exit/teardown; no further session commands

5. Synchronize on observable context state, not time

The child workflow waits for the number of handles to change; frame workflow waits until the frame is available and switches as part of that condition; navigation waits on title or element state. A fixed sleep encodes elapsed time, not readiness. On a fast machine it wastes time; on a slow machine it may still be too short.

6. Challenge: choose the correct context control

Modify the fixture so the inner frame contains a link that opens a child tab. After it opens, the test must verify the child title and then return to the top-level home document in the original handle. Before coding, write the required state transitions:

  1. Which frame level is current when the link is clicked?
  2. How will you identify the new top-level handle without assuming order?
  3. After closing the child, which API restores the original window?
  4. Which API then guarantees top-level frame context?

The key distinction is that switch_to.window(original) and default_content() solve different dimensions of context.

7. Verification checklist and cleanup

  • Version output records Selenium 4.47.0 and actual browser capability values.
  • Back/forward/refresh remain on the original handle.
  • The application-created child is discovered by set difference.
  • Each closed handle disappears before reuse.
  • Nested frame state is observed only after explicit frame switches.
  • default_content() restores top-level DOM lookup.
  • Before teardown, exactly the original handle remains.
  • driver.quit() runs even when an assertion fails.

The following example makes the Verification checklist and cleanup behavior concrete. Read it with the stated assumptions, then compare its observable output or state changes with the explanation that follows.

# Stop the local http.server with Ctrl+C in its terminal.
# Preserve evidence/ if needed, then remove only the disposable lab directory.
cd ..
rm -rf selenium-ch08-lab   # Linux/macOS example
# Windows PowerShell alternative:
# Remove-Item -Recurse -Force .\selenium-ch08-lab

8. Summary and next step

The workflow separated history state, top-level handle state, frame context, and application state. Every transition had an observable proof and explicit restoration rule.

Knowledge check

Why use set difference after a link opens a new context?

What does switch_to.new_window("tab") do in Selenium Python 4.47?

After closing the child tab, why explicitly switch to the original handle?

Why is default_content() insufficient for returning from a child tab to the original tab?

Which evidence proves refresh actually reloaded this fixture?

Next lesson

Choose the right context design

Lesson 3 compares tabs versus windows, direct URL navigation versus link-driven behavior, handle discovery strategies, nested-frame abstractions, and test-isolation trade-offs.

Official references and version notes

Version and compatibility note

Version-sensitive behavior was rechecked against current primary documentation on 2026-08-28. Mandatory examples pin Selenium Python 4.47.0, require Python 3.10+, use a supported local Chromium-family browser with Selenium Manager for normal local driver resolution, and target only loopback fixtures. Window handles are treated as opaque identifiers; examples do not assume handle ordering. Chapter 08 intentionally avoids WebDriver BiDi/CDP because classic WebDriver navigation and context APIs are sufficient for the mandatory learning objective.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.