|

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.tspROJECT, as GitHub serves it. The shadow lab is 251_Shadom_DOM.spec.tsShadom, as GitHub serves it. The download lab is 254_File_Downlaod.spec.tsDownlaod, 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.mdgetByTestId hosts, scope from host, Playwright pierces open roots
  • 251_Shadom_DOM.spec.tscard-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.mdscrollIntoViewIfNeeded, 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: tests/15_File_Download/254_File_Downlaod.spec.ts

Filename: Downlaod. Keep it.

The test.describe title is File Upload Demo - TestingAcademy. The folder is File Download. The URL is https://app.thetestingacademy.com/playwright/widgets/upload-download. The test title is demo: upload file using setInputFiles. Titles lie again. Body:

The commented block waits for download, clicks getByTestId('download-text'), expects suggestedFilename() to contain tta-notes, and saveAs into path.join(__dirname, 'downloads', 'tta-notes.txt').

The live path:

        const [staticDownload] = await Promise.all([
            page.waitForEvent('download'),
            page.getByTestId('download-static').click()
        ]);

        await staticDownload.saveAs('out/' + staticDownload.suggestedFilename());
  1. Promise.all so the wait and the click start together.
  2. Event name 'download'.
  3. Trigger getByTestId('download-static').
  4. saveAs('out/' + staticDownload.suggestedFilename()).

There is no expect on the live path. I run what is not commented. I will not assert tta-notes against download-static. I do not know the static file’s suggested name from a comment about a different button.

downloads/README.md says generated artifacts should stay isolated, and names tta-notes.txt as the current local artifact filename. I fetched tests/15_File_Download/downloads/tta-notes.txt. Raw GitHub 404. The README names a local artifact. The blob is not on main. Do not invent it. The commented saveAs would create it at runtime. The live saveAs writes under out/ plus whatever suggestedFilename() returns. out/ is not a file in this folder on GitHub. It is a runtime directory the spec assumes.

import path from 'path' is used only in the commented block. Live path concatenates 'out/' plus the suggested name. Unused import in the running test. Same classroom leftover pile as 253.

The 15 module README still describes the lesson as: wait for download, click the static control, save using Playwright’s suggested filename. That matches the live lines, not the commented tta-notes expect.

If you uncomment the download-text block, you have two downloads in one test unless you re-comment the static one. The file does not run both. I will not run both in this post.

Module 16 — scroll until the list grows, then poll

Playwright auto-scrolls many actions. You still need an explicit scroll when the thing you care about is not in the DOM until a scroll container fires a lazy load. You cannot click a li that does not exist yet. Folder 16 is that lesson.

Lab 255 — live lazy list; four recipes commented

File: tests/16_Scroll_toElement/255_ScrollToView.spec.ts

test.describe title is again File Upload Demo - TestingAcademy. Copy-paste leftover from 254. URL is https://app.thetestingacademy.com/playwright/widgets/scroll. The file imports path and never uses it. Dead import. Test title: scroll to view.

Commented recipes — I quote them because the 16 README teaches them, and they are in the file as comments. They are not the live test:

  1. scrollIntoViewIfNeeded on getByTestId('deep-anchor'), then click that same test id, then a 5 second freeze-frame.
  2. Relative window scroll of 1000 pixels down (the file comments window.scrollBy(0, 1000) inside page.evaluate), then a 500 ms freeze.
  3. Jump to the bottom with window.scrollTo(0, document.body.scrollHeight) via page.evaluate, then expect getByTestId('cta-button') to be enabled.
  4. Jump back to top with window.scrollTo(0, 0).

Uncomment in *your* working copy to watch them. The GitHub file leaves them commented. I will not pretend the live test clicks deep-anchor or asserts cta-button.

Live path — lazy list. Scroll section-lazy into view. Scroll lazy-list into view. Bind list as page.getByTestId('lazy-list').locator('li'). Capture initialCount with list.count(). Scroll list.last() into view. Then poll:

        await expect.poll(async () => list.count(), {
            message: 'expected lazy list to load more items',
            timeout: 10_000,
        }).toBeGreaterThan(initialCount);

Then finalCount = await list.count(), console.log(finalCount), then waitForTimeout(5000).

This is the first expect.poll in the fundamentals modules we have opened in this series. Capture initialCount. Scroll the last li into view so the widget thinks you reached the end. Poll list.count() until it is greater than the initial count, 10 seconds, custom message. Then log finalCount. No toBe(20). No invented page size. Greater than initial is the contract.

Why poll, not a five-second freeze alone? Because five seconds is a guess. Poll is a condition. Day 15 will make expect a whole lesson. Today you steal this one shape for infinite lists, load-more sentinels, and virtualized tables that append on scroll.

