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) {