Skip to main content

Performing Hand Seals...

SCROLLS ▸ ANBU - HOSTILE DOM
08

Hostile DOM

ANBU Black Ops → Levels A1-A3

You learn by doing, not reading. You already drive the browser, wrap pages and components, reuse a logged-in session, and mock the network. ANBU is the advanced DOM: the three places where a plain get_by_role either reaches further than you expect (open shadow DOM), needs a different entry point (an iframe), or needs a different action (drag-and-drop). One rule ties the rank together: reach the element FIRST. Every advanced-DOM element in IchiRamen hides behind a flow, so every level ships a real preamble of clicks that makes the element real and visible - the preamble is load-bearing, not decor. Three short levels, each one small step: read the concept, predict the answer, then switch to your IDE and make a real failing pytest pass against the live app. When your terminal shows the expected pass count, come back and tick the level to unlock the next.

ONE-TIME SETUP

You already cloned the practice repo and installed the toolchain back in Academy A1. If you are on a fresh machine, here it is again - same repo, same toolchain.

git clone https://github.com/tebrex1995/ichiramen-dojo-py
pip install -r requirements.txt && playwright install chromium

Tests run against the live app via base_url in pytest.ini, so page.goto("/dojo/app/index.html") takes a relative path. ANBU tests live in tests/anbu/. No new dependency and no new fixture: all three techniques are actions on the page fixture you already have. Pinned toolchain: playwright 1.60.0, pytest 8.4.2 (needs Python 3.11+).

A1

Shadow DOM (the spice-picker)

One concept: Playwright pierces an OPEN shadow root for you - get_by_role, get_by_text, get_by_test_id, and CSS all see through it as if it were not there. One skill: run the preamble to make the picker visible, then reach its shadow radios with a normal role locator.

Some components hide their internals in a shadow root - a mini-DOM attached to a host element so its styles and markup do not leak into the page. IchiRamen's spice picker is one: the host is <spice-picker data-testid="spice-picker"> and its three radio buttons (Mild / Medium / Hot) live inside an OPEN shadow root. The good news: you do NOT need a special API. Role, text, test-id, and CSS locators all pierce an open shadow boundary automatically. Two limits to know: XPath does not pierce, and a closed shadow root is not reachable - but this picker is open and you use a role locator, so it just works.

The catch the old dojo got wrong: the picker sits inside form#checkoutForm, inside the cart panel - both display:none until you open them. So before any assertion you run a preamble: goto the app, add Miso Ramen, open the Shopping cart, then click Checkout. Only after Checkout is the picker visible and its shadow radios reachable. The clicks ARE the path to the element - never hand-wave them.

Two small notes on the reference below. to_have_attribute("aria-checked", "true") is new here: it asserts an element's attribute, which is how you read the picker's selection (the app sets aria-checked="true" on the chosen radio and clears the others). And role names match by substring, so name="Hot" matches the button text even though it carries a trailing chili emoji - use the plain word, not the emoji-laden name.

✓ GOOD - run the preamble, then pierce the open shadow root
# preamble ran: add Miso -> Shopping cart -> Checkout
picker = page.get_by_test_id("spice-picker")
picker.get_by_role("radio", name="Hot").click()  # works: open root is pierced
✗ BAD - no preamble, the host stays hidden
page.goto("/dojo/app/index.html")
picker = page.get_by_test_id("spice-picker")
picker.get_by_role("radio", name="Hot").click()  # times out: picker is hidden
PREDICT - ANBU-1

The spice buttons live inside <spice-picker>'s OPEN shadow root. After the preamble you write page.get_by_test_id("spice-picker").get_by_role("radio", name="Hot").click(). What happens?

Open this file. It ships RED for the exact reachability bug the old dojo shipped: the preamble is missing its final Checkout click, so the checkout form stays display:none and the picker host is in the DOM but never visible. expect(picker).to_be_visible() auto-waits and times out. Restore the one Checkout line and run.

tests/anbu/test_spice_picker_starter.py
✗ EDIT - tests/anbu/test_spice_picker_starter.py: restore the Checkout click
from playwright.sync_api import expect


