Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .agents/skills/webjs/references/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,35 @@ Feature folders are primary, and the test kind is a subfolder inside the feature

Assert only on what the layer needs. A block that inspects only the HTTP response, the SSR HTML string, headers, or the importmap does NOT need a browser. Keep in the browser suite only blocks that genuinely need a DOM (live state via `page.evaluate`, hydration, client-router nav, slots, view transitions, streaming into the DOM, custom-element upgrade).

### A browser test that clicks a real link or submits a real form MUST cancel the default

web-test-runner aborts the whole SESSION, not one file, when the page navigates. So a test that clicks a real `<a href>` or submits a real `<form>` is a single point of failure for every browser test file you have: whenever the client router loses the race to intercept, the browser performs the real navigation and the run dies reporting `0 failed` and then exiting non-zero, which reads as an infrastructure blip rather than a test problem. Cancel the default so an interception gap fails ONE test on its own assertion.

Register the canceling listener on `window` in the BUBBLE phase. That is the last step of the propagation path, so it runs after the router's own document-level listeners, and `preventDefault()` still cancels the default action because that action runs only once dispatch completes.

**Never use the capture phase.** Capture sets `defaultPrevented` before the router ever sees the event, and the router returns immediately on that flag (the same guard that lets a component's `@click` opt out). Every guarded router test then passes while testing nothing. Capture is correct only when suppressing the router is the actual goal, for a test that exercises a menu or a drawer rather than navigation; say so in a comment when you do it, because it looks identical to the mistake. Do not reach for `stopPropagation` either, which hides the click from the router and turns the assertion into a tautology.

Cancel a form on its `submit` event, not on the submit control's `click`. The form's default action fires on submit, so canceling the click stops the form from ever submitting and the router never sees it.

Resolve the anchor from `e.composedPath()`, not `e.target.closest('a[href]')`. The listener is on `window`, so a click inside a shadow root (a `static shadow = true` component) arrives retargeted to the host, and `closest()` walks only light-tree ancestors and never finds the link, which fails open exactly where the router itself handles the case. A pure-fragment `href="#x"` link needs no guard at all, since it never navigates the page away.

One thing this cannot cover: the router assigns `location.href` when it degrades a soft navigation, and `preventDefault` does not cancel a script assignment. Listen for `webjs:navigation-fallback` on `document` and assert none fired; its `cause` is the diagnosis.

It is a few lines, so write it in your suite's setup and detach it in teardown:

```js
const onClick = (e) => {
for (const el of e.composedPath()) {
if (el instanceof HTMLAnchorElement && el.hasAttribute('href')) { e.preventDefault(); return; }
}
};
const onSubmit = (e) => e.preventDefault();
window.addEventListener('click', onClick); // bubble, never capture
window.addEventListener('submit', onSubmit);
```

(The WebJs framework repo keeps its own copy of exactly this in one shared module, `test/browser-nav-guard.js`, whose `installNavGuard()` returns `{ fallbacks, remove }`. That module is framework-repo infrastructure and is not part of a scaffolded app.)

## App runners (`webjs test`)

```sh
Expand Down
11 changes: 11 additions & 0 deletions packages/core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,17 @@ organised by feature: `signals/`, `rendering/`, `directives/`,
`browser/` subfolder when there are real-browser tests for it (run on
Chromium, Firefox, and WebKit via web-test-runner).

A browser test that clicks a real `<a href>` or submits a real `<form>`
MUST install the shared guard from `test/browser-nav-guard.js`
(`installNavGuard()`, returning `{ fallbacks, remove }`). web-test-runner
aborts the whole session when the page navigates, so an escaped click
takes down every browser file rather than failing one test. The guard
listens on `window` in the BUBBLE phase, which is load-bearing: capture
would set `defaultPrevented` before the router's document-level listener
runs, and `onClick` returns on that flag, so every guarded router test
would pass while testing nothing. Full rule in
[`references/testing.md`](../../.agents/skills/webjs/references/testing.md).

Cross-package tests that exercise core through the SSR pipeline
or scaffolds live at the repo root in `test/ssr/`,
`test/scaffolds/`, etc. See [`references/testing.md`](../../.agents/skills/webjs/references/testing.md).
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/routing/browser/fetch-revalidates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@
import { enableClientRouter, disableClientRouter } from '../../../src/router-client.js';

import { assert } from '../../../../../test/browser-assert.js';
import { installNavGuard } from '../../../../../test/browser-nav-guard.js';

/** Shared across the suites below; installed per test in setup(). */
let navGuard;
const tick = () => new Promise((r) => setTimeout(r, 0));
async function settle() { for (let i = 0; i < 4; i++) await tick(); }

suite('Client router: fetches revalidate instead of trusting the HTTP cache (#1131)', () => {
let container, origFetch, calls;

function setup() {
navGuard = installNavGuard();
enableClientRouter();
container = document.createElement('div');
container.innerHTML =
Expand All @@ -45,6 +50,7 @@ suite('Client router: fetches revalidate instead of trusting the HTTP cache (#11
};
}
function teardown() {
navGuard.remove();
window.fetch = origFetch;
container.remove();
disableClientRouter();
Expand Down
27 changes: 10 additions & 17 deletions packages/core/test/routing/browser/form-action-submit.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,26 +23,17 @@ import { render } from '../../../src/render-client.js';
import { enableClientRouter } from '../../../src/router-client.js';

import { assert } from '../../../../../test/browser-assert.js';
import { installNavGuard } from '../../../../../test/browser-nav-guard.js';
const tick = () => new Promise((r) => setTimeout(r, 20));

suite('Client router: bound form submissions (#1155)', () => {
// LAST-RESORT navigation backstop for a loaded runner: if the router under
// pressure fails a boundary scan and degrades to a full page load, the
// native form submission would navigate the WTR page away and kill the
// whole file ("Tests were interrupted..."). A BUBBLE-phase listener at the
// window level fires after the router's document-level handling had its
// chance (a capture listener here would fire FIRST and break every
// router-handled submission): when the event is still not
// default-prevented by the time it bubbles out to the window, cancel it so
// the ASSERTIONS fail visibly instead of the page dying. Never interferes
// with router-handled submissions (those are already default-prevented).
window.addEventListener(
'submit',
(e) => {
if (!e.defaultPrevented) e.preventDefault();
},
false,
);
// The navigation backstop this suite used to declare inline now lives in the
// shared guard (#1135), which is the same window-bubble listener with the
// same reasoning. It is installed per suite, not globally, so any NEW suite
// that clicks a real link or submits a real form has to opt in. See
// `test/browser-nav-guard.js` for why the phase is window bubble and never
// capture.
let navGuard;

let container, origFetch, calls;
// When a test redefines window.location.href (to detect a full-page reload),
Expand All @@ -52,6 +43,7 @@ suite('Client router: bound form submissions (#1155)', () => {

let bOpen, bClose;
function setup(responder) {
navGuard = installNavGuard();
enableClientRouter(); // idempotent
container = document.createElement('div');
// Bracket the container with a live keyed boundary pair (#1015): the swap
Expand All @@ -71,6 +63,7 @@ suite('Client router: bound form submissions (#1155)', () => {
};
}
function teardown() {
navGuard.remove();
window.fetch = origFetch;
if (restoreHref) { try { restoreHref(); } catch { /* ignore */ } restoreHref = null; }
container.remove();
Expand Down
6 changes: 6 additions & 0 deletions packages/core/test/routing/browser/frame-missing.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@
import { enableClientRouter } from '../../../src/router-client.js';

import { assert } from '../../../../../test/browser-assert.js';
import { installNavGuard } from '../../../../../test/browser-nav-guard.js';

/** Shared across the suites below; installed per test in setup(). */
let navGuard;
const tick = () => new Promise((r) => setTimeout(r, 0));

/** Wait for the router's async navigation pipeline to settle. */
Expand All @@ -38,6 +42,7 @@ suite('Client router: <webjs-frame> frame-missing contract (#251)', () => {
let container, origFetch, origWarn, warnings;

function setup() {
navGuard = installNavGuard();
enableClientRouter(); // idempotent; ensures the document listeners are attached
container = document.createElement('div');
// Sibling content that lives OUTSIDE the frame. If the document is
Expand Down Expand Up @@ -65,6 +70,7 @@ suite('Client router: <webjs-frame> frame-missing contract (#251)', () => {
console.warn = (...a) => { warnings.push(a.join(' ')); };
}
function teardown() {
navGuard.remove();
window.fetch = origFetch;
console.warn = origWarn;
container.remove();
Expand Down
10 changes: 8 additions & 2 deletions packages/core/test/routing/browser/frame-targeting.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
} from '../../../src/router-client.js';

import { assert } from '../../../../../test/browser-assert.js';
import { installNavGuard } from '../../../../../test/browser-nav-guard.js';

/** Shared across the suites below; installed per test in setup(). */
let navGuard;
const tick = () => new Promise((r) => setTimeout(r, 0));
async function settle() { await tick(); await tick(); await tick(); }

Expand All @@ -34,6 +38,7 @@ suite('Client router: <webjs-frame> external targeting (#252)', () => {
let container;

function setup() {
navGuard = installNavGuard();
enableClientRouter(); // idempotent
container = document.createElement('div');
container.innerHTML =
Expand All @@ -53,7 +58,7 @@ suite('Client router: <webjs-frame> external targeting (#252)', () => {
'</webjs-frame>';
document.body.appendChild(container);
}
function teardown() { container.remove(); }
function teardown() { navGuard.remove(); container.remove(); }

test('an external data-webjs-frame link (not nested) resolves the external frame id', () => {
setup();
Expand Down Expand Up @@ -138,6 +143,7 @@ suite('Client router: <webjs-frame> aria-busy lifecycle (#252)', () => {
let container, origFetch;

function setup() {
navGuard = installNavGuard();
enableClientRouter();
container = document.createElement('div');
container.innerHTML =
Expand All @@ -148,7 +154,7 @@ suite('Client router: <webjs-frame> aria-busy lifecycle (#252)', () => {
document.body.appendChild(container);
origFetch = window.fetch;
}
function teardown() { window.fetch = origFetch; container.remove(); }
function teardown() { navGuard.remove(); window.fetch = origFetch; container.remove(); }

test('aria-busy is true during the fetch and false after a successful swap, with start+finish events', async () => {
setup();
Expand Down
178 changes: 178 additions & 0 deletions packages/core/test/routing/browser/nav-guard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/**
* The shared browser-test navigation guard (#1135) does its job WITHOUT
* suppressing the router.
*
* This file exists because the guard's phase is a silent correctness trap. A
* capture-phase guard blocks navigation just as well, so the blocking tests
* below pass either way; what it also does is set `defaultPrevented` before the
* router's document-level bubble listener runs, and the router returns
* immediately on that flag. Every guarded router test would then pass while
* testing nothing at all. The "router still runs" tests are the regression test
* for exactly that, and are the reason this is a test rather than a comment.
*
* The two halves need DIFFERENT fixtures, which is the non-obvious part:
*
* - Blocking is proved with `data-no-router`, which the router deliberately
* ignores (`packages/core/src/router-client.js`). The guard is then the ONLY
* thing standing between the click and a real document load, so an unchanged
* `location` is attributable to the guard alone.
* - It CANNOT be proved on a plain link, because a successful soft navigation
* calls `history.pushState` and legitimately changes `location.pathname`. An
* "unchanged pathname" assertion there fails against a perfectly healthy
* router, which is exactly what it did when first written that way.
*/
import { html } from '../../../src/html.js';
import { render } from '../../../src/render-client.js';
import { enableClientRouter } from '../../../src/router-client.js';

import { assert } from '../../../../../test/browser-assert.js';
import { installNavGuard } from '../../../../../test/browser-nav-guard.js';

const tick = () => new Promise((r) => setTimeout(r, 0));

suite('Browser-test nav guard (#1135)', () => {
let container, origFetch, fetched, bOpen, bClose, guard, navigated, onNavigate, origHref;

function setup() {
enableClientRouter(); // idempotent; ensures the document listeners are attached
guard = installNavGuard();
origHref = location.href;
container = document.createElement('div');
// A live keyed boundary pair (#1015) so an intercepted nav swaps softly
// rather than degrading, which the guard could not block.
bOpen = document.createComment('wj:children:/:/');
bClose = document.createComment('/wj:children:/');
document.body.appendChild(bOpen);
document.body.appendChild(container);
document.body.appendChild(bClose);
navigated = [];
onNavigate = (e) => navigated.push(e.detail && e.detail.url);
document.addEventListener('webjs:navigate', onNavigate);
fetched = [];
origFetch = window.fetch;
window.fetch = (url) => {
fetched.push(String(url));
return Promise.resolve(new Response('<!--wj:children:/:/--><p>x</p><!--/wj:children:/-->', {
headers: { 'content-type': 'text/html', 'x-webjs-build': '' },
}));
};
}
function teardown() {
document.removeEventListener('webjs:navigate', onNavigate);
window.fetch = origFetch;
container.remove();
bOpen.remove();
bClose.remove();
guard.remove();
// A committed soft nav pushState'd a fake URL. Put the runner's own URL
// back so it does not leak into the next test or file.
history.replaceState(null, '', origHref);
}

/** Resolve when the router settles, so teardown never runs mid-swap. */
function awaitNavigation(timeoutMs = 2000) {
return new Promise((resolve) => {
let settled = false;
let timer;
const settle = () => {
if (settled) return;
settled = true;
clearTimeout(timer);
document.removeEventListener('webjs:navigate', settle);
document.removeEventListener('webjs:navigation-fallback', settle);
setTimeout(resolve, 0);
};
timer = setTimeout(settle, timeoutMs);
document.addEventListener('webjs:navigate', settle);
document.addEventListener('webjs:navigation-fallback', settle);
});
}

test('blocks the default activation of a link the router ignores', async () => {
setup();
const before = location.pathname;
try {
render(html`<a href="/nav-guard-unrouted" data-no-router>go</a>`, container);
container.querySelector('a').click();
await tick();
// The router returned early on `data-no-router`, so nothing but the guard
// stopped this click. Reaching this line at all is already half the
// proof: without the guard the runner page navigates and the whole
// session is torn down before any assertion runs.
assert.equal(fetched.length, 0, 'the router must ignore a data-no-router link');
assert.equal(location.pathname, before,
'the guard must block the default anchor activation');
} finally { teardown(); }
});

test('blocks the default submission of a form the router ignores', async () => {
setup();
const before = location.pathname;
try {
render(html`<form method="post" action="/nav-guard-unrouted-form" data-no-router><button type="submit">go</button></form>`, container);
container.querySelector('button').click();
await tick();
assert.equal(fetched.length, 0, 'the router must ignore a data-no-router form');
assert.equal(location.pathname, before,
'the guard must block the default form submission');
} finally { teardown(); }
});

test('blocks a link inside a shadow root, which retargets away from the anchor', async () => {
setup();
const before = location.pathname;
try {
// A `static shadow = true` component rendering a link. The listener is on
// `window`, so `e.target` here is the HOST, not the anchor, and a
// `target.closest('a[href]')` lookup finds nothing and fails open. The
// guard walks the composed path instead, like the router does.
const host = document.createElement('div');
container.appendChild(host);
host.attachShadow({ mode: 'open' }).innerHTML =
'<a href="/nav-guard-shadow" data-no-router>go</a>';
host.shadowRoot.querySelector('a').click();
await tick();
assert.equal(location.pathname, before,
'the guard must block an anchor inside a shadow root');
} finally { teardown(); }
});

test('does NOT suppress the router on a plain link (capture-phase regression)', async () => {
setup();
try {
render(html`<a href="/nav-guard-target">go</a>`, container);
const settled = awaitNavigation();
container.querySelector('a').click();
await settled;
// A capture-phase guard would trip the router's `defaultPrevented` early
// return, leaving `fetched` empty and committing no swap, while the
// blocking tests above still passed.
assert.ok(fetched.some((u) => u.includes('/nav-guard-target')),
'the guard must NOT suppress the router (it still fetches the target)');
assert.ok(navigated.some((u) => u && String(u).includes('/nav-guard-target')),
'the guard must NOT suppress the router (the soft swap still commits)');
assert.equal(guard.fallbacks.length, 0,
`the nav must be soft, not degraded (cause: ${guard.fallbacks.length ? guard.fallbacks[0].cause : 'none'})`);
} finally { teardown(); }
});

test('does NOT suppress the router on a plain form submission', async () => {
setup();
try {
render(html`<form method="post" action="/nav-guard-form"><button type="submit">go</button></form>`, container);
// Wait for a real settle, not a fixed number of macrotasks. A bare
// `tick()` let teardown remove the live boundary pair while the
// submission's swap was still running, which is the
// `no-shared-boundary` degradation, and a degradation assigns
// `location.href`, which no guard can cancel. Firefox lost that race
// every run; Chromium and WebKit happened to win it.
const settled = awaitNavigation();
container.querySelector('button').click();
await settled;
assert.ok(fetched.some((u) => u.includes('/nav-guard-form')),
'the guard must NOT suppress the router (it still posts the form)');
assert.equal(guard.fallbacks.length, 0,
`the submission must be soft, not degraded (cause: ${guard.fallbacks.length ? guard.fallbacks[0].cause : 'none'})`);
} finally { teardown(); }
});
});
Loading
Loading