section-lazy then lazy-list — scroll the section, then the list, then the last item. Three scrollIntoViewIfNeeded calls. I keep all three. Removing the first two in a cleanup is how you flake on a layout where the list is below the fold and list.last() is not attached yet.

The 16 README run is the spec path tests/16_Scroll_toElement/255_ScrollToView.spec.ts. Headed is worth it once. You should see the list jump and grow. If it does not grow, the poll fails with expected lazy list to load more items. That message is in the file. Keep it.

What I extract into a framework later — not today

I am not creating page-object files in this post. Folder 20 is LoginPage.ts. None of today’s folders has a helper .ts. The map in my head after these five folders:

  • 249 #circle-blue plus #shapes-output becomes SvgWidget.clickShape(id) then expect output text.
  • 250 name()='path' INUP becomes SvgMap.clickState({ label, pathClass }) — CSS first, name() only if XPath is required.
  • 251 card-account becomes ShadowCard.login(email, password) using getByTestId plus host.locator, never XPath.
  • 252 setInputFiles becomes Uploader.fromDisk(input, absPath).
  • 253 buffers become Uploader.fromMemory(input, files[]).
  • 254 waitForEvent('download') becomes Downloader.save(trigger, destDir) with the wait and click started together.
  • 255 expect.poll becomes LazyList.scrollUntilGrown(list, { timeout }).

Day 21 is when those names get files. Today you run the specs and you feel the boundary. Do not wrap 253’s broken-looking CSS click into a helper until you have a locator that actually matches.

Homework — run the files, then tighten one of them

Do this on main of LearningPlaywrightFundamentals. Do not invent a sixth folder.

  1. Run 249_SVG_Practice.spec.ts headed. Watch #circle-blue and the bars.
  2. Temporarily change 249’s #circle-blue to an XPath //circle[@id='circle-blue'] without name(), run it, see if it fails on your machine, put it back. Then try //*[name()='circle' and @id='circle-blue']. That is the 250 lesson on a 249 widget.
  3. Run 250_Advance_SVG_pROJECT.spec.ts. Remember the filename is pROJECT. If SimpleMaps is down, skip. Do not invent a local map SVG in this homework.
  4. Run 251_Shadom_DOM.spec.ts. Filename Shadom. In DevTools, confirm card-account has an open shadow root. Try an XPath //input[@name="email"] from page in a scratch line, watch it miss, delete the scratch line.
  5. Run 252_FileUpload.spec.ts. Open testdata.txt and confirm it is empty. The assertion should still see the filename.
  6. Run 253_Multi_FileUpload.spec.ts. If PatternFly markup moved, do not “fix” it by inventing a new demo site in a comment that claims to follow this repo. Note the failure.
  7. Run 254_File_Downlaod.spec.ts. Filename Downlaod. Check whether out/ appeared with suggestedFilename(). Do not expect downloads/tta-notes.txt unless you uncomment that block.
  8. Run 255_ScrollToView.spec.ts headed. Watch expect.poll wait for the list to grow. Uncomment recipe 1 (deep-anchor) in your working copy only.

Public URLs need network. Classroom waitForTimeout will make the module slow. That is expected.

Common failures I see in reviews

Empty list from //svg//path. Namespace. Use CSS, or XPath name(). Lab 250.

Clicking svg.first() on a page with fifty icons. Lab 248 on Flipkart. Pin a search control when you leave the classroom file.

XPath into Shadow DOM. Does not pierce. Lab 251 uses getByTestId and locator / getByRole from the host.

fill('/tmp/file.txt') on a file input. Use setInputFiles. Lab 252.

Assuming testdata.txt has bytes. It does not, on main. Name assertion still works. Content assertion would be a lie.

Assuming 253 reads file1.jpg from disk. It builds buffers. The JPEG files exist; this spec does not open them.

Click, then wait for download. Race. Lab 254, wait for the download event *with* the click.

Expecting tta-notes.txt in git. downloads README names it. Blob is not on main. Live save is out/ plus suggested name.

Fixed count on a lazy list without scrolling. Lab 255, poll after list.last().scrollIntoViewIfNeeded().

frameLocator on a shadow host, or shadowRoot on an iframe. Wrong boundary. Day 13 vs Day 14.

Renaming Shadom, pROJECT, or Downlaod in a PR that is supposed to follow the repo. Follow the repo. Fix spellings in a dedicated cleanup, not in a “I could not find the file” comment.

Trusting console.log as the assertion. 248 titles, 249 quarters, 255 finalCount log. 249’s Blue circle, 251’s status and 5, 252’s #uploaded-files, 255’s poll are the real expects. Prefer those.

