A XSS check on the signup form: type a name that looks like HTML, submit, and expect the string to appear as text. Playwright and Cypress both assert that window.__xss stays unset and that #success contains no extra img.
SUT: js-ui · id: security.xss-escape
Cached CI results from 10/4/2026, 3:02:04 AM (ci · ff16d82)
Playwright launches a real browser (Chromium here), gives you a page object, and uses locators plus expect() to assert what the user sees. Traces and auto-waiting make flaky clicks easier to debug than raw WebDriver.
webServer starts samples/js-ui. Each test opens a page, interacts through roles and CSS, and asserts URL or visible text. The SUT is HTML/CSS/JS in the browser — not an imported function.
Testing architecture
Security tests ask what an attacker can make the SUT do — not whether the happy-path JSON or heading is correct. Here that means isolation headers on js-api responses, and proving that a script-like signup name is assigned with textContent so it cannot run. Failures point at missing headers or unsafe DOM writes.
The SUT writes the name with textContent (not innerHTML). The test is a user path plus a security oracle: if the page had assigned innerHTML, the onerror handler would set window.__xss. Failures point at unsafe DOM writes, not at a missing required-field message.
SUT: js-ui · samples/js-ui/public/app.js · run npm test in examples/security/xss-escape/playwright
Code under test · samples/js-ui/public/app.js
// Increment the on-page counter (used by the navigate smoke path as a live control)
const increment = document.getElementById("increment");
const countOut = document.getElementById("count");
if (increment && countOut) {
increment.addEventListener("click", () => {
countOut.textContent = String(Number(countOut.textContent ?? "0") + 1);
});
}
// Client-side required-name check — the form-validation UX scenario asserts this message
const form = document.getElementById("signup-form");
const nameInput = document.getElementById("name");
const nameError = document.getElementById("name-error");
const success = document.getElementById("success");
const thanksName = document.getElementById("thanks-name");
if (form && nameInput && nameError && success && thanksName) {
form.addEventListener("submit", (event) => {
event.preventDefault();
const empty = nameInput.value.trim() === "";
nameError.hidden = !empty;
success.hidden = empty;
if (!empty) {
// textContent, never innerHTML — markup in the name must not run
thanksName.textContent = nameInput.value.trim();
}
});
}
Test · examples/security/xss-escape/playwright/xss.spec.js · Apache-2.0
· run in examples/security/xss-escape/playwright: npm test
// XSS oracle — a crafted name must render as text, never run
import { test, expect } from "@playwright/test";
const payload = `<img src=x onerror="window.__xss=1">`;
test("script-like name is shown as text and does not run", async ({ page }) => {
await page.goto("./signup.html");
await page.getByLabel("Name").fill(payload);
await page.getByRole("button", { name: "Submit" }).click();
// Visible copy is the literal payload (textContent)
await expect(page.locator("#thanks-name")).toHaveText(payload);
// If the page had used innerHTML, onerror would set this flag
expect(await page.evaluate(() => window.__xss)).toBeUndefined();
await expect(page.locator("#success img")).toHaveCount(0);
});
Cypress runs tests in (or beside) the browser with a chainable API: cy.visit, cy.click, cy.should. The open-source runner is enough for these demos; we do not use Cypress Cloud.
start-server-and-test boots js-ui, then cypress run visits the page, clicks, and asserts. Commands retry until the assertion passes or times out — that retry is Cypress’s default stability model.
Testing architecture
Security tests ask what an attacker can make the SUT do — not whether the happy-path JSON or heading is correct. Here that means isolation headers on js-api responses, and proving that a script-like signup name is assigned with textContent so it cannot run. Failures point at missing headers or unsafe DOM writes.
The SUT writes the name with textContent (not innerHTML). The test is a user path plus a security oracle: if the page had assigned innerHTML, the onerror handler would set window.__xss. Failures point at unsafe DOM writes, not at a missing required-field message.
SUT: js-ui · samples/js-ui/public/app.js · run npm test in examples/security/xss-escape/cypress
Code under test · samples/js-ui/public/app.js
// Increment the on-page counter (used by the navigate smoke path as a live control)
const increment = document.getElementById("increment");
const countOut = document.getElementById("count");
if (increment && countOut) {
increment.addEventListener("click", () => {
countOut.textContent = String(Number(countOut.textContent ?? "0") + 1);
});
}
// Client-side required-name check — the form-validation UX scenario asserts this message
const form = document.getElementById("signup-form");
const nameInput = document.getElementById("name");
const nameError = document.getElementById("name-error");
const success = document.getElementById("success");
const thanksName = document.getElementById("thanks-name");
if (form && nameInput && nameError && success && thanksName) {
form.addEventListener("submit", (event) => {
event.preventDefault();
const empty = nameInput.value.trim() === "";
nameError.hidden = !empty;
success.hidden = empty;
if (!empty) {
// textContent, never innerHTML — markup in the name must not run
thanksName.textContent = nameInput.value.trim();
}
});
}
Test · examples/security/xss-escape/cypress/xss.cy.js · MIT
· run in examples/security/xss-escape/cypress: npm test
// Cypress XSS oracle — same textContent contract as Playwright
const payload = `<img src=x onerror="window.__xss=1">`;
describe("xss escape", () => {
it("script-like name is shown as text and does not run", () => {
cy.visit("/signup.html");
cy.get("#name").type(payload, { parseSpecialCharSequences: false });
cy.contains("button", "Submit").click();
cy.get("#thanks-name").should("have.text", payload);
cy.window().should((win) => {
expect(win.__xss).to.equal(undefined);
});
cy.get("#success img").should("not.exist");
});
});