|

Day 14: Playwright SVG, Shadow DOM, File Upload, Download, and Scroll

Compact diagram of Playwright edge cases: svg, shadow DOM, file upload

This is Day 14 of the 21-day JS to Playwright Framework series. One lesson a day. JavaScript first. TypeScript next. Playwright Test now. Framework later.

Days 1–7 were the language. Day 8 was the object model. Day 9 typed that object. Day 10 installed Playwright. Day 11 found a field. Day 12 saved a session and read a table. Day 13 chose a document — select, frameLocator, keyboard, hover, drag, alerts. Today the document itself is not enough. The control you can *see* may be an SVG path, a node inside an open shadow root, a hidden <input type="file">, a download the OS owns, or a list that does not exist until you scroll.

I am Pramod Dutta. I teach SDETs in India for a living. The week I open SVG, someone always pastes //svg/path from Chrome DevTools and watches Playwright return an empty list. The week I open Shadow DOM, someone pastes an XPath that stops at the host. The week I open upload, someone tries fill() on a file input. Those three failures are the same class of mistake: you treated the page as a flat HTML document. It is not.

This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts later in the stack. This series started at console.log. Day 14 is the day the spec has to *choose a tree and a channel* — SVG namespace, open shadow, file input, download event, or scroll — before it chooses a locator.

All labs come from my public fundamentals repo: LearningPlaywrightFundamentals on branch main. I fetched five folders from raw GitHub: tests/12_Handle_SVG, tests/13_Shadow_DOM, tests/14_FileUpload, tests/15_File_Download, and tests/16_Scroll_toElement. I quote those files. I will not invent a file that is not there.

Classroom spellings stay. The map lab is 250_Advance_SVG_pROJECT.spec.ts — pROJECT, as GitHub serves it. The shadow lab is 251_Shadom_DOM.spec.ts — Shadom, as GitHub serves it. The download lab is 254_File_Downlaod.spec.ts — Downlaod, as GitHub serves it. I do not rename files to make this post prettier. I follow the tree.

If you want the video plus project path after you finish these 21 posts, the course is here: Playwright Automation Mastery. The series hub for every day lives here: JavaScript to TypeScript to Playwright Advanced Framework 21-Day Guide.

*Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.*

Compact diagram of Playwright edge cases: svg, shadow DOM, file upload

Contents

What you will be able to do after Day 14

By the end of this post you can:

  1. Click an SVG icon with a CSS locator (page.locator('svg').first()) the way 248_SVG_Project.spec.ts does on Flipkart search.
  2. Click #circle-blue, assert #shapes-output contains Blue circle, click a Q3 bar by role, pick a 4 stars radio, and iterate .bar for data-quarter the way 249_SVG_Practice.spec.ts does.
  3. Use XPath name() for SVG namespaced nodes — //*[name()='text'] and //*[name()='path' and contains(@class,'INUP')] — the way 250_Advance_SVG_pROJECT.spec.ts does on SimpleMaps India.
  4. Fill an open-shadow login through getByTestId('card-account') then card.locator('input[name="email"]') without writing a pierce helper.
  5. Increment a shadow cart twice and assert counter-value is 5 — as the file is written, including that starting count.
  6. Reach nested shadow fields card-inside-email, card-inside-password, card-inside-submit with getByTestId on the page.
  7. Upload a disk fixture with locator.setInputFiles([filePath]) on HerokuApp #file-upload and assert #uploaded-files.
  8. Admit that testdata.txt on main is 0 bytes, and that file1.jpg / file2.jpg exist as binary fixtures even though lab 253 does not read them from disk.
  9. Upload two in-memory files with { name, mimeType, buffer } the way 253_Multi_FileUpload.spec.ts does on PatternFly.
  10. Arm page.waitForEvent('download') *before* the click, then saveAs, the way 254_File_Downlaod.spec.ts does on download-static.
  11. Distinguish the live out/ + suggestedFilename() path from the commented downloads/tta-notes.txt block in that same file.
  12. Scroll a lazy list with scrollIntoViewIfNeeded and wait with expect.poll until the item count grows, the way 255_ScrollToView.spec.ts does.

That is the skill. Not the selector. The skill is picking the tree (SVG / shadow / light), then the channel (click / setInputFiles / download event / scroll), then the assertion — in that order.

