Pointer capture ending is not the same thing as a drag operation ending. releasePointerCapture() can return without throwing, hasPointerCapture() can become false, and the browser can dispatch lostpointercapture, while application state still says “dragging.” Likewise, a user-agent cancellation can emit pointercancel and implicitly release capture, yet a cleanup path that only handles pointerup can leave stale pointer identity, CSS state, listeners, or application ownership behind. Pointer Events Level 3 makes the browser-side lifecycle explicit: pointercancel is fired when the user agent suppresses a pointer stream; suppression includes implicit capture release; and lostpointercapture must fire after capture is released and before subsequent pointer events for that pointer.
This playbook treats that boundary as a release-acceptance problem. It proposes a disposable Chromium fixture pinned to Node.js 26.13.1, Playwright 1.65.0, and Chromium 158.0.8127.18; a prewritten expected-case manifest; browser-driven mouse input; Chromium DevTools Protocol touch cancellation; an intentionally broken negative control; one evidence record per case; and an ACCEPT / REPAIR / RERUN / HOLD gate. Node.js lists 26.13.1 as an LTS release dated November 11, 2026, while Playwright 1.65 documents Chromium 158.0.8127.18 and support for Node 22, 24, and 26. No browser cases in this article were executed for this publication. Every behavioral result is therefore EXPECTED / NOT EXECUTED, and the release decision for the published fixture is HOLD.
A drag implementation usually has at least two kinds of state: browser-owned pointer state and application-owned interaction state. Pointer capture belongs to the first category. Flags such as dragging, the current pointer ID, temporary CSS classes, movement deltas, subscriptions, and the logical owner of an in-progress interaction belong to the second. Confusing those domains creates a particularly convincing false positive.
Consider an application that calls releasePointerCapture(pointerId) and receives no exception. The browser-side operation may have succeeded, yet nothing in that successful return establishes that application cleanup executed. Pointer Events Level 3 explicitly separates capture processing from application semantics. hasPointerCapture() reports whether the pending capture override belongs to an element; it does not report whether your drag state machine has returned to idle.
Cancellation adds another boundary. When the browser suppresses a pointer stream, the specification requires pointercancel, then boundary events, followed by implicit release when the pointer is captured. An implementation that cleans up only on pointerup therefore has an obvious hole to test. MDN similarly describes pointercancel as the browser signaling that further pointer events are unlikely, including cases involving viewport manipulation, hardware conditions, and other interruption scenarios.
The acceptance question is consequently not “did capture go away?” It is: after capture loss or cancellation, did the application reconcile every piece of drag state exactly once, for the correct pointer, and remain idle under subsequent input?
Assumptions for this playbook are explicit. The primary executable acceptance target is Chromium, because Chromium DevTools Protocol exposes a deterministic touchCancel synthetic-input primitive. The proposed container base is Debian Bookworm through the official node:26.13.1-bookworm-slim image; CI architecture is assumed to be Linux AMD64; and no claim is made about macOS, Windows, physical pen hardware, Firefox, or WebKit until equivalent evidence is collected. The official Node image publishes a 26.13.1-bookworm-slim tag.
Define the acceptance contract
Begin with states, not event handlers. For this fixture, a drag is operationally ended only when four independent conditions agree: browser capture is absent, the application state machine is idle, the active pointer identity has been cleared, and later movement cannot mutate the completed drag.
The W3C contract provides only part of that conclusion. setPointerCapture() changes the capture target for subsequent events while capture remains active, and an invalid active pointer ID raises NotFoundError; importantly, the method can fail silently when the pointer is not in the active-buttons state. MDN summarizes the same API as routing subsequent pointer events to the capture element until release or pointerup. Neither source defines your dragging flag or your cleanup function. Those are application contracts that must be asserted separately.
For this playbook, acceptance requires the following independently observable evidence:
Boundary | Required evidence | Reject when |
Capture established | gotpointercapture plus matching pointerId | Capture event never appears |
Capture lost | lostpointercapture for that pointer | Missing or wrong pointer |
Cancellation | pointercancel when cancellation case is invoked | Case claims cancellation without event evidence |
Application termination | dragging === false, activePointerId === null | Browser capture ended but app remains active |
Cleanup idempotence | cleanupCount === 1 | pointercancel plus later loss cleans twice |
Movement quiescence | no post-terminal drag mutations | Later move changes drag state |
Runtime identity | Node, Playwright, Chromium, OS recorded | Environment cannot be reconstructed |
Oracle independence | expected row exists before run | Expected values edited after observation |
This is deliberately narrower than Machine learning evaluation. That article is aimed largely at model builders and beginners deciding whether predictive performance generalizes; it covers data splits, baselines, metrics, calibration, subgroup analysis, and protected test sets. Here, “independent oracle” means a literal browser-event and application-state manifest, not an ML validation set.
The asyncio article targets backend engineers investigating when asyncio.to_thread() captures contextvars.Context, using coroutine construction, task scheduling, and worker execution as its boundaries. The Kubernetes preStop article targets platform and reliability work around termination grace periods, hook timing, SIGTERM, EndpointSlice state, and forced shutdown. These playbooks share an emphasis on evidence. This playbook examines Pointer Events capture loss and cancellation.
The acceptance command is therefore not merely npm test. A successful test process is useful only if the case ledger proves the required event, state, pointer identity, and runtime assertions.
Capture a pinned baseline
The normative baseline is Pointer Events Level 3, published as a W3C Recommendation on June 30, 2026. Four clauses matter most.
First, when capture changes, the “process pending pointer capture” algorithm fires lostpointercapture at the former capture target before clearing the capture override. Second, lostpointercapture must be fired after capture is released and before subsequent pointer events for that pointer; subsequent pointer events then return to normal hit testing, subject to the specification’s separate rules for click-like events.
Third, stream suppression requires pointercancel, then pointerout, then pointerleave, and implicit capture release when capture exists. Fourth, pointercancel is non-cancelable and must preserve a defined set of pointer properties from the last event with that pointer ID.
That documented ordering gives us a strong expected invariant for a captured touch that is canceled: the log should contain pointercancel before capture is ultimately reported lost. The test should not assume they are adjacent because boundary events may occur in between. The fixture records all relevant events and compares ordering, not wall-clock spacing.
MDN provides an implementation-facing cross-check. Its lostpointercapture page says the event fires when a captured pointer is released; its pointercancel page describes browser-determined termination of the stream; and its setPointerCapture() page describes capture routing until release.
The proposed runtime baseline is current and pinned as of November 12, 2026:
Component | Pin | Basis |
Node.js | 26.13.1 | Current LTS release listed November 11, 2026 |
@playwright/test | 1.65.0 | Exact package version |
Chromium | 158.0.8127.18 | Playwright 1.65 bundled browser |
Container base | node:26.13.1-bookworm-slim | Official Node image |
Host architecture | linux/amd64 | Explicit fixture assumption |
Pointer Events baseline | Level 3 Recommendation, 2026-06-30 | W3C |
Node 26.13.1 and Playwright’s browser/version statements are documented by their respective official sources.
Pin fixture versions
The measurable rule is simple: PASS only if every recorded runtime field equals the manifest pin; otherwise RERUN before evaluating behavior.
Use exact dependency versions rather than ranges:
{
"name": "pointer-capture-loss-fixture",
"private": true,
"version": "1.0.0",
"engines": {
"node": "26.13.1"
},
"scripts": {
"test": "playwright test --project=chromium --workers=1"
},
"devDependencies": {
"@playwright/test": "1.65.0"
}
}Create and commit package-lock.json; run installations with npm ci. Playwright 1.65 documents Chromium 158.0.8127.18, Firefox 159.0, and WebKit 27.2. It also states that Node 20 is no longer tested and names Node 22, 24, and 26 as supported lines.
Capture the resolved versions before any behavioral assertion:
node --version
npm --version
npx playwright --version
node -e "console.log(process.platform, process.arch)"Inside the browser, persist navigator.userAgent and navigator.platform. Do not silently “accept equivalent” versions. If Chromium updates because the package pin changed, that is a different evidence set.
Create an isolated fixture
Isolation means the fixture owns its HTML, JavaScript, browser profile, synthetic data, and output directory. It should not point at a production application, share application storage, or depend on remote APIs.
That makes this materially different from Asyncio context capture, whose experimental object is Python coroutine/task scheduling and ContextVar propagation. The transferable principle is only that the binding boundary must be explicit.
Use this container recipe:
FROM node:26.13.1-bookworm-slim
WORKDIR /work
COPY package.json package-lock.json ./
RUN npm ci
RUN npx playwright install --with-deps chromium
COPY . .
CMD ["npm", "test"]Then build a minimal target:
<!doctype html>
<meta charset="utf-8">
<style>
#pad {
width: 240px;
height: 160px;
margin: 80px;
background: #ddd;
touch-action: none;
user-select: none;
}
#pad.dragging { outline: 4px solid currentColor; }
</style>
<div id="pad">drag target</div>Load /app.js as a JavaScript module on this page.
touch-action: none is intentional. Pointer Events defines touch-action as the mechanism through which content declares direct-manipulation behavior; viewport panning is not suppressed by simply canceling ordinary pointer events. The cancellation case in this fixture should therefore come from the test’s explicit touchCancel input rather than an accidental scroll arbitration path.
The state machine should expose evidence but no test-only shortcut for ending the drag:
const pad = document.querySelector("#pad");
window.fixture = {
dragging: false,
activePointerId: null,
cleanupCount: 0,
moveCount: 0,
events: [],
mode: "correct"
};
function record(e) {
fixture.events.push({
type: e.type,
pointerId: e.pointerId,
pointerType: e.pointerType,
buttons: e.buttons,
cancelable: e.cancelable,
trusted: e.isTrusted,
t: performance.now()
});
}
function finish(reason, e) {
if (!fixture.dragging) return;
if (fixture.activePointerId !== e.pointerId) return;
fixture.cleanupCount++;
fixture.dragging = false;
fixture.activePointerId = null;
fixture.terminalReason = reason;
pad.classList.remove("dragging");
}
pad.addEventListener("pointerdown", e => {
record(e);
fixture.dragging = true;
fixture.activePointerId = e.pointerId;
pad.classList.add("dragging");
pad.setPointerCapture(e.pointerId);
});
pad.addEventListener("pointermove", e => {
record(e);
if (fixture.dragging &&
fixture.activePointerId === e.pointerId) {
fixture.moveCount++;
}
});
pad.addEventListener("pointerup", e => {
record(e);
finish("pointerup", e);
});
pad.addEventListener("pointercancel", e => {
record(e);
finish("pointercancel", e);
});
pad.addEventListener("gotpointercapture", record);
pad.addEventListener("lostpointercapture", e => {
record(e);
if (fixture.mode !== "broken-loss") {
finish("lostpointercapture", e);
}
});This cleanup function is deliberately idempotent. A cancellation path may see pointercancel and then capture loss; the application should end the logical drag once, not perform destructive cleanup twice. The browser lifecycle and the application lifecycle are recorded separately.
Write the expected-case manifest
The manifest is the independent test oracle. Write it before execution and store it separately from emitted evidence. Do not let the test regenerate “expected” values from the actual event stream.
A useful first matrix is:
ID | Input | Expected browser boundary | Expected app state | Publication status |
C1 | mouse down → move → up | capture established, then lost after up | idle, cleanup once | EXPECTED / NOT EXECUTED |
C2 | mouse down → explicit capture release while button held | lostpointercapture | idle via loss cleanup | EXPECTED / NOT EXECUTED |
C3 | touch start → move → CDP touchCancel | pointercancel, later capture loss | idle, cleanup once | EXPECTED / NOT EXECUTED |
C4 | C2 followed by post-loss mouse move | subsequent move uses normal hit testing | no drag mutation | EXPECTED / NOT EXECUTED |
N1 | C2 with seeded broken loss handler | capture lost but app remains dragging | harness must reject | EXPECTED / NOT EXECUTED |
The W3C specification supports the expected capture-release and cancellation boundaries; the application-state columns are authored acceptance requirements, not browser guarantees.
Represent the oracle as machine-readable data:
{
"runtime": {
"node": "v26.13.1",
"playwright": "1.65.0",
"chromium": "158.0.8127.18"
},
"cases": {
"C1": {
"mustContain": ["pointerdown", "gotpointercapture", "pointerup",
"lostpointercapture"],
"finalDragging": false,
"cleanupCount": 1
},
"C2": {
"mustContain": ["pointerdown", "gotpointercapture",
"lostpointercapture"],
"finalDragging": false,
"cleanupCount": 1
},
"C3": {
"ordered": ["pointercancel", "lostpointercapture"],
"finalDragging": false,
"cleanupCount": 1
},
"N1": {
"expectedDetection": "STALE_DRAG_STATE"
}
}
}Avoid overfitting to incidental event counts. For example, movement coalescing, boundary-event details, or the precise number of intermediate moves are not the contract under review.
Establish a negative control
A test harness that only proves good cases can be wrong in a reassuring direction. N1 deliberately seeds a defect: lostpointercapture is recorded but its cleanup call is disabled.
The pass condition for the negative control is that the harness detects all of the following after explicit release:
capture absent
lostpointercapture observed
dragging == true
activePointerId != null
=> STALE_DRAG_STATEIf N1 is reported as an ordinary pass, the oracle is untrustworthy and the entire run becomes HOLD.
This is not an invented production bug. It is a controlled mutation in the disposable fixture whose sole purpose is to show that the acceptance machinery can distinguish browser capture state from application drag state.
Reproduce the misleading success
C2 captures the false-positive pattern most directly.
Playwright’s lower-level mouse API allows precise move, down, and up operations and is appropriate when a test needs more control than a one-shot drag helper. Start a mouse drag, wait until the application receives capture, and then invoke the real DOM release API while the mouse button remains down:
await page.mouse.move(120, 120);
await page.mouse.down();
await page.mouse.move(180, 130);
await expect.poll(async () =>
page.evaluate(() =>
window.fixture.events.some(e => e.type === "gotpointercapture")
)
).toBe(true);
const release = await page.evaluate(() => {
const pad = document.querySelector("#pad")!;
const id = window.fixture.activePointerId;
let error = null;
try {
pad.releasePointerCapture(id);
} catch (e) {
error = String(e);
}
return {
error,
hasCaptureImmediately: pad.hasPointerCapture(id)
};
});The release call itself is not the acceptance result. Pointer Events defines releasePointerCapture() as releasing capture for the identified pointer and hasPointerCapture() as reporting the pending capture override. Neither tells you whether your logical drag state has ended.
Wait for lostpointercapture, then snapshot independent application state:
await expect.poll(async () =>
page.evaluate(() =>
window.fixture.events.some(
e => e.type === "lostpointercapture"
)
)
).toBe(true);
const after = await page.evaluate(() => ({
dragging: window.fixture.dragging,
activePointerId: window.fixture.activePointerId,
cleanupCount: window.fixture.cleanupCount,
classDragging:
document.querySelector("#pad").classList.contains("dragging")
}));For the correct fixture, C2 expects dragging:false, activePointerId:null, cleanupCount:1, and no dragging class. For mode="broken-loss", the expected negative-control result is the opposite application state even though capture loss is evidenced.
Both are EXPECTED / NOT EXECUTED until an artifact exists.
This is the core distinction: a browser-side API operation can complete and still leave an application-side transaction unfinished. “No exception” is not a release criterion.
Run the comparison case
C1 provides the ordinary comparison: capture begins on pointerdown, movement is routed through the capturing target, and pointerup ends the interaction.
Pointer Events Level 3 says that immediately after pointerup or pointercancel, the user agent clears the pending capture target and processes pending capture, which can produce lostpointercapture. That makes C1 useful for answering a specific question: does the same cleanup function converge correctly when the terminal path is ordinary rather than explicitly forced?
The sequence is intentionally boring:
await page.mouse.move(120, 120);
await page.mouse.down();
await page.mouse.move(180, 130);
await page.mouse.up();
const evidence = await page.evaluate(() => ({
state: {
dragging: fixture.dragging,
activePointerId: fixture.activePointerId,
cleanupCount: fixture.cleanupCount,
moveCount: fixture.moveCount
},
events: fixture.events
}));Expected C1 invariants are one logical cleanup, idle final state, a matching pointer ID across the drag lifecycle, and evidence of capture loss after termination. This case is EXPECTED / NOT EXECUTED.
The procedural technique resembles the Kubernetes preStop playbook only at the evidence level. That article asks whether preStop consumed shutdown grace time and separates deletion, hook execution, signal delivery, and process termination. Here the boundaries are pointer cancellation, capture loss, and application cleanup.
Assert comparison invariants
The comparison should not assert that C1 and C2 have identical event histories. Their triggers differ. It should assert that they converge on the same owned application state.
A compact helper:
function assertIdle(caseId, evidence) {
expect(evidence.state.dragging, caseId).toBe(false);
expect(evidence.state.activePointerId, caseId).toBe(null);
expect(evidence.state.cleanupCount, caseId).toBe(1);
}Then establish case-specific lifecycle evidence:
expect(types(C1)).toContain("pointerup");
expect(types(C1)).toContain("lostpointercapture");
expect(types(C2)).not.toContain("pointerup");
expect(types(C2)).toContain("lostpointercapture");
assertIdle("C1", C1);
assertIdle("C2", C2);The measurable pass condition is therefore different browser cause, same reconciled application terminal state.
Do not require identical performance.now() deltas or a fixed number of pointer moves. Those values are diagnostic, not acceptance semantics.
Exercise failure boundaries
The cancellation case is C3. Chromium DevTools Protocol exposes Input.dispatchTouchEvent with explicit touchStart, touchMove, touchEnd, and touchCancel types; touchCancel carries no active touch points. Playwright can open a raw CDP session only for Chromium-based browsers, which is why this fixture scopes the deterministic cancellation test to Chromium.
Use the browser protocol rather than element.dispatchEvent(new PointerEvent(...)). Manually dispatching an untrusted DOM pointercancel would exercise your JavaScript listener but would not establish that the user agent performed its Pointer Events suppression and capture-release algorithm.
The synthetic cancellation sequence is:
const cdp = await page.context().newCDPSession(page);
await cdp.send("Input.dispatchTouchEvent", {
type: "touchStart",
touchPoints: [{ x: 120, y: 120, id: 1 }]
});
await cdp.send("Input.dispatchTouchEvent", {
type: "touchMove",
touchPoints: [{ x: 180, y: 130, id: 1 }]
});
await cdp.send("Input.dispatchTouchEvent", {
type: "touchCancel",
touchPoints: []
});The expected browser lifecycle is grounded in two W3C rules: suppression fires pointercancel, and a currently captured pointer is implicitly released; capture release in turn results in lostpointercapture. C3 is nevertheless EXPECTED / NOT EXECUTED because this publication has not run the sequence against the pinned Chromium binary.
Do not hard-code a CDP touch identifier as the expected Pointer Events pointerId. Record the actual pointer ID at pointerdown and require subsequent pointer events for that logical stream to match it.
C4 tests a different failure boundary: after explicit release and completed cleanup, send further pointer movement. The specification says subsequent pointer events after capture release return to normal hit testing, except for separately defined click-like event behavior. The application-level assertion is that post-loss motion must not increase the drag mutation counter.
Also test wrong-pointer isolation. A cleanup event for a different pointer ID must not terminate the active pointer’s drag. This is an application requirement, not a claim that the browser will spontaneously misroute IDs.
Finally, retain the event’s cancelable property for C3. The specification requires pointercancel.cancelable to be false. Treat disagreement as browser/spec evidence requiring HOLD, not as an excuse to rewrite the oracle.
Capture per-case evidence
A per-case ledger should preserve inputs, raw events, before/after state, and runtime identity in one record. Do not reduce the only durable evidence to “PASS.”
A useful schema is:
{
"caseId": "C3",
"status": "EXPECTED / NOT EXECUTED",
"runtime": {
"node": null,
"playwright": null,
"browserVersion": null,
"userAgent": null,
"platform": null
},
"input": {
"kind": "cdp-touch-cancel"
},
"before": null,
"events": [],
"after": null,
"assertions": [],
"artifactHashes": {}
}Before execution, null is more honest than fabricated values. The pinned values belong in the manifest; the runtime fields belong to the observed evidence.
At execution time, collect runtime identity from both process and browser:
const runtime = {
node: process.version,
playwright: require("@playwright/test/package.json").version,
browserVersion: browser.version(),
browser: await page.evaluate(() => ({
userAgent: navigator.userAgent,
platform: navigator.platform
}))
};Record each event without interpreting it:
function record(e) {
fixture.events.push({
type: e.type,
pointerId: e.pointerId,
pointerType: e.pointerType,
buttons: e.buttons,
cancelable: e.cancelable,
isPrimary: e.isPrimary,
trusted: e.isTrusted,
timeStamp: e.timeStamp,
observedAt: performance.now()
});
}The event ledger should keep pointerdown, pointermove, pointerup, pointercancel, gotpointercapture, lostpointercapture, pointerout, and pointerleave. That makes ordering disputes diagnosable rather than hidden behind a summary.
A publication-time ledger should look like this:
Case | Expected | Actual | State reconciliation | Status |
C1 | ordinary up → loss → idle | not observed | not assessed | EXPECTED / NOT EXECUTED |
C2 | explicit release → loss → idle | not observed | not assessed | EXPECTED / NOT EXECUTED |
C3 | cancel → implicit release/loss → idle | not observed | not assessed | EXPECTED / NOT EXECUTED |
C4 | post-loss move cannot mutate drag | not observed | not assessed | EXPECTED / NOT EXECUTED |
N1 | stale drag must be detected | not observed | not assessed | EXPECTED / NOT EXECUTED |
Preserve failure artifacts
A failed row must retain enough information to distinguish browser behavior, fixture behavior, and oracle failure.
Write one directory per run:
artifacts/
run.json
manifest.json
C1/
evidence.json
trace.zip
C2/
evidence.json
trace.zip
C3/
evidence.json
trace.zip
C4/
evidence.json
trace.zip
N1/
evidence.json
trace.zipEnable Playwright tracing for every case, not only after deciding which failure is interesting. Preserve the exact manifest beside the evidence and hash both:
sha256sum artifacts/manifest.json \
artifacts/*/evidence.json \
> artifacts/SHA256SUMSThe pass condition is measurable: every executed required case must have a parseable evidence file, trace, manifest reference, and checksum entry. Missing evidence changes the gate to HOLD.
Reconcile state and identity
Event identity is necessary because pointer capture is per pointer. A lostpointercapture event for pointer 8 says nothing about application state owned by pointer 7 unless your state machine explicitly defines such coupling.
The W3C model associates capture APIs with a specific pointerId; hasPointerCapture(pointerId) reports capture for that identifier, and capture release changes targeting for subsequent events from that pointer. The application should mirror that identity explicitly.
Reconciliation therefore joins four views:
manifest expectation
|
v
browser event identity ----+
|
capture state --------------+--> reconciled case
|
application pointer owner --+
|
application terminal state -+Do not “repair” identity by assuming that the latest event belongs to whichever drag is active. Require equality.
The principle resembles version reconciliation in HTTP If-Match, where the article requires a state-changing write to be tied to the same validator the client observed rather than merely to the same resource URL. Here, however, the identity token is a pointerId, and the problem is interaction ownership rather than HTTP concurrency.
A useful reconciler is:
function reconcile(row) {
const terminal = row.events.filter(e =>
e.type === "pointerup" ||
e.type === "pointercancel" ||
e.type === "lostpointercapture"
);
return {
pointerIds: [...new Set(terminal.map(e => e.pointerId))],
finalIdle:
row.after.dragging === false &&
row.after.activePointerId === null,
singleCleanup:
row.after.cleanupCount === 1
};
}If C3 contains pointercancel and lostpointercapture for the same pointer but cleanupCount === 2, the browser evidence can be conforming while the application still fails acceptance.
Likewise, if dragging === false but activePointerId remains populated, classify that as incomplete reconciliation. Stale identity is not harmless merely because the visual class disappeared; later event handling may still consult it.
Separate documentation from observation
Keep three evidence classes visibly separate.
Documented behavior comes from the W3C Recommendation and API documentation. Examples include the requirement to fire lostpointercapture after release, the suppression sequence that begins with pointercancel, and the fact that capture methods operate on a specific pointer ID.
Expected behavior is the prewritten interpretation of those requirements plus the application contract. “C3 must finish with cleanupCount === 1” is not a browser standard; it is our fixture’s acceptance rule.
Observed behavior exists only after a pinned run produces artifacts. This publication contains no such execution, so every row remains EXPECTED / NOT EXECUTED.
This distinction matters when documentation and observations disagree. Do not rewrite the expected manifest merely to make a browser pass. First determine whether the disagreement is caused by a flawed assumption, unsupported automation, a browser defect, a changed specification, or a fixture bug.
The W3C recommendation itself documents clarification work around failed capture, suppression, removed nodes, and pointercancel behavior, which is a reminder that lifecycle details can be refined over time. Version the documentary baseline with the evidence rather than treating “Pointer Events” as a timeless undifferentiated source.
Also preserve exceptions. The specification notes that click-like events have special targeting behavior and can still target the capturing element even after lostpointercapture has been dispatched in relevant circumstances. Therefore this fixture does not use click targeting as proof that capture still exists.
Define repair boundaries
A bounded repair may alter only application cleanup or fixture code that is demonstrably responsible for a failed invariant.
Acceptable repair examples are:
route both pointercancel and lostpointercapture through one idempotent finish() function;
clear activePointerId in the same logical transaction that clears dragging;
guard cleanup by pointer identity;
stop mutations once the drag is terminal.
Do not classify a changed browser version, changed expected manifest, or removal of a failing assertion as a repair. Those invalidate the evidence and require RERUN or HOLD.
The repair passes only when the original negative control still fails in the intended way and all repaired positive cases satisfy the unchanged oracle.
Implement a bounded repair
A robust cleanup function should own application state, not browser capture state. It should be safe to call from more than one terminal signal because pointercancel and later capture loss can occur in the same browser lifecycle. The Pointer Events suppression algorithm explicitly links cancellation and implicit release.
A bounded implementation looks like this:
function finishDrag(reason, event) {
if (!fixture.dragging) return false;
if (fixture.activePointerId !== event.pointerId) return false;
const endingPointerId = fixture.activePointerId;
fixture.dragging = false;
fixture.activePointerId = null;
fixture.terminalReason = reason;
fixture.cleanupCount += 1;
pad.classList.remove("dragging");
fixture.events.push({
type: "app-cleanup",
reason,
pointerId: endingPointerId,
t: performance.now()
});
return true;
}
pad.addEventListener("pointerup", e => {
record(e);
finishDrag("pointerup", e);
});
pad.addEventListener("pointercancel", e => {
record(e);
finishDrag("pointercancel", e);
});
pad.addEventListener("lostpointercapture", e => {
record(e);
finishDrag("lostpointercapture", e);
});This deliberately does not call releasePointerCapture() from finishDrag(). On pointercancel, capture may already be entering implicit release; on lostpointercapture, release has already occurred by definition. Calling release again adds no useful acceptance signal and can blur the direction of causality in the evidence.
The function also does not infer success from DOM classes. The class is a consequence of cleanup, not its authoritative state.
If your real component allocates document listeners, timers, animation-frame callbacks, subscriptions, or drag overlays, extend the fixture state with counters for those resources. The same rule applies: cleanup completeness must be asserted directly.
A repair is accepted only when the unchanged manifest passes under the same pins, N1 still demonstrates that the harness detects stale drag state, and no required artifact is missing.
Verify rollback and rerun safety
Rollback safety starts with making the fixture disposable. No case should depend on state created by a prior case. Start each test with a fresh browser context or at minimum a fresh page and reset fixture state before the first pointer input.
The adjacent CSS transition cancellation playbook makes a similar evidence distinction between lifecycle events and final style state. It currently marks its own browser matrix as unexecuted and uses HOLD until evidence is populated. That article tests transitioncancel versus transitionend; this playbook examines pointer capture loss and application cleanup.
The rerun workflow should be deterministic:
rm -rf artifacts test-results playwright-report
docker build --no-cache -t pointer-loss-fixture .
docker run --rm \
--init \
--ipc=host \
-v "$PWD/artifacts:/work/artifacts" \
pointer-loss-fixturePlaywright’s Docker guidance recommends pinning the image/tool version and notes that matching Playwright versions are important for locating the expected browser executables. It also recommends --ipc=host for Chromium workloads.
Before comparing a repaired run with the prior failure, verify that the manifest hash is identical:
sha256sum expected-cases.json
git diff --exit-code -- expected-cases.jsonThen verify runtime identity. If a dependency, browser binary, container base, architecture, or manifest changed, classify the new evidence as a separate run. Do not splice event records from different runtimes into one “passing” row.
Rollback itself should be tested by restoring the pre-repair application code and confirming that the same failing case is again detectable. That is stronger evidence than merely observing that the repaired revision is green.
The intended safety property is not that a rollback reproduces identical timestamps. It is that the same semantic defect returns under the same input and oracle.
Assign operational ownership
Assign one owner to each evidence class:
Concern | Owner |
Pointer-event contract interpretation | frontend/platform engineering |
Fixture and synthetic input | test engineering |
Application cleanup implementation | component owner |
Runtime/version pins | build or CI owner |
Evidence retention | release/CI system |
Browser/spec contradiction | engineering review, not automatic waiver |
The measurable rule is that every REPAIR or HOLD row names an owner and a next decision boundary before release approval.
An unexplained mismatch must not drift into “probably browser timing.” Either produce evidence that localizes it or keep the decision at HOLD.
Decide ACCEPT, REPAIR, RERUN or HOLD
The release gate has four outcomes, and they are intentionally not synonyms for test-runner statuses.
Decision | Condition |
ACCEPT | Required cases ran under exact pins; manifest matched; negative control detected the seeded defect; evidence complete |
REPAIR | A reproducible fixture/application defect explains the mismatch and can be changed without moving the oracle |
RERUN | Environment, dependency, browser, fixture, or evidence integrity changed enough to invalidate prior observations |
HOLD | Required cases are unexecuted, artifacts are incomplete, identity is unresolved, or browser/spec contradiction remains |
For this publication the decision is HOLD. C1, C2, C3, C4, and N1 are all EXPECTED / NOT EXECUTED. HOLD does not mean Pointer Events failed. It means the publication contains a documentary baseline and reproducible test design, but no observed browser-result set from which release acceptance could responsibly be inferred.
The minimum executable sequence is:
node --version
npm ci
npx playwright install chromium
npx playwright test --project=chromium --workers=1
sha256sum artifacts/manifest.json artifacts/*/evidence.jsonA run may move from HOLD to ACCEPT only if all required evidence is present and reconciles against the oracle written before execution.
Use the following final ledger rather than prose such as “drag seems fixed”:
Case | Required proof | Current evidence | Decision |
C1 | ordinary termination converges to idle | none | HOLD |
C2 | explicit loss alone reconciles drag state | none | HOLD |
C3 | cancellation + implicit release converges once | none | HOLD |
C4 | post-loss movement cannot mutate ended drag | none | HOLD |
N1 | seeded missing-loss cleanup is rejected | none | HOLD |
Runtime | Node 26.13.1 / PW 1.65.0 / Chromium 158.0.8127.18 | proposed pins only | HOLD |
The hardest release mistake here is accepting a browser fact as proof of an application fact. lostpointercapture establishes that capture was lost. It does not establish that your drag object, component state, temporary resources, or pointer ownership were reconciled. Conversely, pointercancel establishes a browser cancellation boundary; it does not prove your handler performed complete cleanup.
That separation is the durable operational rule.
Connect the practice to Refonte Learning
This workflow is a narrow frontend-engineering exercise in event lifecycles, state ownership, deterministic testing, and evidence-based acceptance. Those skills fit the broader scope of Refonte Learning’s Frontend Development program; its published curriculum does not explicitly describe this exact Pointer Events laboratory.
The current program page confirms a four-month period and a stated dedication of 10–12 hours per week. It lists HTML5/CSS3, JavaScript ES6+, responsive design, React component architecture, Redux, APIs/AJAX, Git, accessibility, and performance optimization among the competencies, and its curriculum includes JavaScript, React, advanced CSS, performance, accessibility, API integration, and a capstone project. It also states a basic understanding of programming concepts as a requirement, says HTML/CSS familiarity is a plus, and separately lists working toward a bachelor’s or higher-level degree as an admission prerequisite.
The page currently states that successful completion can yield a Training Certificate and Certificate of Internship and describes a potential internship structure. It does not explicitly name Pointer Events, Playwright, CDP, lostpointercapture, or pointercancel.
For engineers or learners who want structured practice in the broader frontend foundations behind this fixture (JavaScript event handling, React-oriented interface development, Git, accessibility, performance, and project work), the program page is the appropriate source to review for current terms and curriculum. The acceptance discipline remains the same whether learned independently or in a structured program: document the browser contract, define the application contract separately, execute controlled inputs, preserve raw evidence, and do not call a drag finished merely because pointer capture disappeared.