def open_checkout_form(page):
    # Real preamble: add a bowl, open the cart, click Checkout.
    # Only after this is the checkout form (and the spice picker) visible.
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    # TODO: one line is missing. The spice picker only appears after Checkout:
    #       page.get_by_role("button", name="Checkout").click()


def test_spice_picker_inside_shadow_dom(page):
    open_checkout_form(page)

    picker = page.get_by_test_id("spice-picker")
    expect(picker).to_be_visible(timeout=3000)  # picker stays hidden -> times out

Without the Checkout click the checkout form stays display:none, so the host resolves in the DOM but never becomes visible - the assertion times out on a hidden <spice-picker>. The one missing piece is page.get_by_role("button", name="Checkout").click().

✓ Reference - tests/anbu/test_spice_picker.py
from playwright.sync_api import expect


def open_checkout_form(page):
    # Real preamble: add a bowl, open the cart, click Checkout.
    # Only after this is the checkout form (and the spice picker) visible.
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    page.get_by_role("button", name="Checkout").click()


def test_spice_picker_inside_shadow_dom(page):
    open_checkout_form(page)

    picker = page.get_by_test_id("spice-picker")
    expect(picker).to_be_visible()

    # "Medium" ships pre-selected (aria-checked="true").
    medium = picker.get_by_role("radio", name="Medium")
    expect(medium).to_have_attribute("aria-checked", "true")

    # Click "Hot" inside the open shadow root and assert the selection moved.
    hot = picker.get_by_role("radio", name="Hot")
    hot.click()
    expect(hot).to_have_attribute("aria-checked", "true")
    expect(medium).to_have_attribute("aria-checked", "false")

Real locators used: get_by_test_id("spice-picker") is the host <spice-picker>; get_by_role("radio", name="Mild" / "Medium" / "Hot") are the three role="radio" buttons built inside the open shadow root. The aria-checked flip is the app's own behavior: clicking a radio sets its aria-checked="true" and clears the others.

The two limits, spelled out. Open shadow roots are pierced for role / text / test-id / CSS locators, but XPath does not pierce a shadow boundary, and a closed shadow root is not reachable at all - you would need evaluate() as a last resort. Both are out of scope here because this picker is open and you use a role locator.

pytest tests/anbu/test_spice_picker_starter.py

Expected result once you restore the Checkout click: 1 passed.

A2

Iframes (the order-confirmation frame)

One concept: an iframe is a whole separate document embedded in the page; your normal page.get_by_* locators search the MAIN document only and cannot see inside it. One skill: page.frame_locator("#confirmFrame") points at the frame, then you chain a normal locator to reach inside.

Your normal page locators search the main document only - they cannot see into a frame. To reach in, you first point at the frame, then locate inside it: page.frame_locator("#confirmFrame") returns a frame locator, and page.frame_locator("#confirmFrame").get_by_role("button", name="Continue") reaches the button in the iframe. Everything you know - get_by_role, expect, .click() - works once you are pointed at the frame. IchiRamen shows a confirmation iframe after you place an order: <iframe id="confirmFrame">, hidden until checkout submit fills its srcdoc. Inside is a heading <h1>Order Confirmed!</h1> and a <button> Continue that posts a message to the parent, which then hides the iframe.

The iframe only appears after a full checkout, so the preamble is longer here: goto, add Miso Ramen, open Shopping cart, click Checkout, then fill the three required fields - Full name, Delivery address, Phone number - and click Place order. That submit injects and shows the iframe. One field is fussy: use get_by_label("Delivery address", exact=True). Without exact=True it also matches a "Delivery addresses" listbox on the page and Playwright raises a strict-mode violation - so the exact=True is load-bearing.

✓ GOOD - point at the frame first, then locate inside it
# order placed
frame = page.frame_locator("#confirmFrame")
expect(frame.get_by_role("heading", name="Order Confirmed!")).to_be_visible()
frame.get_by_role("button", name="Continue").click()  # acts INSIDE the iframe
✗ BAD - search the main frame, the heading is never found
# order placed
heading = page.get_by_role("heading", name="Order Confirmed!")  # main frame only
expect(heading).to_be_visible()  # times out: heading lives in the iframe
PREDICT - ANBU-2

After placing an order, the "Continue" button is inside #confirmFrame. You write page.get_by_role("button", name="Continue").click() (no frame_locator). What happens?