The labs we are actually using

Clone the fundamentals repo and stay on main:

git clone https://github.com/PramodDutta/LearningPlaywrightFundamentals.git
cd LearningPlaywrightFundamentals
git checkout main

Five folders. I fetched every file GitHub lists in them. Those files, as GitHub serves them:

tests/12_Handle_SVG — SVG roots, shapes, chart bars, map paths.

  • README.md — module index, CSS vs name() XPath, run commands
  • 248_SVG_Project.spec.ts — Flipkart search, click first svg, scrape product titles
  • 249_SVG_Practice.spec.ts — TTA SVG widget: circle, Q3 bar, 4-star radio, iterate .bar
  • 250_Advance_SVG_pROJECT.spec.ts — SimpleMaps India, name()='text' labels, click path with class INUP (pROJECT in the filename)

tests/13_Shadow_DOM — open shadow only.

  • README.md — getByTestId hosts, scope from host, Playwright pierces open roots
  • 251_Shadom_DOM.spec.ts — card-account login, counter-cart increment, nested card-inside-* (Shadom in the filename)

tests/14_FileUpload

  • README.md — disk fixture vs in-memory { name, mimeType, buffer }
  • 252_FileUpload.spec.ts — HerokuApp /upload, #file-upload, testdata.txt
  • 253_Multi_FileUpload.spec.ts — PatternFly multiple-file-upload, two Buffer objects named file1.jpg and file2.jpg
  • testdata.txt — present, empty (0 bytes). The 14 README says so. Lab 252 still points at it.
  • file1.jpg and file2.jpg — local JPEG assets on main. Lab 253 does not path.join them. It builds buffers in memory that *happen to use those filenames*.

tests/15_File_Download

  • README.md — wait for download, click static control, save with suggested filename
  • 254_File_Downlaod.spec.ts — live path is download-static + saveAs('out/' + …) (Downlaod in the filename)
  • downloads/README.md — folder reserved for artifacts; it names tta-notes.txt as the classroom local artifact. That .txt is not committed on main (raw GitHub 404). I will not pretend it is in the tree.

tests/16_Scroll_toElement

  • README.md — scrollIntoViewIfNeeded, window.scrollBy / scrollTo, lazy expect.poll
  • 255_ScrollToView.spec.ts — live path is the lazy list; the other four scroll recipes are commented

I am not opening tests/17_Expect_Assertions today. That is Day 15. I am not inventing a closed-shadow lab. I am not inventing a pierce() helper. I am not inventing a tta-notes.txt blob. The 15 downloads README talks about it. GitHub does not serve the file.

Run each module from its README. Folder 12: tests/12_Handle_SVG. Shadow: tests/13_Shadow_DOM/251_Shadom_DOM.spec.ts. Upload: tests/14_FileUpload. Download: tests/15_File_Download/254_File_Downlaod.spec.ts. Scroll: tests/16_Scroll_toElement/255_ScrollToView.spec.ts. One SVG lesson the 12 README points at: tests/12_Handle_SVG/249_SVG_Practice.spec.ts.

Public demo sites need network. Flipkart, SimpleMaps, HerokuApp, PatternFly, and app.thetestingacademy.com widgets are live URLs in these specs. If a third-party page is down or the markup moved, the spec fails. That is a classroom fact, not a Playwright bug.

Several files still call waitForTimeout(5000) or 15000. I will point at those lines. I will not pretend they are the production habit. Auto-wait plus expect is the habit. The timeout is a classroom freeze-frame so the batch can see the UI.

Why Day 14 is the next framework decision

Day 13 asked which document. Day 14 asks which tree inside the document, and which OS channel.

Five things break a naive click-and-fill suite after frames are solved:

  1. SVG is not HTML. CSS still works. Naive XPath often does not, because SVG nodes live in a namespace. Folder 12 is that lesson. Lab 250 is the name() fix.
  2. Open Shadow DOM is not the light tree. XPath from page stops at the host. Playwright built-in locators pierce open roots. Folder 13 is that lesson. The diagram at the top of this post is the rule I want in every PR that mentions shadowRoot.
  3. A file chooser is not a text field. You do not type a path into a visible textbox and hope the OS dialog cooperates. You call setInputFiles. Folder 14 is that lesson.
  4. A download is not a navigation. If you click first and then wait for the file, you race the browser. You wait for the download event with the click. Folder 15 is that lesson.
  5. A lazy list is not in the DOM yet. A fixed count on first paint is a lie if the widget appends on scroll. You scroll the last item, then poll the count. Folder 16 is that lesson.

