Configure locator self-healing on a working suite, then ship two seeded UI changes: one where the heal is correct, and one where the heal quietly hides a real regression. You build the audit workflow that catches the second — and the lab is failed if the wrong heal lands.
Vikram's team adopted a self-healing tool after a locator-churn quarter, and within a month a broken "Apply discount" button rode a heal into production behind a green suite. That is the signature failure mode of the entire category: the healer's objective is a passing test, and a passing test is not the same thing as a working feature. In this lab you run a minimal self-healing shim for Playwright — the same fallback-matching idea Healenium and cy.prompt implement, small enough to read in two minutes — apply two UI changes, and operate every heal as a proposed change through an audit queue. Teams running Healenium can substitute its Docker setup in step 2; the governance workflow is identical, and the workflow is the lab.
~90 minutes hands-on · Core tier (maps to module M3 and playbooks PB-17/PB-18). Cost: $0.
mkdir -p qa-lab-05 && cd qa-lab-05 && npm init -y
npm install express@4 && npm install --save-dev @playwright/test
npx playwright install chromium
shop.js — the app, version 1:
const express = require("express");
const app = express();
app.use(express.urlencoded({ extended: true }));
let cart = { items: 2, subtotal: 800, discount: 0 };
app.get("/", (req, res) => {
res.send(`<!doctype html><html><head><title>MiniShop</title></head><body>
<h1>Checkout</h1>
<p id="subtotal">Subtotal: ₹${cart.subtotal}</p>
<p id="discount">Discount: ₹${cart.discount}</p>
<form method="POST" action="/discount">
<button id="apply-discount" name="apply">Apply 10% discount</button>
</form>
<form method="POST" action="/pay">
<button id="pay-now">Pay ₹${cart.subtotal - cart.discount}</button>
</form></body></html>`);
});
app.post("/discount", (req, res) => {
cart.discount = Math.round(cart.subtotal * 0.1);
res.redirect("/");
});
app.post("/pay", (req, res) => {
res.send("<p id='paid'>Payment accepted</p>");
});
app.listen(3000);
heal.js — the self-healing shim (read it; auditing a tool you understand is the point):
// Minimal healing locator: try primary selector; on failure, score all
// candidates of the same tag by attribute/text similarity and log a
// proposed heal to heals.log instead of silently passing.
const fs = require("fs");
async function healingClick(page, primary, expectedText) {
try {
await page.locator(primary).click({ timeout: 2000 });
return primary;
} catch {
const buttons = await page.locator("button").all();
let best = null, bestScore = -1;
for (const b of buttons) {
const t = (await b.textContent()) || "";
const overlap = t.split(" ").filter((w) => expectedText.includes(w)).length;
if (overlap > bestScore) { bestScore = overlap; best = { b, t }; }
}
fs.appendFileSync("heals.log",
`HEAL PROPOSED: ${primary} -> button "${best.t.trim()}"\n`);
await best.b.click();
return `button:has-text("${best.t.trim()}")`;
}
}
module.exports = { healingClick };
tests/checkout.spec.js — the suite:
const { test, expect } = require("@playwright/test");
const { healingClick } = require("../heal");
test("discount reduces the payable amount", async ({ page }) => {
await page.goto("http://localhost:3000");
await healingClick(page, "#apply-discount", "Apply 10% discount");
await expect(page.locator("#discount")).toHaveText("Discount: ₹80");
await expect(page.getByRole("button", { name: /Pay/ })).toContainText("₹720");
});
test("payment completes", async ({ page }) => {
await page.goto("http://localhost:3000");
await healingClick(page, "#pay-now", "Pay");
await expect(page.locator("#paid")).toHaveText("Payment accepted");
});
Run node shop.js, then npx playwright test — green baseline. git init && git add -A && git commit -m baseline.
heal-policy.md, write four rules you will operate by: (1) every heal is a proposed change, never an auto-merge; (2) a heal is reviewed as a diff — old locator, new locator, and the assertions that ran after it; (3) a heal accompanied by any assertion failure, weakened assertion, or behavior change escalates to human investigation as a suspected product bug; (4) heals are batched and audited within 24h, with an owner. Everything after this step is you obeying your own policy.shop.js, change id="apply-discount" to id="promo-apply" (button text unchanged). Restart the app, run the suite. The shim fails on #apply-discount, scores candidates, clicks the right button, logs HEAL PROPOSED — and both downstream assertions still pass (discount ₹80, pay ₹720). Test green, heal logged.heals.log. In heal-audit.md, record: primary locator, healed target, downstream assertions status (all passed), verdict ACCEPT, and the follow-up action — commit the new stable locator into the test (#promo-apply) so the heal doesn't re-fire every run. A heal you accept but never bake into the code is permanent runtime magic; kill it at the source.cart.discount = 0; (the regression — the feature is now broken) and change the button to id="promo-btn" with text "Apply discount code" (the locator churn that summons the healer). Restart. Run the suite.Discount: ₹80 fails, because the feature is genuinely broken. This is the correct system behavior: heal succeeded, test failed. Record in heal-audit.md: heal proposed, click landed, downstream assertions FAILED — per policy rule 3, escalate as suspected product bug, verdict REJECT the green path.await expect(page.locator("#discount")).toBeVisible() — rerun, and watch the suite go fully green against a broken feature. Screenshot or copy the passing output into heal-audit.md under the heading "what the wrong heal looks like when it lands." Then revert the weakening immediately.bug-report.md citing the handler line. Fix the regression (cart.discount = Math.round(cart.subtotal * 0.1);), bake the new locator into the test, run to green — a green that now means something.heal-audit.md with the summary table: two heals, one accepted (locator-only, assertions green), one escalated (assertions red → product bug found), zero heals landed with weakened assertions. Add one paragraph: which of your four policy rules did the actual catching, and what would have happened without it.The dangerous artifact in any self-healing rollout is the fully green run after a heal. Green-after-heal has exactly two explanations — the UI merely moved, or your assertions are too weak to notice the feature died — and only the audit queue can tell you which one you are looking at.
heal-policy.md written before the first heal, with all four rules.heals.log contains both proposed heals.heal-audit.md shows heal #1 ACCEPTED with assertions-passed evidence and the locator baked back into the test; heal #2 ESCALATED with the failing assertions quoted.bug-report.md cites the broken handler; final suite is green with original-strength assertions and the real fix.Self-grading rubric (pass = 7/10):
| Dimension | 0 | 1 | 2 |
|---|---|---|---|
| Policy-first discipline | Policy written after the fact | Written first, ignored once | Written first, followed throughout |
| Correct heal handling | Auto-trusted | Accepted, not baked in | Accepted + locator committed to source |
| Wrong heal caught | Landed for real | Caught, no manual repro | Caught, repro'd, bug filed with line cited |
| Assertion integrity | Weakened assertions survive | Restored late | Weakening only as demo, immediately reverted |
| Audit trail | None | Partial | Complete log with verdicts + reasoning |
Self-healing removes locator churn, not judgment. Operate every heal as a proposed change with the downstream assertions as evidence: locator-only heal with green assertions gets accepted and baked into source; any heal wearing a red assertion is a product bug filing itself — and a tool that turns that red into green has hidden a regression, not healed a test.