FAQ

How do I click an SVG element in Playwright?

CSS first. 249_SVG_Practice.spec.ts clicks #circle-blue and .bar. 248_SVG_Project.spec.ts clicks page.locator('svg').first() on Flipkart after filling input[name="q"] with macmini. Roles work when the widget exposes them: 249 uses getByRole('button', { name: /Q3 bar/ }) and getByRole('radio', { name: '4 stars' }). Assert on HTML output when you have it — 249 expects #shapes-output to contain Blue circle.

Why does my XPath miss SVG paths that DevTools shows?

SVG nodes are namespaced. 250_Advance_SVG_pROJECT.spec.ts uses //*[name()='svg']//*[name()='text' and contains(@class,'sm_label')] and //*[name()='path' and contains(@class,'INUP')] on SimpleMaps India. The 12 README says CSS works; XPath needs name() or local-name(). Filename is pROJECT.

Does Playwright pierce Shadow DOM automatically?

Open shadow, yes, for built-in locators. 251_Shadom_DOM.spec.ts (Shadom in the filename) does page.getByTestId('card-account') then card.locator('input[name="email"]'). The 13 README says Playwright pierces open roots and XPath does not. Closed shadow is not in this folder. Nested fields in 251 are card-inside-email, card-inside-password, card-inside-submit.

Why does the cart assertion expect 5 after two Increment clicks?

Because that is what 251_Shadom_DOM.spec.ts writes: two getByRole('button', { name: 'Increment' }) clicks on counter-cart, then expect(cart.getByTestId('counter-value')).toHaveText('5'). I will not invent the widget’s initial count. If the default is not 3 with step 1, the line fails. Read the assertion.

How do I upload a file in Playwright?

locator.setInputFiles. Disk: 252_FileUpload.spec.ts does path.join(__dirname, 'testdata.txt') into #file-upload on https://the-internet.herokuapp.com/upload, clicks role Upload, expects #uploaded-files to contain testdata.txt. Memory: 253_Multi_FileUpload.spec.ts passes { name, mimeType, buffer } objects named file1.jpg and file2.jpg without reading the JPEG files from disk.

Is testdata.txt empty? Do file1.jpg and file2.jpg exist?

testdata.txt is present and 0 bytes on main. The 14 README calls it an empty text fixture. file1.jpg and file2.jpg exist as binary fixtures (raw GitHub 200). Lab 252 uses the empty txt. Lab 253 does not read the JPEGs; it builds buffers.

How do I wait for a Playwright download?

Arm the event with the click. 254_File_Downlaod.spec.ts (Downlaod in the filename) waits for download together with getByTestId('download-static').click(), then saveAs('out/' + staticDownload.suggestedFilename()). A commented block uses download-text, expects suggested name to contain tta-notes, and saves to downloads/tta-notes.txt. That block is commented. downloads/tta-notes.txt is not committed on main.

How do I scroll until a lazy list loads more rows?

255_ScrollToView.spec.ts scrolls section-lazy and lazy-list with scrollIntoViewIfNeeded, records li count, scrolls list.last(), then expect.poll until count is greater than the initial count with a 10 second timeout. Recipes for deep-anchor, relative 1000 px scroll, jump to bottom / cta-button, and jump to top are commented in that file.

Why are some describe titles “File Upload Demo” in the download and scroll labs?

Copy-paste leftovers. 254_File_Downlaod.spec.ts and 255_ScrollToView.spec.ts both use test.describe('File Upload Demo - TestingAcademy'). URLs are /widgets/upload-download and /widgets/scroll. Read the body and the folder name. I do not rename describes in this post.

What is Day 15 of this series?

Assertions, hooks, and data-driven testing — tests/17_Expect_Assertions, tests/18_Test_hooks, and tests/19_Data_Driven_Testing in the same fundamentals repo. I will quote those files tomorrow. I will not open them in this draft.