In a framework these five become five helpers, not five copy-pasted specs. Today we still read the classroom files. Day 20 and Day 21 will ask you to put setInputFiles and waitForEvent download behind a page object. Do not skip the messy files. The mess is the curriculum. Shadom, pROJECT, and Downlaod are part of that mess. Keep them until a dedicated cleanup PR.

Module 12 — SVG is a namespace, not a CSS crisis

The module README is the contract:

SVG nodes live in the SVG namespace, not HTML. CSS selectors work fine. XPath needs name() / local-name().

Read that twice. Most of the “SVG is impossible in Playwright” Slack messages I get are XPath messages. CSS #circle-blue and .bar work. Flipkart’s search icon is an svg. You can click page.locator('svg').first(). The pain starts when someone copies an XPath from DevTools that assumes HTML tag names.

Lab 248 — Flipkart search, click the first SVG, scrape titles

File: tests/12_Handle_SVG/248_SVG_Project.spec.ts

The file header already teaches the rule before any test runs. SVG nodes live in the SVG namespace, not HTML. CSS selectors work fine. XPath needs name() / local-name() because tag names are namespaced (for example svg:path). Common patterns: locate SVG, locate child shapes (path / rect / circle / g), click, hover, read attributes (d, fill, viewBox, stroke), assert state.

Then the live test. Import is test, expect, Locator from @playwright/test. Constant URL is https://www.flipkart.com/search. test.describe('SVG handling') with beforeEach that gotos that URL.

The test title is locate SVG root and assert visible. The body does not call toBeVisible() on the SVG. It fills input[name="q"] with macmini, clicks the first svg on the page (the search icon in this layout), then asserts a product result. Titles lie. Read the body.

page.locator('svg') is a collection. Flipkart has many SVGs — cart, chevrons, ratings. .first() is the classroom shortcut for “the search icon in this layout today”. It is also a flake magnet if Flipkart ships a new icon before the search box. I leave the file as GitHub serves it. In a framework you would pin a search-button role or a test id. This lab is teaching that an SVG is clickable with CSS, not teaching Flipkart page objects.

A commented loop is still in the file: page.locator('svg').all() assigned to svgAllElement, then for(let svgElement in svgAllElement). I do not uncomment it in this post. for...in on an array of locators is a JavaScript footgun we already killed in week one.

The live scrape is XPath on product cards, not on SVG:

        const firstResult: Locator = page.locator('//div[contains(@data-id,"CPU")]/div/a[2]');
        await expect(firstResult.first()).toBeVisible({ timeout: 15000 });

        const titlesResults: Locator = page.locator(
            "//div[contains(@data-id,'CPU') or contains(@data-id,'MP')]/div/a[2]"
        );

        const count: number = await titlesResults.count();
        console.log(`Total products found: ${count}`);

        for (let i = 0; i < count; i++) {
            const title: string | null = await titlesResults.nth(i).textContent();
            console.log(title?.trim());
        }

The SVG lesson in 248 is the click. The rest is “did search actually run”. data-id containing CPU or MP is Flipkart’s card markup on the day this lab was recorded. If Flipkart changes cards, the SVG click can still work and the assertion dies. Separate those failures in your notes. Do not “fix SVG” when the product grid moved.

The getByTitle('Search for products, brands and more') fill is commented. The live fill is input[name="q"]. I run the live line. The spec ends with waitForTimeout(5000). Freeze-frame.

Public URL. Network required. Flipkart can show a login wall, a CAPTCHA, or a layout shift. Classroom fact.

Lab 249 — the TTA widget is the SVG gym

File: tests/12_Handle_SVG/249_SVG_Practice.spec.ts

