PR Checks / Frontend Typecheck + Tests (pull_request) Successful in 1m5s
PR Checks / Backend Smoke (pull_request) Successful in 9s
PR Checks / Build Backend (no push) (pull_request) Successful in 10s
PR Checks / Build Frontend (no push) (pull_request) Successful in 47s
PR Checks / Build Pipeline (no push) (pull_request) Successful in 10s
PR Checks / AI Code Review (Claude) (pull_request) Successful in 1m13s
The staging E2E gate has been red since #132 merged (run 1064, and 1066 after it): "a school reached from a location page is attributed to it, not to direct" expects `place`, receives `direct`. #132 fixed a real bug — `/schools/` had no case and fell through to `direct` — but the mechanism underneath it never worked. getNavigationSource read document.referrer, which the browser writes only when a *document* loads. Every internal navigation here is an App Router soft navigation: history.pushState, no new document, so document.referrer goes on naming whatever opened the tab for the whole session. Verified on staging: load /schools/brentwood, click a school, the URL becomes /school/… and document.referrer is still "". So `from` reported `direct` for essentially every in-app journey, not just the ones through the location layer — search, rankings, compare and detail were all being counted as "typed the URL". The unit suite passed throughout because every case set document.referrer directly, which only happens on a full page load. The fix is a module-level trail written by RouteTrail, a render-nothing client component in the root layout. Its lifetime is exactly right: it survives soft navigation, and it dies on a real document load — which is precisely when document.referrer becomes meaningful again, so the two cover each other with no overlap. Reading it skips entries equal to the current path rather than taking the second-to-last. That makes the answer independent of whether the layout effect or the page effect ran first — React orders those by tree position, which is not a contract worth resting a measurement on — and it gives the right answer both when the user returns to a page they came from and on a hard load of a school page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FuPUioHpxtaiDNagQvjxyM
159 lines
5.6 KiB
TypeScript
159 lines
5.6 KiB
TypeScript
import { getNavigationSource } from '@/lib/analytics';
|
|
|
|
/** jsdom's document.referrer is read-only; redefining it is the way in. */
|
|
function referrer(url: string) {
|
|
Object.defineProperty(document, 'referrer', { value: url, configurable: true });
|
|
}
|
|
|
|
const ORIGIN = 'http://localhost';
|
|
|
|
describe('getNavigationSource', () => {
|
|
afterEach(() => referrer(''));
|
|
|
|
it('attributes a visit from a location page to the place layer', () => {
|
|
/*
|
|
* The one this was added for.
|
|
*
|
|
* W2 published ~3,900 location pages whose entire purpose is to funnel
|
|
* search traffic onto school pages. Before this case existed they fell
|
|
* through to 'direct' — so the location layer's contribution was not
|
|
* merely missing from the funnel, it was being counted in the bucket you
|
|
* read as "typed the URL". The measurement that decides whether W2 worked
|
|
* was confidently reporting the wrong answer.
|
|
*/
|
|
referrer(`${ORIGIN}/schools/barnet`);
|
|
expect(getNavigationSource()).toBe('place');
|
|
});
|
|
|
|
it.each([
|
|
['/schools/authority/kent', 'authority'],
|
|
['/schools/near/sw11', 'outcode'],
|
|
['/schools/brentwood/primary', 'phase variant'],
|
|
])('covers %s (%s)', (path) => {
|
|
referrer(`${ORIGIN}${path}`);
|
|
expect(getNavigationSource()).toBe('place');
|
|
});
|
|
|
|
it('still calls a school page "detail", one character away', () => {
|
|
// /school/ and /schools/ differ by one letter and mean different things.
|
|
// A prefix test written in the wrong order silently merges them.
|
|
referrer(`${ORIGIN}/school/100010-brecknock-primary-school`);
|
|
expect(getNavigationSource()).toBe('detail');
|
|
});
|
|
|
|
it.each([
|
|
['/', 'search'],
|
|
['/rankings', 'rankings'],
|
|
['/compare?urns=1,2', 'compare'],
|
|
])('leaves %s attributed as %s', (path, expected) => {
|
|
referrer(`${ORIGIN}${path}`);
|
|
expect(getNavigationSource()).toBe(expected);
|
|
});
|
|
|
|
it('treats an external referrer as direct', () => {
|
|
// Umami records the real referrer on the pageview; this field is only
|
|
// about internal navigation.
|
|
referrer('https://www.google.com/search?q=schools+in+barnet');
|
|
expect(getNavigationSource()).toBe('direct');
|
|
});
|
|
|
|
it('treats no referrer as direct', () => {
|
|
referrer('');
|
|
expect(getNavigationSource()).toBe('direct');
|
|
});
|
|
});
|
|
|
|
/*
|
|
* The defect the existing suite could not see.
|
|
*
|
|
* Every test above sets document.referrer, which the browser writes only when
|
|
* a *document* loads. Every internal navigation in this app is an App Router
|
|
* soft navigation — history.pushState, no new document — so document.referrer
|
|
* keeps naming whatever opened the tab for the whole session. Verified on
|
|
* staging: /schools/brentwood → click a school → URL changes to /school/…
|
|
* and document.referrer is still "".
|
|
*
|
|
* So `from` reported 'direct' for essentially every in-app journey, and the
|
|
* suite passed because it only ever exercised the full-page-load path.
|
|
*/
|
|
function freshAnalytics() {
|
|
let mod!: typeof import('@/lib/analytics');
|
|
jest.isolateModules(() => {
|
|
mod = require('@/lib/analytics');
|
|
});
|
|
return mod;
|
|
}
|
|
|
|
function at(path: string) {
|
|
window.history.pushState({}, '', path);
|
|
}
|
|
|
|
describe('getNavigationSource across a soft navigation', () => {
|
|
afterEach(() => {
|
|
referrer('');
|
|
at('/');
|
|
});
|
|
|
|
it('attributes a school view to the place page the user actually came from', () => {
|
|
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
|
at('/schools/brentwood');
|
|
recordVisitedPath('/schools/brentwood');
|
|
|
|
at('/school/115429-brentwood-school');
|
|
recordVisitedPath('/school/115429-brentwood-school');
|
|
|
|
expect(source()).toBe('place');
|
|
});
|
|
|
|
it('does not depend on whether the new path was recorded first', () => {
|
|
// The trail is written by a layout-level effect and read by a page-level
|
|
// one. React orders those by tree position, which is not a contract worth
|
|
// resting a measurement on, so the answer must be the same either way.
|
|
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
|
recordVisitedPath('/rankings');
|
|
at('/school/115429-brentwood-school');
|
|
|
|
expect(source()).toBe('rankings');
|
|
});
|
|
|
|
it('names the previous page, not the current one, when both are schools', () => {
|
|
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
|
at('/school/100010-brecknock-primary-school');
|
|
recordVisitedPath('/school/100010-brecknock-primary-school');
|
|
|
|
at('/school/115429-brentwood-school');
|
|
recordVisitedPath('/school/115429-brentwood-school');
|
|
|
|
expect(source()).toBe('detail');
|
|
});
|
|
|
|
it('looks past a return visit to the page the user came back from', () => {
|
|
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
|
for (const p of ['/schools/brentwood', '/school/115429-brentwood-school',
|
|
'/schools/brentwood']) {
|
|
at(p);
|
|
recordVisitedPath(p);
|
|
}
|
|
expect(source()).toBe('detail');
|
|
});
|
|
|
|
it('falls back to the referrer on a real document load, where it is true', () => {
|
|
// A fresh module is a fresh document: nothing has been recorded, and
|
|
// document.referrer is meaningful again.
|
|
const { getNavigationSource: source } = freshAnalytics();
|
|
at('/school/115429-brentwood-school');
|
|
referrer(`${ORIGIN}/schools/barnet`);
|
|
|
|
expect(source()).toBe('place');
|
|
});
|
|
|
|
it('still reads an arrival from outside as direct', () => {
|
|
const { recordVisitedPath, getNavigationSource: source } = freshAnalytics();
|
|
at('/schools/brentwood');
|
|
recordVisitedPath('/schools/brentwood');
|
|
referrer('https://www.google.com/search?q=schools+in+brentwood');
|
|
|
|
expect(source()).toBe('direct');
|
|
});
|
|
});
|