|

Day 12: Playwright Storage State, Allure, Lists, and Web Tables

Compact diagram of Playwright login then storageState cookie jar

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

Days 10 and 11 opened the browser, wrote the first test(), and learned locators — goto, CSS, XPath, getByRole, cookies. That is enough to log in to VWO fifty times in a row. It is not enough to ship a suite. A real suite logs in once, snapshots the authenticated browser, and reuses that snapshot. Then it labels the run so a manager can read Allure. Then it stops clicking the first a on the page and starts walking a list. Then it reads a table the way a human reads a table: find the name, read the country next to it.

I am Pramod Dutta. I teach SDETs in India for a living. The week I introduce storageState, someone always asks: “Why not put login in beforeEach?” Because beforeEach is a tax. Every test pays it. Every retry pays it. Every worker pays it. VWO, OrangeHRM, any app with a slow auth wall — you will burn minutes on a form you already proved works. storageState is the receipt. You keep it. You reuse it.

This is not the existing 21-Day Playwright with TypeScript Challenge. That series starts with npx playwright test. This series started at console.log. Today we are in the fundamentals repo, labs 228 through 233, plus an empty 234 I will not invent.

All labs today come from my public fundamentals repo: LearningPlaywrightFundamentals on branch main. I fetched each file from raw GitHub. I quote those files. I will not invent a file that is not there.

Three honesty notes before we open a browser.

One. tests/04_Session_Storage/228_Session.spec.ts looks like a spec. The filename ends in .spec.ts. It is not a Playwright Test. There is no import { test, expect } from "@playwright/test". There is no test(). It is a library script: import { chromium } from "playwright", a saveSession() function, and a top-level saveSession() call. The folder README runs it with node --experimental-strip-types, not npx playwright test. I say this in the classroom every time, because npx playwright test tests/04_Session_Storage will also try to load 228 as a spec. Do not do that.

Two. Allure is installed. package.json lists allure-playwright at ^3.7.1. The Allure reporter line in root playwright.config.ts is commented out. The live reporter array is HTML plus the custom TTA reporter. Lab 230 still calls allure.epic, allure.feature, allure.story, and allure.description. Those labels do nothing useful until a reporter consumes them. I will show you the commented line. I will not pretend the default npx playwright test writes an Allure report.

Three. Lab number 234 is used twice on main. tests/07_WebTables/234_WebTABLE_Employe_Management.spec.ts is present and empty — 0 bytes. I will not invent an employee-management CRUD table for it. The other 234 is tests/08_Web_Select_Frames_Iframe/234_Web.spec.ts. That file belongs to Day 13. I will name the collision and leave the body for tomorrow.

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 login then storageState cookie jar

Contents

What you will be able to do after Day 12

By the end of this post you can:

  • Explain why context.storageState({ path }) is a snapshot of cookies and local storage, and why you must wait for the dashboard URL before you write the file.
  • Run 228_Session.spec.ts as a Node library script, not as Playwright Test, and produce ./user-session.json at the repo root.
  • Load that JSON with test.use({ storageState: "./user-session.json" }) in 229.TestVWo.spec.ts and open dashboard and settings without typing a password.
  • Add Allure epic / feature / story / description in a spec, and tell an interviewer the reporter is commented out in this repo’s config.
  • Collect a list of matching links with allInnerTexts() and all(), then click one item without failing Playwright’s strict mode.
  • Walk a static HTML table with a dynamic XPath and with a native locator { hasText } plus td.nth().
  • Extract row text from a second table with rows.nth(i).locator('td').allInnerTexts().
  • Refuse to invent content for an empty employee-table file, and name the duplicated lab 234.

That is a framework day dressed as four folders. Login reuse. Reporting labels. Collections. Tables. Tomorrow we leave the table and enter frames, selects, keyboard, hover, drag, and JS alerts.

Clone LearningPlaywrightFundamentals and stay on main

Days 1-9 lived in LearningPlaywrightBatch. Days 10-16 live in LearningPlaywrightFundamentals. Different repo. Same classroom numbering. Labs here start at 209 so they map onto the live batch.

