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.
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__)"
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:
- Which frame level is current when the link is clicked?
- How will you identify the new top-level handle without assuming order?
- After closing the child, which API restores the original window?
- 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?
It identifies the newly created handle relative to a known baseline without assuming that the handle list has a stable creation order.
What does switch_to.new_window("tab") do in
Selenium Python 4.47?
It creates a new top-level browsing context with a tab type hint and switches WebDriver to it.
After closing the child tab, why explicitly switch to the original handle?
The closed context is no longer a valid destination. Explicit restoration makes the next command deterministic instead of depending on browser/UI focus.
Why is default_content() insufficient for
returning from a child tab to the original tab?
It only restores the top-level frame context within the current top-level window handle; it does not change window handles.
Which evidence proves refresh actually reloaded this fixture?
The page-2 load marker stored in sessionStorage increments, while the current handle remains the same.
Official references and version notes
- Selenium 4.47 release notes — pinned stable baseline for this chapter.
- Selenium downloads — current stable binding and Selenium Server/Grid versions.
-
Browser navigation
—
get, back, forward, and refresh semantics. - Working with windows and tabs — handles, switching, creating, closing, and quitting top-level browsing contexts.
- Working with frames and iframes — switching by WebElement/name/index and returning to default content.
-
Selenium Python 4.47 SwitchTo API
—
window,new_window,frame,parent_frame, anddefault_content.
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.