This is the file I want you to memorize. Flipkart is a demo. This widget is the gym. URL: https://app.thetestingacademy.com/playwright/widgets/svg. Same describe title SVG handling. Same leftover test title locate SVG root and assert visible. Body:

        const circleShape: Locator = page.locator('#circle-blue');
        await circleShape.click();

        const output = await page.locator('#shapes-output').innerText();
        expect(output).toContain('Blue circle');

        await page.getByRole('button', { name: /Q3 bar/ }).click();

        await page.getByRole('radio', { name: '4 stars' }).click();

        await page.waitForTimeout(5000);

        let allBars = await page.locator('.bar').all();
        for (const bar of allBars) {
            const q = await bar.getAttribute('data-quarter');
            await bar.click();
            console.log(q);
        }

CSS id #circle-blue — no namespace panic. Click. Then assert the *HTML* output region #shapes-output contains Blue circle. That is the pattern I want in a framework: interact with the SVG shape, assert on a stable HTML sink the app already exposes. Do not screenshot the circle unless you are doing visual testing. This file is not visual testing.

getByRole('button', { name: /Q3 bar/ }) is Day 11 showing up in Day 14. The bar is an SVG rectangle with a role and accessible name in this widget. Prefer role when the widget gave you one.

getByRole('radio', { name: '4 stars' }) is the star rating. SVG stars, radio semantics. Same rule.

Then the loop: page.locator('.bar').all(), read data-quarter, click every bar, console.log(q). The file has a comment // if.... as a classroom pause — “if this is Q4, assert something”. The file does not add that if. I will not invent the Q4 assertion. I will tell you the log is not an assertion. When you rewrite this for a suite, expect the quarter string or click a known quarter and assert #shapes-output. Prefer 249’s expect(output).toContain('Blue circle') as the model.

waitForTimeout(5000) sits *before* the bar loop. Freeze-frame. Not a wait for the bars to exist. Playwright already auto-waits the click.

Lab 250 — name() is the XPath tax for SVG

File: tests/12_Handle_SVG/250_Advance_SVG_pROJECT.spec.ts

Filename: pROJECT. I will type it that way every time.

The first line of the file is a commented XPath the batch used while exploring: //div[@id='admin1_map_inner']//*[name()='svg']//*[contains(@class,'sm_label')]. The live locator tightens name()='text'. Constant SimpleMaps is https://simplemaps.com/svg/country/in. Describe title is Map Selection. Test title is Generate the list of all states.

        const states = await page
            .locator(
                `//div[@id='admin1_map_inner']//*[name()='svg']//*[name()='text' and contains(@class,'sm_label')]`,
            )
            .allTextContents();

        // Printing all 36 states and clicking on UP
        for (const state of states) {
            if (state.trim() === "Uttar Pradesh") {
                await page
                    .locator(`//*[name()='path' and contains(@class,'INUP')]`)
                    .click();
            }
        }

Why name()? In HTML XPath, //svg means an element in the XHTML/HTML namespace whose local name is svg. SVG elements are in http://www.w3.org/2000/svg. A naive //svg//text often matches nothing. //*[name()='svg'] (or local-name()) ignores the namespace URI and matches the local name. That is the entire SVG-XPath lesson. CSS does not need this tax. XPath does.

The test title says generate the list of all states. The body collects allTextContents(), then if any label trims to Uttar Pradesh, clicks path whose class contains INUP. There is no expect on the list length. There is no console.log of each state in the live file — the comment says “Printing all 36 states” but the loop only clicks UP. I will not invent a console.log(state) that is not there. I will not invent expect(states.length).toBe(36). The comment claims 36. The code does not assert 36.

SimpleMaps is a third-party map. Class INUP is their code for Uttar Pradesh on the day this lab was recorded. If they change the class, the click dies. Classroom fact.

The 12 README cheat sheet, because I want it above your keyboard: CSS page.locator('svg'), #circle-blue, .bar. XPath //*[name()='svg']//*[name()='text'] and //*[name()='path' and contains(@class,'INUP')]. Actions: click #circle-blue, read .bar first data-quarter, expect #shapes-output to contain Blue circle.

CSS first. name() when you insist on XPath. Assert on HTML output when the app gives you one.

Module 13 — open Shadow DOM is a tree, not a pierce plugin

Day 13’s iframe rule was: locators do not cross documents. Today’s shadow rule is: Playwright’s built-in locators pierce open shadow roots. XPath does not. Closed shadow is not in this folder.