Clone the fundamentals repo from GitHub. Stay on branch main. Install dependencies from the repository root with the project’s lockfile.

We will run, quote, and adapt these files only.

04 — Session storage

  • tests/04_Session_Storage/228_Session.spec.ts — library script, not a Test
  • tests/04_Session_Storage/229.TestVWo.spec.ts — Playwright Test that loads the saved state
  • tests/04_Session_Storage/README.md

05 — Allure reporting

  • tests/05_Allure_Reporting/230_Login.spec.ts
  • tests/05_Allure_Reporting/README.md
  • root playwright.config.ts (Allure reporter commented out)
  • root package.json (allure-playwright is a devDependency)

06 — Multiple elements (the folder name on GitHub is 06_Multiple_Element_ with a trailing underscore)

  • tests/06_Multiple_Element_/231_Multiple_Element.spec.ts
  • tests/06_Multiple_Element_/README.md

07 — Web tables

  • tests/07_WebTables/232_WebTable_Basic.spec.ts
  • tests/07_WebTables/233_WebTable_Dyanamic.spec.ts (filename is spelled Dyanamic on GitHub)
  • tests/07_WebTables/234_WebTABLE_Employe_Management.spec.ts — empty, 0 bytes
  • tests/07_WebTables/README.md

Skipped as a body: the employee-management spec. Present. Empty. I will not write a fake CRUD flow and pretend it is on main.

Not today: tests/08_Web_Select_Frames_Iframe/234_Web.spec.ts. Same lab number. Different folder. Day 13.

You need the repo’s Playwright install. package.json pins @playwright/test at ^1.59.1. The root config runs Chromium, Full HD 1920×1080, headed, with trace, video, and screenshot all set to 'on'. That is the classroom machine. CI in this repo is a separate workflow. Today we stay local.

Why Playwright storageState is the first framework habit

A locator finds one control. A storageState file finds a session.

When you log in to https://app.vwo.com/#login, the server sets cookies. The app may also write local storage. Playwright’s browserContext.storageState() snapshots that authenticated context to JSON: cookies, local storage origins, and related browser storage the API supports. You hand that JSON to the next context. The next context is already logged in.

This is not “sessionStorage the Web API.” The folder is named 04_Session_Storage. The method is storageState. Do not confuse the two in an interview. HTML sessionStorage is tab-scoped and Playwright does not persist it through storageState the way it persists cookies and local storage. If an app keeps the auth token only in sessionStorage, this lab’s JSON will not save you. VWO’s classroom flow is cookie-backed enough that the snapshot works — after you wait for the dashboard.

That last clause is the whole lab. Look at the comment inside 228. I put it there because students snapshot too early.

// Wait for login to actually complete before snapshotting storage —
// otherwise the auth cookie isn't set yet and the saved state is empty.

Click #js-login-btn. Wait 200 ms. Call storageState. Open the JSON. Empty cookies. Empty origins. Then 229 opens the dashboard and VWO throws you back to login. The file is not magic. It is a photograph. Photograph the room after the guest has sat down, not while they are still in the hallway.

Day 11 already taught context.cookies() and context.addCookies() in lab 227. That is the manual version. storageState is the packaged version: write the jar, load the jar, stop touching individual cookie names.

In a later framework we will put this behind a setup project so CI generates the JSON, then every project consumes it. That file is not in folders 04-07. I will not invent a playwright/auth/user.json setup project today. Today is the classroom two-step: script writes, spec reads.

Lab 228 — a library script that saves user-session.json

Lab: tests/04_Session_Storage/228_Session.spec.ts

I am going to say this again, because the filename is a trap.

This file is not a Playwright Test.

  • It imports chromium from "playwright", the library.
  • It does not import test or expect from "@playwright/test".
  • It defines async function saveSession().
  • It calls saveSession() at the bottom of the file. That call runs when Node loads the module.

