diff --git a/test-app/app/router.ts b/test-app/app/router.ts
index 4ced252..48efb5e 100644
--- a/test-app/app/router.ts
+++ b/test-app/app/router.ts
@@ -13,6 +13,7 @@ export default class Router extends EmberRouter {
Router.map(function () {
this.route('foo');
this.route('hash-target');
+ this.route('inert-demo-target');
this.route('docs', function () {
this.route('page');
this.route('other');
diff --git a/test-app/app/routes/inert-demo-target.ts b/test-app/app/routes/inert-demo-target.ts
new file mode 100644
index 0000000..66972d6
--- /dev/null
+++ b/test-app/app/routes/inert-demo-target.ts
@@ -0,0 +1,18 @@
+import Route from '@ember/routing/route';
+
+import type Transition from '@ember/routing/transition';
+
+/**
+ * Stands in for a route an app deliberately refuses to render — the pattern
+ * docs sites use so a demo's links look real while clicking one keeps the
+ * reader in place.
+ *
+ * Such a target isn't meaningfully visitable, and in a real app an aborted
+ * transition can make `visit()` reject outright (TransitionAborted), failing
+ * the crawl. `shouldVisit` is how an app excludes them.
+ */
+export default class InertDemoTargetRoute extends Route {
+ beforeModel(transition: Transition) {
+ transition.abort();
+ }
+}
diff --git a/test-app/app/templates/application.gts b/test-app/app/templates/application.gts
index 67b4bc2..c121394 100644
--- a/test-app/app/templates/application.gts
+++ b/test-app/app/templates/application.gts
@@ -11,6 +11,9 @@
{{! a page+hash link: currentURL() after the click has no #hash, so the
navigation assertion must compare without it }}
hash link
+ {{! a link the app deliberately refuses to navigate (its route aborts the
+ transition) — crawls must be able to skip it via shouldVisit }}
+ inert demo link
{{! non-SPA links: handled by the browser, not the router — visitAllLinks must skip them }}
new tab
diff --git a/test-app/tests/acceptance/visit-all-links-test.ts b/test-app/tests/acceptance/visit-all-links-test.ts
index f2961ff..4c7bd75 100644
--- a/test-app/tests/acceptance/visit-all-links-test.ts
+++ b/test-app/tests/acceptance/visit-all-links-test.ts
@@ -3,14 +3,29 @@ import { setupApplicationTest } from 'ember-qunit';
import { visitAllLinks } from '@universal-ember/test-support';
+/**
+ * The application template links /inert-demo-target, whose route aborts the
+ * transition on purpose (the pattern docs sites use so a demo's links look
+ * real while clicking one keeps the reader in place). It is not meaningfully
+ * visitable, so every crawl here skips it — which is what `shouldVisit` is
+ * for.
+ */
+const SKIP_INERT = {
+ shouldVisit: (url: string) => !url.startsWith('/inert-demo-target'),
+};
+
module('All Links', function (hooks) {
setupApplicationTest(hooks);
test('are visitable without error', async function (assert) {
- const size1 = await visitAllLinks();
- const size2 = await visitAllLinks((url) => {
- assert.ok(url);
- });
+ const size1 = await visitAllLinks(undefined, undefined, SKIP_INERT);
+ const size2 = await visitAllLinks(
+ (url) => {
+ assert.ok(url);
+ },
+ undefined,
+ SKIP_INERT,
+ );
assert.ok(size1 > 0, 'The test app has links');
assert.ok(size2 > 0, 'The test app has links');
@@ -29,13 +44,17 @@ module('All Links', function (hooks) {
// must be one the router can serve.
const visited: string[] = [];
- await visitAllLinks((url) => {
- visited.push(url);
+ await visitAllLinks(
+ (url) => {
+ visited.push(url);
- const isNonSPA = url.startsWith('mailto:') || url.endsWith('.html');
+ const isNonSPA = url.startsWith('mailto:') || url.endsWith('.html');
- assert.false(isNonSPA, `${url} is a route the router handles`);
- });
+ assert.false(isNonSPA, `${url} is a route the router handles`);
+ },
+ undefined,
+ SKIP_INERT,
+ );
assert.ok(visited.length > 0, 'the SPA links were still visited');
});
@@ -55,14 +74,21 @@ module('All Links', function (hooks) {
console.warn = (...args: unknown[]) => warnings.push(args.join(' '));
try {
- await visitAllLinks((url) => {
- visited.push(url);
- });
+ await visitAllLinks(
+ (url) => {
+ visited.push(url);
+ },
+ undefined,
+ SKIP_INERT,
+ );
} finally {
console.warn = originalWarn;
}
- assert.true(visited.includes('/docs/other'), `visited: ${visited.join(', ')}`);
+ assert.true(
+ visited.includes('/docs/other'),
+ `visited: ${visited.join(', ')}`,
+ );
const warning = warnings.find((w) => w.includes('Relative href "other"'));
@@ -81,18 +107,26 @@ module('All Links', function (hooks) {
const viaVisit: string[] = [];
const viaClick: string[] = [];
- await visitAllLinks((url) => {
- viaVisit.push(url);
- });
+ await visitAllLinks(
+ (url) => {
+ viaVisit.push(url);
+ },
+ undefined,
+ SKIP_INERT,
+ );
await visitAllLinks(
(url) => {
viaClick.push(url);
},
undefined,
- { mode: 'click' },
+ { ...SKIP_INERT, mode: 'click' },
);
- assert.deepEqual(viaClick.sort(), viaVisit.sort(), 'both modes crawl the same URLs');
+ assert.deepEqual(
+ viaClick.sort(),
+ viaVisit.sort(),
+ 'both modes crawl the same URLs',
+ );
});
test('each target is visited once', async function (assert) {
@@ -101,9 +135,13 @@ module('All Links', function (hooks) {
// every page, and pair-keying makes the crawl quadratic in app size.
const visited: string[] = [];
- await visitAllLinks((url) => {
- visited.push(url);
- });
+ await visitAllLinks(
+ (url) => {
+ visited.push(url);
+ },
+ undefined,
+ SKIP_INERT,
+ );
const paths = visited.map((url) => url.split('#')[0]);
@@ -113,4 +151,33 @@ module('All Links', function (hooks) {
`no target is visited twice: ${paths.join(', ')}`,
);
});
+
+ test('shouldVisit filters what the crawl reaches', async function (assert) {
+ const visited: string[] = [];
+
+ await visitAllLinks(
+ (url) => {
+ visited.push(url);
+ },
+ undefined,
+ SKIP_INERT,
+ );
+
+ assert.false(
+ visited.some((url) => url.startsWith('/inert-demo-target')),
+ 'the filtered-out target was never visited',
+ );
+ assert.true(visited.length > 0, 'everything else still was');
+
+ // Filtering everything out crawls nothing (and asserts nothing).
+ const size = await visitAllLinks(undefined, undefined, {
+ shouldVisit: () => false,
+ });
+
+ assert.strictEqual(
+ size,
+ 0,
+ 'a filter that rejects everything visits nothing',
+ );
+ });
});
diff --git a/test-support/src/routing/visit-all.ts b/test-support/src/routing/visit-all.ts
index 8aef06e..a4e325f 100644
--- a/test-support/src/routing/visit-all.ts
+++ b/test-support/src/routing/visit-all.ts
@@ -77,6 +77,24 @@ interface VisitAllLinksOptions {
* apps that need per-link click fidelity.
*/
mode?: 'visit' | 'click';
+
+ /**
+ * Decides whether a discovered target is crawled at all. Return `false` to
+ * skip it — the crawl neither navigates to it nor asserts on it, and its
+ * own links are not discovered through it.
+ *
+ * For links the app deliberately refuses to navigate — e.g. demo links
+ * whose route calls `transition.abort()`, or areas under test elsewhere:
+ *
+ * ```js
+ * await visitAllLinks(undefined, undefined, {
+ * shouldVisit: (url) => !url.startsWith('/demo-targets/'),
+ * });
+ * ```
+ *
+ * Receives the app-relative target (hash included, as authored).
+ */
+ shouldVisit?: (url: string) => boolean;
}
export async function visitAllLinks(
@@ -85,6 +103,7 @@ export async function visitAllLinks(
options?: VisitAllLinksOptions,
) {
const mode = options?.mode ?? 'visit';
+ const shouldVisit = options?.shouldVisit ?? (() => true);
/**
* app-relative target paths (without hash)
*/
@@ -136,6 +155,8 @@ export async function visitAllLinks(
if (visited.has(key)) continue;
+ if (!shouldVisit(toVisit.href)) continue;
+
const result = router.recognize(toVisit.href);
if (!result) {