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
10 changes: 7 additions & 3 deletions public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -1176,16 +1176,20 @@ function navigate() {
closeNav();

// Backward-compat redirect: #/traces/<hash> → #/tools/trace/<hash> (issue #944).
// Uses replaceState (not location.hash =) so this redirect doesn't add its
// own history entry, otherwise the back button gets stuck bouncing off it.
if (location.hash.startsWith('#/traces/')) {
location.hash = location.hash.replace('#/traces/', '#/tools/trace/');
history.replaceState(null, '', location.hash.replace('#/traces/', '#/tools/trace/'));
navigate();
return;
}

// Backward-compat redirect: #/roles → #/analytics?tab=roles (issue #1085).
// The Roles page was folded into the Analytics tab strip; old links and
// bookmarks must keep working.
// bookmarks must keep working. Uses replaceState for the same reason as above.
if (location.hash === '#/roles' || location.hash.startsWith('#/roles?') || location.hash.startsWith('#/roles/')) {
location.hash = '#/analytics?tab=roles';
history.replaceState(null, '', '#/analytics?tab=roles');
navigate();
return;
}

Expand Down
1 change: 1 addition & 0 deletions test-all.sh
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ node test-issue-1648-m2-emoji-scan.js
node test-issue-1648-m3-emoji-scan.js
node test-issue-1648-m6-final-sweep.js
node test-issue-1648-m6-lint-self.js
node test-issue-1883-redirect-history.js
node test-issue-1890-og-url.js
node test-traces.js
node test-live-multibyte-filter.js
Expand Down
149 changes: 149 additions & 0 deletions test-issue-1883-redirect-history.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
/* Regression tests for legacy-route history replacement (#1883). */
'use strict';

const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');

const appPath = process.env.APP_JS || path.join(__dirname, 'public', 'app.js');

function makeHistorySandbox() {
const entries = ['#/home'];
let index = 0;
let currentHash = entries[index];

const location = {};
Object.defineProperty(location, 'hash', {
get() { return currentHash; },
set(hash) {
if (hash === currentHash) return;
entries.splice(index + 1);
entries.push(hash);
index++;
currentHash = hash;
},
});

const history = {
get length() { return entries.length; },
replaceState(_state, _title, hash) {
entries[index] = hash;
currentHash = hash;
},
back() {
if (index === 0) return;
index--;
currentHash = entries[index];
},
};

const classList = { add() {}, remove() {}, toggle() {} };
const window = { addEventListener() {}, dispatchEvent() {} };
const document = {
readyState: 'complete',
body: { classList },
createElement: () => ({ id: '', textContent: '', innerHTML: '' }),
head: { appendChild() {} },
getElementById: () => null,
addEventListener() {},
querySelectorAll: () => [],
querySelector: () => null,
};
window.document = document;
window.location = location;
window.history = history;

const sandbox = {
window,
document,
location,
history,
console,
Date,
Infinity,
Math,
Array,
Object,
String,
Number,
JSON,
RegExp,
Error,
TypeError,
parseInt,
parseFloat,
isNaN,
isFinite,
encodeURIComponent,
decodeURIComponent,
setTimeout() {},
clearTimeout() {},
setInterval() {},
clearInterval() {},
fetch: () => Promise.resolve({ json: () => Promise.resolve({}) }),
performance: { now: () => Date.now() },
localStorage: { getItem: () => null, setItem() {}, removeItem() {} },
CustomEvent: class CustomEvent {},
Map,
Promise,
URLSearchParams,
addEventListener() {},
dispatchEvent() {},
requestAnimationFrame() {},
};

vm.createContext(sandbox);
vm.runInContext(fs.readFileSync(appPath, 'utf8'), sandbox, { filename: appPath });
return sandbox;
}

function isLegacyRedirect(hash) {
return hash.startsWith('#/traces/') || hash === '#/roles' ||
hash.startsWith('#/roles?') || hash.startsWith('#/roles/');
}

function assertRedirectReplacesHistory(legacyHash, expectedHash) {
const sandbox = makeHistorySandbox();
const productionNavigate = sandbox.navigate;
let recursiveNavigateCalls = 0;

// Keep the test focused on the redirect branch: the production redirect
// deliberately re-enters navigate(), while rendering the target route is
// covered by the browser suite.
sandbox.navigate = () => { recursiveNavigateCalls++; };

sandbox.location.hash = legacyHash;
const historyLengthBeforeRedirect = sandbox.history.length;
productionNavigate();

assert.strictEqual(sandbox.location.hash, expectedHash,
`${legacyHash} should redirect to ${expectedHash}`);
assert.strictEqual(sandbox.history.length, historyLengthBeforeRedirect,
`${legacyHash} must replace its history entry instead of adding one`);
assert.strictEqual(recursiveNavigateCalls, 1,
`${legacyHash} should render the replacement route immediately`);

sandbox.history.back();
// Browsers dispatch hashchange after Back. Re-run the production router only
// when Back exposed the legacy entry; the old location.hash implementation
// redirects forward again here and reproduces the loop.
if (isLegacyRedirect(sandbox.location.hash)) productionNavigate();

assert.strictEqual(sandbox.location.hash, '#/home',
`Back from ${expectedHash} should return to #/home without a redirect loop`);
}

const cases = [
['#/traces/a1b2c3d4', '#/tools/trace/a1b2c3d4'],
['#/roles', '#/analytics?tab=roles'],
['#/roles?from=bookmark', '#/analytics?tab=roles'],
['#/roles/legacy', '#/analytics?tab=roles'],
];

for (const [legacyHash, expectedHash] of cases) {
assertRedirectReplacesHistory(legacyHash, expectedHash);
console.log(` ✅ ${legacyHash} replaces history and Back returns to #/home`);
}

console.log(`\nredirect history: ${cases.length} passed, 0 failed`);
Loading