The module README:

Rely on Playwright’s built-in support for piercing open Shadow DOM roots with standard locators.

One spec. Filename 251_Shadom_DOM.spec.ts. Shadom. Keep it.

Lab 251 — three hosts on one TTA widget

File: tests/13_Shadow_DOM/251_Shadom_DOM.spec.ts

URL: https://app.thetestingacademy.com/playwright/widgets/shadow-dom. Describe title: Shadow handling. Test title: locate Shadow DOM and assert visible. beforeEach goes to that URL.

First host — account card:

        const card = page.getByTestId('card-account');
        await card.locator('input[name="email"]').fill('student@thetestingacademy.com');
        await card.locator('input[name="password"]').fill('pw');
        await card.getByTestId('card-account-submit').click();
        await expect(page.getByTestId('card-account-status'))
            .toContainText('student@thetestingacademy.com');

getByTestId('card-account') finds the host in the light DOM. The email and password fields are inside that host’s open shadow. card.locator('input[name="email"]') still works because Playwright walks open roots for locator engines (CSS, role, test id, text). You did not call shadowRoot. You did not write >>>. You did not import a pierce helper. The folder does not contain a pierce helper. Do not invent one in a PR that claims to follow this repo.

XPath is the exception people hit in reviews. An XPath //input[@name="email"] from the page often does not see the shadowed input. The 13 README says XPath does not pierce. Lab 251 never uses XPath. That is the lesson: if you need shadow, stay on the locator engines that pierce.

Status is asserted with page.getByTestId('card-account-status') — not card.getByTestId. Both can work if the test id is inside the same tree Playwright can see. The file uses page for the status. I quote the file.

Second host — the cart:

        const cart = page.getByTestId('counter-cart');
        await cart.getByRole('button', { name: 'Increment' }).click();
        await cart.getByRole('button', { name: 'Increment' }).click();
        await expect(cart.getByTestId('counter-value')).toHaveText('5');

Two increments. Expected text '5'. That means the widget’s starting value is not zero on this page — two clicks land on 5, so the initial displayed count is 3 unless the increment step is not 1. I will not invent the widget’s initial HTML. I will tell you what the spec asserts: after two Increment clicks, counter-value is 5. If the widget ships a different default, this line is the failure. Read the assertion. Do not “fix Shadow DOM” when the default count changed.

Third block — nested host:

        await page.getByTestId('nested-host');
        await page.getByTestId('card-inside-email').fill('pramod@thetestingacdemy.com');
        await page.getByTestId('card-inside-password').fill('pramod@123');
        await page.getByTestId('card-inside-submit').click();

        await page.waitForTimeout(15000);

Honesty, because this is a classroom file, not a polished framework.

The first line is a no-op locator. await page.getByTestId('nested-host') builds a locator and does not click, fill, or expect it. Playwright locators are lazy. That await does not search the page. The nested fields are then addressed from page with card-inside-* test ids. Playwright still pierces. The unused host locator is a leftover “look, nested-host exists” comment in code form. I leave it. I do not invent const nested = page.getByTestId('nested-host') then nested.getByTestId('card-inside-email') unless that is what the file does. It is not.

The email is pramod@thetestingacdemy.com. That is the spelling in the file — acdemy, not academy. I will not “fix” it in the quote. There is no status expect after nested submit. The file ends on waitForTimeout(15000). Freeze-frame. When you rewrite, add toContainText the way the first card already does.

Closed shadow roots (attachShadow({ mode: 'closed' })) are not demonstrated in this folder. I will not write a CDP workaround. I will not write page.evaluate into shadowRoot. This module is open shadow plus getByTestId. That is enough for most design systems that wanted testability. If a vendor ships closed shadow with no test ids, that is a product problem you escalate, not a Day 14 helper I invent.

The diagram: light DOM host on the left, open #shadow-root on the right, XPath hitting the wall, getByTestId tunneling. Iframe was a second document. Shadow is a second tree. Do not use frameLocator on a shadow host. Do not use shadow APIs on an iframe. Pick the boundary you actually have.

Module 14 — upload is setInputFiles, not fill

The OS file dialog is not a DOM node you can click reliably across headed, headless, and CI. Playwright’s contract is: find the input type=file (it can be hidden), call setInputFiles. The 14 README says that in one sentence.