<script type=”application/ld+json”> { “@context”: “https://schema.org”, “@type”: “FAQPage”, “mainEntity”: [ { “@type”: “Question”, “name”: “How do I click an SVG element in Playwright?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Use CSS or role locators first. 249_SVG_Practice.spec.ts clicks #circle-blue, getByRole button Q3 bar, and getByRole radio 4 stars, then expects #shapes-output to contain Blue circle. 248_SVG_Project.spec.ts clicks page.locator(‘svg’).first() on Flipkart after filling input[name=q]. For XPath, 250_Advance_SVG_pROJECT.spec.ts uses name() on svg text labels and path.INUP.” } }, { “@type”: “Question”, “name”: “Why does my XPath miss SVG paths that DevTools shows?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “SVG nodes are namespaced. 250_Advance_SVG_pROJECT.spec.ts locates //*[name()=’svg’]//*[name()=’text’ and contains(@class,’sm_label’)] and clicks //*[name()=’path’ and contains(@class,’INUP’)] on simplemaps.com/svg/country/in. CSS does not need name(); XPath does.” } }, { “@type”: “Question”, “name”: “Does Playwright pierce Shadow DOM automatically?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Open shadow roots: yes for built-in locators. 251_Shadom_DOM.spec.ts uses getByTestId(‘card-account’) then card.locator(‘input[name=email]’). XPath does not pierce. Closed shadow is not demonstrated in tests/13_Shadow_DOM.” } }, { “@type”: “Question”, “name”: “How do I upload a file in Playwright?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Call locator.setInputFiles. 252_FileUpload.spec.ts uploads testdata.txt (empty on main) to #file-upload on the-internet.herokuapp.com/upload and expects #uploaded-files to contain testdata.txt. 253_Multi_FileUpload.spec.ts passes in-memory name/mimeType/buffer objects file1.jpg and file2.jpg on PatternFly and does not read the JPEG fixtures from disk.” } }, { “@type”: “Question”, “name”: “Is testdata.txt empty in the Playwright upload lab?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Yes. tests/14_FileUpload/testdata.txt is 0 bytes on main. The module README calls it an empty text fixture. file1.jpg and file2.jpg exist as binaries. Lab 252 still asserts the filename testdata.txt after upload.” } }, { “@type”: “Question”, “name”: “How do I wait for a Playwright download?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Use Promise.all with page.waitForEvent download and the click. 254_File_Downlaod.spec.ts clicks getByTestId download-static and saveAs out/ plus suggestedFilename. A commented block uses download-text and downloads/tta-notes.txt; that txt is not committed on main.” } }, { “@type”: “Question”, “name”: “How do I scroll a lazy-loaded list in Playwright?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “255_ScrollToView.spec.ts calls scrollIntoViewIfNeeded on section-lazy and lazy-list, records li count, scrolls list.last(), then expect.poll until count is greater than the initial count with a 10 second timeout. deep-anchor, scrollBy, and scrollTo recipes are commented in that file.” } }, { “@type”: “Question”, “name”: “Why is the shadow spec named 251_Shadom_DOM.spec.ts?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Classroom spelling as GitHub serves it. tests/13_Shadow_DOM/251_Shadom_DOM.spec.ts uses Shadom. The SVG map file is 250_Advance_SVG_pROJECT.spec.ts (pROJECT). The download file is 254_File_Downlaod.spec.ts (Downlaod). Follow the tree. Do not invent corrected filenames.” } }, { “@type”: “Question”, “name”: “Does Playwright setInputFiles work with hidden file inputs?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Yes. You target the input node and call setInputFiles; you do not drive the OS chooser. 252 uses #file-upload. 253 uses div.pf-v6-c-multiple-file-upload input. Do not fill a path as text.” } }, { “@type”: “Question”, “name”: “What is Day 15 of the JS to Playwright Framework series?”, “acceptedAnswer”: { “@type”: “Answer”, “text”: “Day 15 covers assertions, hooks, and data-driven testing from LearningPlaywrightFundamentals tests/17_Expect_Assertions, tests/18_Test_hooks, and tests/19_Data_Driven_Testing.” } } ] } </script>

Tomorrow — Day 15: assertions, hooks, and data-driven testing

SVG hid a namespace. Shadow DOM hid a tree. Upload and download hid OS channels. Scroll hid nodes that were not in the DOM yet. Day 15 is when the spec has to *say what true means* — expect families, hooks, test.step, describe.serial, and data-driven loops from CSV, JSON, and Faker.

Day 15 of this series opens tests/17_Expect_Assertions, tests/18_Test_hooks, and tests/19_Data_Driven_Testing in the same LearningPlaywrightFundamentals repo. I will quote those files tomorrow. I will not invent a YAML or XLSX spec if the readers exist without a consuming test. I will not open them in this draft.

Series hub (bookmark this): JavaScript → TypeScript → Playwright Advanced Framework — 21-Day Guide.

Master Playwright end to end

If you want these labs as a live classroom — SVG name(), open Shadow DOM, setInputFiles, download waitForEvent, lazy expect.poll, and the framework we assemble on Day 21 — join Playwright Automation Mastery at The Testing Academy. Lifetime access. Real projects. A job-ready suite, not a folder of //svg//path on the wrong namespace and fill() on a file input.

*This is Day 14 of 21. Draft only. Not published.*

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.