Open this file. It ships RED for the classic iframe mistake: the preamble is whole and the order is placed, but the heading lookup queries the MAIN frame instead of the iframe, so it never resolves and to_be_visible times out. The one fix is to wrap the lookup in page.frame_locator("#confirmFrame"). Fix it and run.

tests/anbu/test_confirm_iframe_starter.py
✗ EDIT - tests/anbu/test_confirm_iframe_starter.py: wrap the heading lookup in the frame
from playwright.sync_api import expect


def place_order(page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    page.get_by_role("button", name="Checkout").click()
    page.get_by_label("Full name").fill("Naruto Uzumaki")
    page.get_by_label("Delivery address", exact=True).fill("Konoha Village, Gate 4")
    page.get_by_label("Phone number").fill("0700000000")
    page.get_by_role("button", name="Place order").click()


def test_confirm_iframe_continue(page):
    place_order(page)

    # TODO: the heading is INSIDE the #confirmFrame iframe. A plain page locator
    #       cannot see in. Wrap it in the frame:
    #       page.frame_locator("#confirmFrame").get_by_role("heading", name="Order Confirmed!")
    heading = page.get_by_role("heading", name="Order Confirmed!")  # wrong: main frame
    expect(heading).to_be_visible(timeout=3000)

The heading exists, but only inside the iframe document, so the main-frame locator never resolves and the assertion times out. The one missing piece is wrapping the lookup in page.frame_locator("#confirmFrame").

✓ Reference - tests/anbu/test_confirm_iframe.py
from playwright.sync_api import expect


def place_order(page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Shopping cart").click()
    page.get_by_role("button", name="Checkout").click()
    page.get_by_label("Full name").fill("Naruto Uzumaki")
    page.get_by_label("Delivery address", exact=True).fill("Konoha Village, Gate 4")
    page.get_by_label("Phone number").fill("0700000000")
    page.get_by_role("button", name="Place order").click()


def test_confirm_iframe_continue(page):
    place_order(page)

    # The confirmation lives INSIDE the iframe. frame_locator reaches in.
    frame = page.frame_locator("#confirmFrame")
    expect(frame.get_by_role("heading", name="Order Confirmed!")).to_be_visible()

    # Continue posts 'confirmContinue' to the parent, which hides the iframe.
    frame.get_by_role("button", name="Continue").click()
    expect(page.locator("#confirmFrame")).to_be_hidden()

Real locators used: page.frame_locator("#confirmFrame") is the iframe; frame.get_by_role("heading", name="Order Confirmed!") and frame.get_by_role("button", name="Continue") reach the heading and button inside its srcdoc. Everything chained onto the frame locator acts inside the iframe.

The one asymmetry to notice. The final assert - that the iframe hides after Continue - uses page.locator("#confirmFrame"), NOT the frame locator. That is deliberate: you are now asserting on the OUTER iframe element itself (the parent's message handler hides it), not on something inside it. Reach INSIDE with frame_locator; assert on the frame element itself with page.locator.

pytest tests/anbu/test_confirm_iframe_starter.py

Expected result once you wrap the lookup in the frame: 1 passed.

A3

Drag-and-drop (reorder the cart)

One concept: source.drag_to(target) does the whole gesture - hover the source, press the mouse, move to the target, release - which the browser turns into native drag events. One skill: drag the first cart row onto the second and assert the hidden order input flipped.

The cart rows are HTML5-draggable (draggable="true"). Dragging one row onto another swaps them in the cart array and re-renders. A hidden input <input id="cartOrder" data-testid="cart-order"> always holds the current order as comma-joined item ids, updated on every render - that hidden input is your assertion target: read its value with to_have_value(...) and you know the order changed. The skill is source.drag_to(target): for an element with draggable="true" the browser turns that mouse sequence into native drag events, firing the app's dragstart / dragover / drop handlers. No extra setup. (to_have_value and hover are both new here; they are self-explanatory siblings of the assertions and actions you already use.)

Honest note on reliability. HTML5 native drag-and-drop is famously finicky with automation, so this was not assumed. Against THIS SUT on playwright 1.60.0, source.drag_to(target) reordered the cart on 5 of 5 consecutive runs, and the manual mouse fallback (below) worked on 3 of 3. So for IchiRamen drag_to is the idiomatic primary and the manual sequence is a stable fallback - no overclaim beyond what ran.

A target-precision gotcha worth teaching. Each cart row has remove / quantity buttons in its CENTER, and drag_to drops at the target's center. Dropping onto the SECOND row's body correctly fires that row's drop handler. But dropping a row onto its OWN center - for example first.drag_to(first) - lands the mouse-up on that row's remove button and DELETES the item instead of reordering (#cartOrder becomes just tonkotsu). Lesson: drag onto a DIFFERENT row, and assert the new order, not just "something changed."

✓ GOOD - drag the first row onto the second, then assert
first = page.get_by_test_id("cart-item-0")
second = page.get_by_test_id("cart-item-1")
first.drag_to(second)
expect(page.get_by_test_id("cart-order")).to_have_value("tonkotsu,miso-ramen")
✗ BAD - clicking a row does not reorder
# no drag; clicking the center can even hit a child button
page.get_by_test_id("cart-item-0").click()  # may remove the item
PREDICT - ANBU-3

A two-item cart starts with #cartOrder value miso-ramen,tonkotsu. You run page.get_by_test_id("cart-item-0").drag_to(page.get_by_test_id("cart-item-1")). What is the new #cartOrder value?

Open this file. It ships RED for the plainest reason: the drag line is missing, so #cartOrder stays miso-ramen,tonkotsu and to_have_value("tonkotsu,miso-ramen") times out on the unchanged value. The one fix is first.drag_to(second). (A no-drag starter is chosen on purpose over a "wrong target" one, because a wrong target here can REMOVE an item and muddy the lesson - see the precision gotcha above.)

tests/anbu/test_cart_reorder_starter.py
✗ EDIT - tests/anbu/test_cart_reorder_starter.py: add the drag_to line
from playwright.sync_api import expect


def two_item_cart(page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Add Tonkotsu to cart").click()
    page.get_by_role("button", name="Shopping cart").click()


def test_drag_to_reorder(page):
    two_item_cart(page)

    order = page.get_by_test_id("cart-order")
    first = page.get_by_test_id("cart-item-0")
    second = page.get_by_test_id("cart-item-1")

    # TODO: one line is missing. Drag the first row onto the second:
    #       first.drag_to(second)

    expect(order).to_have_value("tonkotsu,miso-ramen", timeout=3000)

With no drag, #cartOrder stays miso-ramen,tonkotsu, so the assertion times out showing the unchanged value. The one missing piece is first.drag_to(second).

✓ Reference - tests/anbu/test_cart_reorder.py
from playwright.sync_api import expect


def two_item_cart(page):
    page.goto("/dojo/app/index.html")
    page.get_by_role("button", name="Add Miso Ramen to cart").click()
    page.get_by_role("button", name="Add Tonkotsu to cart").click()
    page.get_by_role("button", name="Shopping cart").click()


def test_drag_to_reorder(page):
    two_item_cart(page)
    order = page.get_by_test_id("cart-order")
    expect(order).to_have_value("miso-ramen,tonkotsu")  # starting order

    first = page.get_by_test_id("cart-item-0")
    second = page.get_by_test_id("cart-item-1")
    first.drag_to(second)

    expect(order).to_have_value("tonkotsu,miso-ramen")  # reordered

Real locators used: the add buttons Add Miso Ramen to cart / Add Tonkotsu to cart; the draggable rows get_by_test_id("cart-item-0") / cart-item-1; and the hidden order input get_by_test_id("cart-order"), whose value is set from the cart array on every render.

Equal alternative - the manual mouse fallback (read only, not a task). If you ever meet a different app where drag_to does nothing, this manual sequence is the known workaround - the repeated move events are what make the browser commit the drag. It was verified stable on this SUT too (3/3), but drag_to is the primary; you do not need this here.

optional equal alternative - read only - manual mouse fallback
first.hover()
page.mouse.down()
second.hover()
second.hover()      # the second move helps the browser commit the drag
page.mouse.up()

Ticking A3 completes rank-08 "ANBU" and ends the advanced-DOM trio.

pytest tests/anbu/test_cart_reorder_starter.py

Expected result once you add the drag line: 1 passed.

← Jonin - API & Mocking Next: Hokage - CI & Gauntlet →