Lab 252 — one disk file on HerokuApp

File: tests/14_FileUpload/252_FileUpload.spec.ts

URL: https://the-internet.herokuapp.com/upload. Describe: FileUpload handling. Test: locate FileUpload and upload. Import includes unused Locator and used path.

        const filePath = path.join(__dirname, 'testdata.txt');
        console.log('File path:', filePath);

        await page.locator("#file-upload").setInputFiles([filePath]);

        await page.getByRole("button", { name: "Upload" }).click();

        await expect(page.locator('#uploaded-files')).toContainText('testdata.txt');

        await page.waitForTimeout(15000);

path.join(__dirname, 'testdata.txt') — fixture next to the spec. Array form [filePath] even for one file. #file-upload is the input id on the-internet. Role Upload submits. #uploaded-files must contain the filename.

testdata.txt is 0 bytes on main. I fetched it. HTTP 200, size 0. The 14 README is honest: “Empty text fixture used by the single-file upload test.” HerokuApp still accepts an empty text file and echoes the name. The assertion is the name, not the contents. If you expected a non-empty payload, this fixture will disappoint you. I will not invent lorem ipsum into a file I did not find. Put real bytes in *your* working copy if you need content assertions. Do not claim GitHub’s testdata.txt has content.

file1.jpg and file2.jpg are on main (raw GitHub 200). Lab 252 does not use them. They are, in the README’s words, “available for file upload practice or extending the multi-file example.” Extending is homework. The committed spec does not setInputFiles those JPEGs from disk.

Lab 253 — in-memory files, PatternFly, filenames without disk reads

File: tests/14_FileUpload/253_Multi_FileUpload.spec.ts

This is the lab people skip and then fail interviews on. You do not always have a file on disk. CI can build a payload in memory. URL: https://www.patternfly.org/components/file-upload/multiple-file-upload/. Same describe title FileUpload handling. Same test title locate FileUpload and upload. import path is unused.

The live upload targets div.pf-v6-c-multiple-file-upload input and passes two objects:

  • { name: 'file1.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('image from thetestingacademy code') }
  • { name: 'file2.jpg', mimeType: 'image/jpeg', buffer: Buffer.from('this is test') }

Then it clicks .pf-v6-c-button pf-m-secondary and freezes with waitForTimeout(15000).

Three facts. I will not sand them down.

One. name: 'file1.jpg' here is a payload filename, not path.join(__dirname, 'file1.jpg'). The buffer is ASCII text wearing a image/jpeg mime type. Same for file2.jpg. The JPEG files on disk are not read in this spec. If you thought 253 uploads the binary fixtures, read the buffer lines again.

Two. import path from 'path' is unused. Dead import. Same class as 252’s unused Locator.

Three. The click locator string is .pf-v6-c-button pf-m-secondary. In CSS that is a descendant combinator: an element with class pf-v6-c-button that contains an element named pf-m-secondary — which is not a tag. The README in-memory snippet does not include this click. PatternFly’s real secondary button is usually two classes on one node (a dot before pf-m-secondary, no space). I will not silently “fix” the locator in the quote. I will tell you the file has that string. If the click no-ops or times out, inspect that selector before you rewrite Playwright’s upload API.

No expect after upload in 253. Freeze-frame timeout. 252 is the file that asserts. Prefer 252’s #uploaded-files habit when you rewrite 253 against a page you control.

The 14 README’s two patterns are the interview answers. Disk: path.join(__dirname, 'testdata.txt') then setInputFiles([filePath]) on #file-upload. Memory: setInputFiles with { name, mimeType, buffer }. The README buffer string is 'image content'. The spec buffer strings are 'image from thetestingacademy code' and 'this is test'. Quote the spec when you run the spec. Quote the README when you teach the API.

PatternFly and HerokuApp are third-party. Network required. Markup can move.

Module 15 — download is an event, not a click with hope

If you click a download link and then look at the filesystem, you are racing. Playwright’s contract is page.waitForEvent('download') armed with the click, usually via Promise.all. Same shape as Day 13’s page.once('dialog') *before* the alert click. Listen first. Then trigger.

Lab 254 — Downlaod, live download-static, commented tta-notes

File: