Imported from jagreehal/executable-stories-demo (
.agents/skills/playwright-story-api/SKILL.md). Install upstream withnpx skills add jagreehal/executable-stories-demo --skill playwright-story-api. Copyright stays with the author.
executable-stories-playwright — Story API
Setup
Sources of truth: packages/executable-stories-playwright/src/story-api.ts and
the docs-site Playwright story API reference.
import { test, expect } from "@playwright/test";
import { story, given, when, then } from "executable-stories-playwright";
test.describe("Login page", () => {
test("authenticates with valid credentials", async ({ page }, testInfo) => {
story.init(testInfo, { tags: ["auth"], ticket: "AUTH-42", covers: ["src/auth.ts"] });
given("the login page is loaded");
await page.goto("/login");
when("valid credentials are entered");
await page.getByLabel("Email").fill("alice@example.com");
await page.getByLabel("Password").fill("secret");
await page.getByRole("button", { name: "Sign in" }).click();
then("the dashboard is shown");
await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
});
File naming: *.story.spec.ts.
Playwright uses top-level step exports. story.init(testInfo) requires the testInfo parameter from the test callback.
Core Patterns
Top-level step exports with fixtures
import { story, given, when, then, and, but } from "executable-stories-playwright";
test("blocks suspended user login", async ({ page }, testInfo) => {
story.init(testInfo);
given("the user account exists"); // renders "Given"
given("the account is suspended"); // renders "And" (auto-converted)
when("the user submits valid credentials");
await page.getByLabel("Email").fill("user@test.com");
await page.getByRole("button", { name: "Sign in" }).click();
then("the user sees an error message");
await expect(page.getByRole("alert")).toBeVisible();
but("the user is not logged in"); // renders "But" (always)
await expect(page).toHaveURL("/login");
});
Doc entries with screenshots
Prefer story.screenshot({ page, alt }) — it captures the screenshot itself
and inlines the bytes directly, so there's no separate path to keep in
sync and nothing for Playwright's output cleanup to delete before the report
is built:
test("checkout flow", async ({ page }, testInfo) => {
story.init(testInfo);
given("a cart with items");
story.json({ label: "Cart", value: { items: 3, total: 150 } });
when("the user completes checkout");
await page.getByRole("button", { name: "Checkout" }).click();
await page.waitForURL("/confirmation");
then("the confirmation page is shown");
await story.screenshot({ page, alt: "Order confirmation" }); // async — capture, no path needed
story.table({
label: "Order details",
columns: ["Item", "Qty", "Price"],
rows: [["Widget", "3", "$50"]],
});
});
Storyboards: when two or more steps each carry a screenshot, reports
render a horizontal filmstrip at the top of the scenario (one captioned
thumbnail per step, linking to the full-size image). To give stakeholders a
visual walkthrough, call await story.screenshot({ page, alt }) once after
each step — the alt becomes the frame caption. Derived automatically; no
option to set.
The HTML compare report reuses these step screenshots for scenarios whose
status flipped between runs (Regressed or Fixed). Capture the product state
that explains the outcome; screenshots stored only as unresolved local absolute
paths cannot render in a downloaded comparison report.
The older story.screenshot({ path, alt }) form attaches a screenshot that
already exists on disk (e.g. one taken earlier for another purpose) — the
caller must write the file to path first (typically
await page.screenshot({ path })). If nothing wrote to that exact path
before story.screenshot() runs, it warns at the call site and the report
falls back to a "Screenshot unavailable" placeholder rather than a broken
image — that warning almost always means the preceding page.screenshot()
call is missing or its path doesn't match.
Feature narrative and glossary (story.feature)
Scenarios say what the system does. story.feature says why the feature exists and defines its terms, so a reader meets the intent before the examples. Call it once per file at module scope. It heads every scenario in the file and applies its tags to all of them.
story.feature({
title: "Fruit machine",
kind: "ability", // "feature" (default) | "ability" | "business-need"
narrative: `
A fruit machine holds a **pot** of money. You pay to spin four slots.
Match the colours and you win some of the pot; if the machine cannot
afford your prize it owes you *free plays* instead.
`,
tags: ["fruit-machine"],
glossary: [
{ term: "float", definition: "The money the machine starts with, before anyone plays." },
{ term: "free play", definition: "A go you do not pay for. Owed when the pot could not cover a prize." },
],
});
The HTML report renders the narrative above the scenarios and the glossary as an aligned term/definition list; the Markdown report prints both under the feature heading.
Prose with Markdown (story.section)
narrative and story.section({ title, markdown }) are the two places that take Markdown (CommonMark plus GFM tables). Everything else (note, kv, table cells, step text) is plain text. Use a section wherever a scenario needs explanation a note cannot carry: why this rule comes first, the trap a reader is likely to fall into, a worked example.
it("four matching colours wins the jackpot", async ({ page }, testInfo) => {
story.init(testInfo);
// Before the first step: attached to the scenario, rendered after the steps.
story.section({
title: "The big one",
markdown: `
Four slots, all the same colour. The machine hands over everything it has:
the float, every pound other players lost, *and* the pound you just put in.
| You see | You get |
| ---------------- | ------------- |
| Four the same | The whole pot |
| Four different | Half the pot |
The pot is counted **after** your pound went in, not before.
`,
});
story.given("a machine with a £10 float");
// After a step: attached to that step and rendered beneath it.
story.section({ title: "Where the money went", markdown: "…" });
});
Template-literal indentation is stripped before parsing, so write the Markdown indented to match the surrounding code; a fenced block keeps its own relative indent. The title is the section's heading; do not add # headings inside the body, use bold run-in labels for sub-parts. Prefer story.table over a Markdown table when the rows are data the test already holds; Markdown tables are for prose that happens to line up.
State snapshots (story.state)
story.state({ label?, value }) captures what the world looks like at the current step as a JSON-serializable snapshot. Steps carrying state docs (or screenshots) become storyboard frames: a label's first appearance shows the full snapshot, consecutive snapshots with the same label render as a diff (items[0].qty: 1 → 2), and multiple labels appear as side-by-side lanes. Labels are scoped to the scenario: snapshots in different scenarios never diff against each other.
Reach for it wherever you would otherwise assert a difference. Run the same operation for two actors or two inputs, snapshot under one label after each, and the report states the delta itself — + tools[2]: "update_case" — instead of leaving a reader to compare two blocks of JSON. The same step can carry a screenshot and a state — the screen next to the backend record.
given("an empty basket");
story.state({ label: "Basket", value: { items: [], total: 0 } });
when("the shopper adds a hoodie");
await page.getByRole("button", { name: "Add to basket" }).click();
story.state({ label: "Basket", value: { items: [{ sku: "hoodie", qty: 1 }], total: 45 } });
Capture the business-relevant projection, not the ORM entity — the adapter warns above ~100KB.
Embedded HTML (story.html)
Embed generated HTML (charts, single-file reports, skill/agent output) in a
sandboxed iframe in the HTML report. Exactly one of path / url / content
is required; optional title and height (number → px, string passed
through; default 400px).
// Generated in-test — never touches disk. The safest option: inherently
// self-contained, nothing to resolve.
story.html({ content: chartHtml, title: "Latency chart", height: "60vh" });
// Remote URL — rendered via iframe src with an open-in-new-tab button.
story.html({ url: "https://dash.example.com/run/42", height: 600 });
// Local file — read at capture time and inlined, so an absolute runner path
// still resolves when the report is downloaded as a CI artifact.
story.html({ path: testInfo.outputPath("summary.html"), title: "Summary" });
The embedded HTML must be self-contained (a single file). Local files are
read at capture time and inlined as the iframe's srcdoc; relative references
to sibling CSS/JS/images are not rewritten or bundled, so a multi-file
report will render broken. Use your tool's single-file/inline mode, or pass the
markup via content. Directory bundling is planned.
All embedded HTML renders inside <iframe sandbox="allow-scripts"> — scripts
run (charts work) but cannot touch the report DOM, cookies, or storage.
Attachments (story.attach)
story.attach({ name: "debug.log", mediaType: "text/plain", path: "/tmp/debug.log" });
story.attach({
name: "notes.md",
mediaType: "text/markdown",
body: "## Notes\n\nRetried once.",
encoding: "IDENTITY",
});
The HTML report previews text/plain, text/markdown and text/html attachments inline, next to the download link. Pass inline text with encoding: "IDENTITY"; a body without an encoding is read as base64.
Step wrappers with timing
const response = await story.fn("When", "the API is called", async () => {
return page.request.get("/api/data");
});
await story.expect("the response is successful", async () => {
expect(response.status()).toBe(200);
});
Playwright's live assertion counter is observed automatically. Assertions after a
marker belong to that marker until the next step or test end; story.expect measures
its own callback. A passing observable Then/And/But claim with zero assertions is marked
in Markdown and HTML and grades none. Grades are defined in spec-evidence-review/SKILL.md. An absent count means unobservable, not zero.
Suite headings from test.describe
test.describe("Authentication", () => {
test("valid login", async ({ page }, testInfo) => {
story.init(testInfo);
// Produces "## Authentication" heading in generated docs
});
});
Suite path comes from testInfo.titlePath. Describe titles become ## headings in generated docs.
Common Mistakes
CRITICAL Missing testInfo argument in story.init()
Wrong:
test("my test", async ({ page }) => {
story.init();
given("something");
});
Correct:
test("my test", async ({ page }, testInfo) => {
story.init(testInfo);
given("something");
});
Without testInfo, story metadata is not linked to the test. The testInfo parameter must be the second argument in the Playwright test callback.
Source: packages/executable-stories-playwright/src/story-api.ts
HIGH Using .story.test.ts file extension
Wrong:
tests/login.story.test.ts
Correct:
tests/login.story.spec.ts
Playwright uses .spec.ts by convention. The reporter filters for .story.spec.ts files. Using .test.ts may cause the reporter to miss story metadata.
Source: CLAUDE.md — "Story test files use .story.spec.ts (playwright)"
HIGH Forgetting testInfo in callback destructuring
Wrong:
test("my test", async ({ page }) => {
story.init(testInfo); // testInfo is undefined
});
Correct:
test("my test", async ({ page }, testInfo) => {
story.init(testInfo);
});
testInfo is the second parameter of the Playwright test callback, not a fixture. It must be explicitly named after the fixtures object.
Source: packages/executable-stories-playwright/src/story-api.ts
MEDIUM Calling steps before story.init()
Wrong:
test("my test", async ({ page }, testInfo) => {
given("something");
story.init(testInfo);
});
Correct:
test("my test", async ({ page }, testInfo) => {
story.init(testInfo);
given("something");
});
Steps called before init() are silently dropped because no story context exists.
Source: packages/eslint-plugin-executable-stories-playwright/src/rules/require-story-context-for-steps.ts
Parameterized Scenarios (Scenario Outline equivalent)
Use Playwright's data-driven pattern with a for...of loop to produce one scenario per data row — the framework-native replacement for Cucumber's Scenario Outline + Examples. The scenario name comes from the test() title.
import { test } from "@playwright/test";
import { story, given, when, then } from "executable-stories-playwright";
const cases = [
{ input: 1, expected: 2 },
{ input: 2, expected: 4 },
{ input: 3, expected: 6 },
];
for (const { input, expected } of cases) {
test(`doubles ${input} to ${expected}`, async ({ page }, testInfo) => {
story.init(testInfo);
given(`the input is ${input}`);
when("the doubler runs");
then(`the result is ${expected}`);
// ... assertions
});
}
Each iteration produces a separate scenario in the generated report. Use interpolated titles so each scenario has a distinct, descriptive name.
Note: Playwright does not have it.each — use a for...of loop instead.