If Playwright Test collects this path, the worker imports the file. The import runs saveSession(). A headed Chromium window appears during collection. That is not a test. That is a side effect. The folder README is explicit. Run it with Node.

Exact file on main (imports and launch):

import { chromium } from "playwright";

async function saveSession() {

  let browser = await chromium.launch({ headless: false });
  let context = await browser.newContext();
  let page = await context.newPage();

Then the script opens the VWO hash login route, fills the username and password CSS ids that sit in the file on GitHub, and clicks #js-login-btn. Classroom warning, same sentence as the folder README: do not commit real credentials. This lesson hardcodes a single-use demo mailbox and a password in the script. If you expand the lesson, move login data to environment variables. I am quoting the file as GitHub serves it. I am not going to pretend the strings are process.env.VWO_USER. They are not. Not in this blob.

  await page.goto("https://app.vwo.com/#login");
  await page.waitForTimeout(2000);

  await page.fill("#login-username", "opg73@singleuseemail.site");
  await page.fill("#login-password", "Wingify@4321");
  await page.waitForTimeout(1500);

  await page.click("#js-login-btn");

The wait that matters is the next block. The waitForTimeout calls are classroom pauses so a student can see the form. Playwright already auto-waits on fill and click. Do not copy waitForTimeout into a CI suite as your wait strategy.

  // Wait for login to actually complete before snapshotting storage —
  // otherwise the auth cookie isn't set yet and the saved state is empty.
  await page.waitForURL(/#\/(dashboard|home)/, { timeout: 15000 });
  await page.waitForTimeout(3000);

  await context.storageState({ path: "./user-session.json" });
  console.log("Session saved to user-session.json");

  await page.waitForTimeout(2000);
  await browser.close();

}

saveSession();

Launch, context, page. chromium.launch headed so you can watch. browser.newContext() is a fresh cookie jar. context.newPage() is one tab in that jar. This is the same Browser, Context, Page hierarchy from labs 212-214. Those three were also library scripts. 228 is the same shape with a purpose: persist the jar.

Wait for the authenticated route. page.waitForURL with a regex for #/dashboard or #/home. VWO uses a hash router. Fifteen seconds. If this times out, you do not have a session. Do not snapshot.

Snapshot. context.storageState({ path: "./user-session.json" }). Relative path. From the repository root. The README says it: when you run from the repo root, the file lands as user-session.json at the project root. Lab 229 loads ./user-session.json. Same place. If you change directory into tests/04_Session_Storage and run the script, the JSON appears next to the spec and 229 will not find it. Run from root.

Close. Another pause. browser.close(). The JSON stays on disk. The browser is gone. That is the point. The session outlives the process.

How the README runs it, from the repository root: install dependencies, then execute the TypeScript file with Node’s strip-types flag.

node --experimental-strip-types tests/04_Session_Storage/228_Session.spec.ts

--experimental-strip-types lets current Node execute a .ts file without a separate compile step. This repo’s labs are TypeScript. The script is TypeScript. Node strips the types and runs the JavaScript. If your Node is too old for that flag, you already have the wrong runtime for Playwright 1.59.

After a successful run you should see a console line that the session was saved to user-session.json. Open that file. You should see a cookies array and an origins array. If both look empty, you snapshotted before auth. Delete the file. Run 228 again. Watch the URL bar. Snapshot only after #/dashboard or #/home.

If the saved session expires, login fails, or the account IDs change, regenerate the file before you run 229. The README says that. I am not adding a refresh helper. There is no refresh helper in this folder.

Lab 229 — Playwright Test loads the snapshot and skips login

Lab: tests/04_Session_Storage/229.TestVWo.spec.ts

Filename has a dot: 229.TestVWo.spec.ts. I use that name. I do not “fix” it to 229_TestVWO.spec.ts.

This one is a Playwright Test.

Exact file on main:

import { test, expect } from "@playwright/test";

// Load saved session — already logged in
test.use({
  storageState: "./user-session.json"
});

Then two tests. The first opens the VWO dashboard get-started URL with accountId=1227004 and asserts the page URL matches /dashboard/. The second opens settings accounts general with accountId=1227007 and asserts /settings/. Each test logs a checkmark and pauses three seconds so you can see the headed window. I quote the names as they are: “go directly to dashboard — no login” and “go directly to settings — no login”.

Three lines of framework. The rest is proof.

test.use({ storageState: "./user-session.json" }) is a Test Options override for every test in this file. Playwright creates each test’s context with that file already applied. The page fixture you receive is a tab in an authenticated context. You do not call fill. You do not click Login. You goto a logged-in URL and assert the URL still contains dashboard or settings.

Two tests. Two deep links. Two different accountId query values in the file. I am not going to invent a reason those IDs differ. They are what main has. If VWO rejects an ID, the URL assertion fails and you regenerate state or update the ID. I will not invent a third test that “fixes” the IDs.

test.use at file scope applies to both tests. You could put storageState in playwright.config.ts under use and every spec in the project would inherit it. This repo’s config does not do that. The root use block sets trace, video, screenshot, headless, and viewport. It does not set storageState. 229 is local on purpose: only these two tests skip login. A login-negative test in another folder still gets a clean context. That is the right default.

Run from the repo root, after 228 has written the JSON:

npx playwright test tests/04_Session_Storage/229.TestVWo.spec.ts

If you skip 228, 229 fails at context creation: the path ./user-session.json does not exist. Order is the lesson. Setup script first. Consumer spec second.

What you should see: two passing tests, two console checkmarks, a headed Chromium that never types in #login-username. If you see the login form, the JSON is stale or empty. Delete it. Re-run 228. Re-run 229.

This is the same idea as a setup project in Playwright’s auth docs, minus the project dependency graph. We will build that graph when the series reaches the advanced framework repo. Today you should be able to draw the arrow: library script, then JSON, then test.use. If you cannot draw it, you are not ready to put storageState in CI.

*Want the live classroom version of this — VWO auth, a setup project, and the Day 21 framework that loads state per role? Playwright Automation Mastery.*

What storageState is not

A short list I give every batch, because the folder name lies a little.

It is not HTML sessionStorage. Playwright’s own auth docs say storageState covers cookies, local storage, IndexedDB, and WebAuthn passkeys. Tab sessionStorage is a separate, manual page.evaluate plus addInitScript pattern. There is no addInitScript in 228 or 229. Do not claim this lab persists sessionStorage.

It is not a secret store. The JSON on disk is a credential. Gitignore it. The folder README does not add a .gitignore line for you. Check your root ignore before you commit a live session. I am not inventing an ignore file that is not part of today’s fetch list.

It is not forever. Tokens expire. Accounts change. The README: “If the saved session expires, login fails, or the account IDs change, regenerate user-session.json before running the dependent tests.”

It is not isolation. 229’s two tests share the same saved identity. They do not share a live context — Playwright still gives each test a fresh context hydrated from the same file. Mutations one test makes to cookies after start are not written back unless you call storageState again. Neither test does.

It is not a substitute for a login test. You still need one test that types the password and proves login works. Lab 230 is that test, with Allure labels on top. 228 and 229 prove you can skip login after you have proved it.

Lab 230 — Allure labels on a real VWO login

Lab: tests/05_Allure_Reporting/230_Login.spec.ts

This folder has one spec and a README. That is the whole module. I will not invent allure.step wrappers, severity, or a screenshot attachment helper. Those are not in 230.

The spec imports test and expect from @playwright/test and * as allure from allure-js-commons. The test name is Verify that the login works.

Four Allure calls, quoted as GitHub serves them:

  • allure.epic("VWO Login Tests") — high-level product or test area
  • allure.description("Verify that the login is page works") — readable blurb. I quote the wording. “the login is page works.” Classroom English. I am not silently editing it.
  • allure.feature("Essential features") — the feature under test
  • allure.story("Authentication") — the user story

Epic, then feature, then story is Allure’s grouping. When a reporter is actually wired, the HTML tree shows VWO Login Tests / Essential features / Authentication / this test name. Without a reporter, these calls are function invocations that write metadata into Allure’s runtime. Nobody renders them.

Then the browser flow is 228 without storageState and with a title assertion. Same VWO login URL. Same CSS ids. Same password. Same waitForURL for dashboard or home. Then expect(page).toHaveTitle("Dashboard"). That is the proof login worked — the document title, not a cookie dump.

The comment above waitForURL is copy-pasted from 228. This spec does not snapshot storage. The comment is leftover. I leave it. I tell students: comments that mention a call you did not make are a smell. We do not invent a storageState line to make the comment true.

The Allure reporter is commented out in playwright.config.ts

This is the part students miss, then they open playwright-report and ask where Allure went.

Root playwright.config.ts on main, reporter block only. The live reporter array is HTML plus ./utils/CustomTTAReporter.ts. Directly under that line, commented out, is the same array with "allure-playwright" inserted in the middle. I am describing the file, not inventing a second config.

The package is installed. The config does not use it.

package.json lists allure-playwright at ^3.7.1 under devDependencies. There is no allure script in scripts. Scripts are go, test, test:headed, test:ui, report, report:tta. Default test is Playwright Test. That run writes the Playwright HTML report and the TTA report. It does not write allure-results unless you pass a reporter on the CLI.

The folder README is the honest runbook.

Default, using the configured project reporters:

npx playwright test tests/05_Allure_Reporting/230_Login.spec.ts

Allure for this command only, still from that README: pass --reporter=line,allure-playwright, then generate HTML from ./allure-results into ./allure-report, then open the report. That CLI override does not change playwright.config.ts. It does not uncomment the line. It attaches Allure for one invocation. You need the Allure command line available. I am not inventing a package.json script named allure:open. It is not there.

If you uncomment the config line yourself, you are editing the repo. This post does not do that. This post tells you the line is commented, the package is present, and the README shows the CLI escape hatch.

Why leave it commented in a teaching repo? Because the default classroom loop is headed Chromium plus the HTML report plus the TTA report. Allure is a second HTML tree. Students who have not installed the Allure CLI think the run is broken when allure-results appears and nothing opens. We teach the labels first. We wire the reporter when the machine has the CLI. Day 12 is labels plus honesty about the comment.

Interview answer, short: “Allure metadata lives in the spec via allure-js-commons. The reporter lives in config. This repo has the metadata. The reporter line is commented. I can enable it per run with --reporter=line,allure-playwright.”

Lab 231 — lists, allInnerTexts, and .first() so strict mode does not slap you

Lab: tests/06_Multiple_Element_/231_Multiple_Element.spec.ts

Folder name on GitHub: 06_Multiple_Element_. Trailing underscore. Path must match.

The spec wraps one test in test.describe('Multiple Elements Handling'). The test title is Basic Test - Verify page title. The body never calls toHaveTitle. There is an expect import and zero expect() calls. I am not going to invent an assertion and claim it is in the file. The lab is a collection drill. The title is leftover classroom naming. Say so in a code review.

The page is The Testing Academy multiple-element filter page: https://app.thetestingacademy.com/playwright/multiple_element_filter. The repeated selector is a.list-group-item — Bootstrap list-group links.

allInnerTexts() returns a string[]. One string per matching node, visible text. The spec logs the length, then loops. When the text is exactly My Account, it clicks. That is “filter the collection in JavaScript, then act.” You already wrote for...of and === in Days 2 and 4. Here they earn a click.

.first() is the strict-mode escape. page.getByText('My Account') may resolve more than one node. Playwright actions are strict. Click on a locator that matches two nodes throws. .first() picks one. The folder README says this out loud: the .first() call avoids strict-mode issues when more than one element has the same text. Prefer a locator that is unique. When the page gives you duplicates, .first(), .nth(), and .last() are the valves. Day 11 already used those on CSS. Same valves. New collection.

all() returns an array of Locator objects. Not strings. Locators. The second loop calls getAttribute("href") on each. Text extraction and attribute extraction are different APIs on purpose. allInnerTexts() when you need to read. all() when you still need locator methods.

Do not store a Locator from all() and expect it to freeze the DOM. A locator is lazy. It re-queries. If the click on My Account navigates, the second loop is a new page’s a.list-group-item set — or an empty set. The spec does not assert navigation. It prints hrefs of whatever matches after the click. Run it headed. Watch. That is the lab.

Run the spec path tests/06_Multiple_Element_/231_Multiple_Element.spec.ts with Playwright Test. For a headed run, the repo script test:headed accepts the same path.

Why this sits on the same day as tables: a table is a list with columns. allInnerTexts() on td is the same muscle as allInnerTexts() on a.list-group-item. Lab 231 is the warm-up. Labs 232 and 233 are the set.

Lab 232 — Helen Bennett, XPath indexes, then the native locator

Lab: tests/07_WebTables/232_WebTable_Basic.spec.ts

This is the table lesson I have taught for years. Find a name. Read the country in the same row. First with the XPath you already know from Selenium. Then with the Playwright locator I want you to keep.

The test lives in test.describe('Web Table Tests'). The test name is Verify that Helen Bennett is actually living in the UK. The page is https://awesomeqa.com/webtable.html. Table id customers. Classic company / contact / country table. Helen Bennett is a contact. Her country in that demo table is the United Kingdom. The test name says UK. The assertions are console.log. Again: expect is imported and never called. The “verify” is your eyes on the log. I will not invent expect(country1).toBe('UK') and paste it as if it were on main.

The comments in the file draw the XPath pattern: //table[@id="customers"]/tbody/tr[ i ]/td[ j ]. Three string slices — firstPart, secondPart, thirdPart — concatenate with i and j. This is the Selenium habit. I keep it in the file so a Java SDET can see the old muscle, then I retire it.

rows is the tr count under tbody. cols is the td count in tr[2] — the first data row. Row 1 in this table is the header, so the data loop starts at i = 2. XPath indexes are one-based. tr[1] is the first tr. td[1] is the first td.

The inner loop walks every cell. If the cell text includes Helen Bennett, the country is the following sibling td. Axis: following-sibling::td. Same row, next cell. On this table the country sits in the third column, immediately after the contact name. The axis works because of that layout. If a column appeared between name and country, the first following sibling would be wrong. Axes are layout-dependent. Say that in the interview.

After the nested loop, four lines I want you to keep. A row locator: #customers tbody tr with { hasText: 'Helen Bennett' }. Then row1.locator('td').nth(2).innerText() for the country. locator(selector, { hasText }) filters the row list to the row that contains that text. No string-built XPath. No i and j. Then nth(2) is the third cell. nth is zero-based. nth(0) company, nth(1) contact, nth(2) country.

That is the conversion table for the rest of your career. XPath first row is tr[1]. Playwright first row is nth(0). XPath third cell is td[3]. Playwright third cell is nth(2). Off-by-one is the web-table interview. I fail people on it every month. Lab 232 exists so you feel both indexes in one file.

The comment in the spec: “Playwright Native Locators is very much recommended.” I agree with my own comment. Teach XPath so you can read a legacy suite. Write hasText plus nth in a new suite.

Run tests/07_WebTables/232_WebTable_Basic.spec.ts. You should see a flood of dynamic XPath strings, cell texts, then a log line that Helen Bennett is in the UK — twice. Once from the axis, once from nth(2). If the demo site changes Helen’s country, the log changes. The file does not freeze the value in an expect.

Lab 233 — a second table, count, nth, allInnerTexts

Lab: tests/07_WebTables/233_WebTable_Dyanamic.spec.ts

Filename spelling on GitHub: Dyanamic. I use that name.

Same describe title: Web Table Tests. The test name is test_web_table_login - structured extraction. There is no login. There is no storageState. Classroom leftover. Structured extraction is the real name of the work.

Different URL: https://awesomeqa.com/webtable1.html, not webtable.html. Different table: table[summary="Sample Table"]. The locator is a collection of