What this class covered
- The week ahead: shadow DOM, uploads and downloads, assertions, hooks and data-driven tests, then the framework
- What shadow DOM is, and how it looks in DevTools
- Why Playwright needs no extra code for an open shadow root, and why a closed one is out of reach
- The shadow DOM spec: an account card, a counter and a nested host
- File upload: find the input with type="file", then setInputFiles
- Building file paths with path.join and __dirname
- The the-internet upload page timing out, and the practice page instead
- Several files at once: in-memory buffers, files from disk, and a PDF, a JPG and a DOC together
- Files in another folder: walking up with '..'
- File download with Promise.all, waitForEvent and saveAs
- Why the listener goes before the click, as with dialogs
- Tasks: the shadow DOM form, sites that use shadow DOM, upload and download checks, and your profile photo
The week ahead
A few topics were missed on the way, and this week covers them before the final framework: shadow DOM, file upload and download, assertions (expect), test hooks and data-driven testing. Next week the advanced framework gets built, with the page object model and fixtures implemented directly in it.
Saturday's code is in the batch repo now, in tests/12_Handle_SVG; the notes for that class are here.
What shadow DOM is
Shadow DOM is a web standard for encapsulating the markup and styles of a web component. Think of it as hidden HTML behind the visible page: it stays tucked away until it is needed, and the component's styles cannot leak out or be overridden from outside.
In DevTools, open the Elements tab and expand an element: inside it you see a #shadow-root node, marked open or closed, and the component's own elements sit under it. Asked why developers hide markup like this: some parts are meant to stay self-contained, or to appear only later, on a click or once something is filled in.
The point for a tester is not why it exists but how to handle it when your company's application has it.
Open and closed shadow roots
In Selenium, an element inside a shadow root cannot be reached directly. You first had to get hold of the shadow root itself, for example through its JavaScript path, and only then find the element inside it.
Playwright does not care. Its locators, getByTestId, getByRole and CSS alike, go straight through an open shadow root, so there is no shadow DOM code to write at all. You find the element the same way as anywhere else.
The rule from class: if the shadow root is closed, you cannot interact with it. Almost every shadow root you meet will be open. I checked the rule on a test page with one open root and one closed root, each holding an input: the locator found the input in the open root and nothing in the closed one.
The shadow DOM spec
The shadow DOM practice page has three shadow hosts: an account card, a counter and a nested host. The spec finds the card by its test id, then chains locators off it to fill the form, submit, and check the status line. It clicks the counter's Increment button twice and checks the value reads 5, then fills the form inside the nested host:
import { test, expect, Locator } from '@playwright/test';
test.describe('Shadow handling', () => {
const URL = 'https://app.thetestingacademy.com/playwright/widgets/shadow-dom'; // replace with target page
test.beforeEach(async ({ page }) => {
await page.goto(URL);
});
test('locate Shadow DOM and assert visible', async ({ page }) => {
const card = page.getByTestId('card-account-card');
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');
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');
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.pause();
});
});
✓ 1 [chromium] › tests/13_Shadow_DOM/259_Shadow_DOM_TC.spec.ts:11:8 › Shadow handling › locate Shadow DOM and assert visible (2.8s)
None of it is shadow DOM code: find the card, then card.locator(...) inside it, exactly as on a normal page.
The line await page.getByTestId('nested-host'); does nothing. Creating a locator does not touch the page; only an action or an assertion does. The three lines after it reach into the nested host on their own, which is why the test passes with or without it.
File upload: find the input, then setInputFiles
Before writing anything, right-click the upload control, inspect it, and look for an input with type="file". That input is what actually takes the file, usually inside a form with a submit button. If there is no such input, the usual approach does not apply.
Playwright then uploads with one method on that input: setInputFiles(). It takes one path, or an array of paths for several files.
Building the path with path.join and __dirname
The one thing you must know for an upload is the path of the file. The class put a testdata.txt next to the spec and built its full path with Node's path module:
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
const URL = 'https://the-internet.herokuapp.com/upload'; // replace with target page
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL, { waitUntil: 'domcontentloaded' });
});
test('locate FileUpload and upload', async ({ page }) => {
// File upload
// Path of the file. - You should. A
const filePath = path.join(__dirname, 'testdata.txt');
console.log(filePath);
// __dirname - Current working directory full path
await page.locator("#file-upload").setInputFiles([filePath]);
await page.getByRole("button", { name: "Upload" }).click();
await expect(page.locator('#uploaded-files')).toContainText('testdata.txt');
});
});
<repo>/tests/14_FileUpload/testdata.txt
✓ 1 [chromium] › tests/14_FileUpload/260_FileUpload_TC.spec.ts:12:8 › FileUpload handling › locate FileUpload and upload (33.1s)
The first line is the path it printed; the start of it is wherever your copy of the repository lives.
- __dirname is the full path of the folder the spec file is in. It is resolved by Node.js at run time, so the same code works on your laptop, a classmate's machine and in CI, with nothing hardcoded.
- path.join() glues the pieces together with the right separator for the operating system.
- To upload from somewhere else, such as your desktop, give that path instead of
__dirname.
path is built into Node.js, so there is nothing to install. The class mentioned the npm package of the same name, and the repo's package.json now lists it, but Node always loads its own built-in path ahead of any package called path: require.resolve('path') returns the built-in. The red underline you may see on the import in VS Code is a TypeScript warning, not an error.
When the-internet is too slow
That spec timed out in class: the-internet.herokuapp.com was too slow to load within the default 30 seconds, and switching waitUntil to networkidle, then to domcontentloaded, did not help. A learner pointed out that networkidle is discouraged anyway, because it waits for every network request to finish. On my run the page did load, but only after 33 seconds, still over the default limit.
So the class moved to the upload and download practice page, which has a single-file input, a multiple-file input, a drop zone and three download buttons:
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
const URL = 'https://app.thetestingacademy.com/playwright/widgets/upload-download'; // replace with target page
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL, { waitUntil: 'domcontentloaded' });
});
test('locate FileUpload and upload', async ({ page }) => {
// File upload
// Path of the file. - You should. A
const filePath = path.join(__dirname, 'testdata.txt');
console.log(filePath);
// __dirname - Current working directory full path
await page.locator("#single-upload").setInputFiles([filePath]);
await page.pause();
});
});
There is no submit button here: the page takes the file as soon as setInputFiles() sets it, and lists it straight away.
Several files at once
For multiple files the class used the PatternFly multiple file upload demo, where the input sits inside a drop zone. setInputFiles() works on that input even though it is hidden behind the drop zone, and an array uploads several files in one call.
Files that exist only in memory. Instead of paths, each entry can be an object with a name, a mimeType and a buffer, the file's bytes. Buffer.from('...') turns a string into bytes, so these two files are created on the fly and never touch the disk:
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
const URL = 'https://www.patternfly.org/components/file-upload/multiple-file-upload/'; // replace with target page
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL);
});
test('locate FileUpload and upload', async ({ page }) => {
// File upload
// Path of the file. - You should. A
await page.locator("div.pf-v6-c-multiple-file-upload input").setInputFiles(
[{
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')
}
]);
// await page.locator(".pf-v6-c-button.pf-m-secondary").click();
await page.pause();
});
});
Real files from disk. That is usually what you want, so 263 passes two real images next to the spec, and asserts that the drop zone lists both names:
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
const URL = 'https://www.patternfly.org/components/file-upload/multiple-file-upload/';
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL);
});
test('upload multiple files from disk', async ({ page }) => {
// Real files living next to this spec, instead of in-memory buffers.
// __dirname is this file's folder, so the paths work no matter where
// the runner is started from.
const file1 = path.join(__dirname, 'file1.jpg');
const file2 = path.join(__dirname, 'file2.jpg');
console.log(file1);
console.log(file2);
await page.locator("div.pf-v6-c-multiple-file-upload input")
.setInputFiles([file1, file2]);
// The dropzone lists what it accepted, so assert on the names.
const uploadArea = page.locator('div.pf-v6-c-multiple-file-upload');
await expect(uploadArea).toContainText('file1.jpg');
await expect(uploadArea).toContainText('file2.jpg');
await page.pause();
});
});
When the class first tried this, the drop zone reported 0 bytes: the files were empty placeholders. The pushed file1.jpg and file2.jpg are real images of about 22 KB.
A PDF, a JPG and a DOC together. Asked whether PDFs work: yes, and Word files too. With paths, Playwright reads each file and works out its type from the extension:
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
const URL = 'https://www.patternfly.org/components/file-upload/multiple-file-upload/';
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL);
});
test('upload a PDF, a JPG and a DOC together', async ({ page }) => {
// Three different MIME types in one call. With the path form Playwright
// reads each file off disk and infers its Content-Type from the
// extension, so nothing has to be declared by hand.
//
// This dropzone's accept attribute is:
// image/jpeg,.jpg,.jpeg,application/msword,.doc,application/pdf,.pdf,image/png,.png
// so it takes .doc but silently drops .docx.
const files = [
path.join(__dirname, 'sample.pdf'),
path.join(__dirname, 'sample.jpg'),
path.join(__dirname, 'sample.doc'),
];
files.forEach(f => console.log(f));
await page.locator("div.pf-v6-c-multiple-file-upload input").setInputFiles(files);
const uploadArea = page.locator('div.pf-v6-c-multiple-file-upload');
await expect(uploadArea).toContainText('sample.pdf');
await expect(uploadArea).toContainText('sample.jpg');
await expect(uploadArea).toContainText('sample.doc');
await page.pause();
});
});
<repo>/tests/14_FileUpload/sample.pdf
<repo>/tests/14_FileUpload/sample.jpg
<repo>/tests/14_FileUpload/sample.doc
✓ 6 [chromium] › tests/14_FileUpload/264_Mixed_FileUpload_TC.spec.ts:12:8 › FileUpload handling › upload a PDF, a JPG and a DOC together (889ms)
Other questions that came up: a very large file takes longer and runs into the same timeouts as anything else, and checking the contents of a PDF is possible with a third-party library.
Files in another folder: walking up with '..'
A learner asked what happens when the files are not next to the spec. 265 keeps them in a shared test-data/uploads folder at the root of the repository, and walks there from __dirname: each '..' goes up one level.
import { test, expect, Locator } from '@playwright/test';
import path from 'path';
import fs from 'fs';
const URL = 'https://www.patternfly.org/components/file-upload/multiple-file-upload/';
/**
* Same upload as 264, but the fixtures live OUTSIDE this folder, in a shared
* test-data/uploads/ directory at the repo root:
*
* LearningPlaywrightFundamentals3x/
* ├── test-data/uploads/ <- sample.pdf, sample.jpg, sample.doc
* └── tests/14_FileUpload/ <- this spec
*
* Walking up with '..' from __dirname is what keeps it working no matter
* which directory you start the runner from.
*/
const UPLOAD_DIR = path.join(__dirname, '..', '..', 'test-data', 'uploads');
test.describe('FileUpload handling', () => {
test.beforeEach(async ({ page }) => {
await page.goto(URL);
});
test('upload files from a different directory', async ({ page }) => {
const files = ['sample.pdf', 'sample.jpg', 'sample.doc']
.map(name => path.join(UPLOAD_DIR, name));
await page.locator("div.pf-v6-c-multiple-file-upload input").setInputFiles(files);
const uploadArea = page.locator('div.pf-v6-c-multiple-file-upload');
await expect(uploadArea).toContainText('sample.pdf');
await expect(uploadArea).toContainText('sample.jpg');
await expect(uploadArea).toContainText('sample.doc');
await page.pause();
});
});
File download with Promise.all and waitForEvent
A download takes time, and you do not know exactly when it will start. So you wait for the download event while clicking the button, and only then save the file:
import { test, expect, Locator } from '@playwright/test';
test.describe('File Upload Demo - TestingAcademy', () => {
test.beforeEach(async ({ page }) => {
await page.goto('https://app.thetestingacademy.com/playwright/widgets/upload-download');
});
test('demo: Download setInputFiles', async ({ page }) => {
const [staticDownload] = await Promise.all([
page.waitForEvent('download'),
page.getByTestId('download-static').click()
]);
await staticDownload.saveAs('./out/'+ staticDownload.suggestedFilename());
});
});
- page.waitForEvent('download') returns a promise that resolves with the download once the event fires.
- Promise.all([...]) waits until both the event and the click are done, and gives back their results in the same order, so the first item is the download.
- saveAs() saves it, and suggestedFilename() is the name the browser would have given it.
After the run, the file is in an out folder at the root of the project:
out/sample-download.txt 572 bytes
The slash matters. Written as './out' + the file name, without the slash after out, the file is saved in the project root as outsample-download.txt. That happened in class, and the repo's .gitignore now lists that exact name. Keep it as './out/'.
Why the listener goes first
Asked whether the waitForEvent could simply come after the click: in class, the click-first version missed the download. It is a race: if the download event fires before anyone is listening, it is gone, because waitForEvent only sees events that happen while it is waiting. It does not replay the past.
I ran both orders against the three buttons on the practice page, three times each:
| Button | Click first, then waitForEvent | Listener first, with Promise.all |
|---|---|---|
| Download static file (a file from the server) | caught, 3 of 3 | caught, 3 of 3 |
| Download text file (built in the page) | missed, 0 of 3 | caught, 3 of 3 |
| Download JSON (built in the page) | missed, 0 of 3 | caught, 3 of 3 |
The static file comes from the server, so it usually starts after the click returns and a late listener can still catch it: that is luck, not a guarantee. The two files the page builds itself start immediately, and a late listener misses them every time.
The same rule came up with alerts: register page.once('dialog') first, then click (the dialog section). In Playwright, events read in reverse: start listening, then do the thing that triggers the event.
Tasks and announcements
- Code: all eight specs are in the batch repo, in
tests/13_Shadow_DOM,tests/14_FileUploadandtests/15_File_Download; the shared files are intest-data/uploads. - Questions go in the doubt thread in the group.
- Coming this week: assertions (expect), test hooks and data-driven testing. Next week: the advanced framework, with the page object model and fixtures.
Task 1, the shadow DOM form. Fill every field of the shadow DOM practice form in the task post with Playwright. The password field sits in a closed shadow root: try it and see what happens.
Task 2, sites that use shadow DOM. Find a few real websites that use shadow DOM and share the list in the group; the class will take examples from it.
Task 3, upload and download. On the upload and download practice page, upload a file and verify that it was accepted, then download a file and verify the download.
Task 4, your profile photo. The Testing Academy profile page has an upload control with an input of type="file". Log in with your own account, upload your photo with automation, and click Save.