diff --git a/src/index.html b/src/index.html
index 36a285015..036fd0f8f 100644
--- a/src/index.html
+++ b/src/index.html
@@ -16,6 +16,7 @@
+
diff --git a/src/main.js b/src/main.js
index ff069297f..4e8e91618 100644
--- a/src/main.js
+++ b/src/main.js
@@ -10,6 +10,7 @@ import {
parseRoute,
formatRoute,
normalizeTarget,
+ getRedirect,
} from './protocol-model.js';
import { $ } from './bling.js';
import { ProtocolRenderer } from './protocol_renderer.js';
@@ -108,7 +109,23 @@ export class App {
* @returns {string}
*/
formatRef(ref) {
- return formatRoute({ target: this._currentTarget, domain: ref });
+ const domainName = ref.split('.')[0];
+ const target = this._activeDomains && this._activeDomains.has(domainName) ? this._currentTarget : 'tot';
+ return formatRoute({ target, domain: ref });
+ }
+
+ /**
+ * Where a moved command now lives. Falls back to tot because experimental commands (and their
+ * redirect annotations) are stripped from stable.
+ * @param {string} domain
+ * @param {string} member
+ * @returns {{ targetDomain: string, targetMember: string } | null}
+ */
+ redirectFor(domain, member) {
+ return (
+ getRedirect(this._activeDomains, domain, member) ??
+ getRedirect(this._targetStore.tot, domain, member)
+ );
}
focusContent() {
@@ -117,14 +134,25 @@ export class App {
/**
* @param {string} route
+ * @param {boolean} [replace=false]
*/
- navigate(route) {
+ navigate(route, replace = false) {
const cleanRoute = formatRoute(parseRoute(route));
- if (window.location.hash !== cleanRoute) {
- window.location.hash = cleanRoute;
- } else {
+ if (cleanRoute === '#/') {
+ // Home is the bare path (not /#/). Setting location.hash = '' would leave a dangling '#'.
+ if (window.location.hash) history.pushState(null, '', window.location.pathname);
this._onRoute();
+ return;
}
+ if (window.location.hash === cleanRoute) {
+ this._onRoute();
+ return;
+ }
+ if (replace) {
+ window.location.replace(cleanRoute);
+ return;
+ }
+ window.location.hash = cleanRoute;
}
/**
@@ -146,7 +174,10 @@ export class App {
]);
this._prepareDatasets(totProto, v8Proto);
+ // ?q=foo permalink / OpenSearch entry. Read before routing, which cancels any search.
+ const query = new URLSearchParams(window.location.search).get('q');
this._onRoute();
+ if (query) this._search.search(query);
} catch (error) {
this._contentElement.textContent = '';
const message = error instanceof Error ? error.message : String(error);
@@ -247,7 +278,7 @@ export class App {
) {
return;
}
- if (href && (href.startsWith('#') || href.startsWith('?'))) {
+ if (href?.startsWith('#')) {
event.preventDefault();
this._closeDrawer();
this.navigate(href);
@@ -258,19 +289,12 @@ export class App {
}
_setupRoutingEvents() {
- window.addEventListener('hashchange', () => this._onRoute());
+ // popstate (not hashchange): it also fires when traversing between / and #/..., which differ in more than the fragment.
window.addEventListener('popstate', () => this._onRoute());
}
_onRoute() {
- let rawRoute = window.location.hash;
- if (window.location.search && !rawRoute) {
- rawRoute = window.location.search;
- } else if (!rawRoute || /^#(?:method|type|event)-/.test(rawRoute)) {
- rawRoute = window.location.pathname + (rawRoute || '');
- }
-
- const route = parseRoute(rawRoute);
+ const route = parseRoute(window.location.hash);
this._currentTarget = route.target;
if (this._targetSelector) {
this._targetSelector.value = route.target;
@@ -292,11 +316,17 @@ export class App {
return;
}
+ const redirect = member && this.redirectFor(domain, member);
+ if (redirect) {
+ // replace, not push: Back would land on the old URL and bounce forward again.
+ this.navigate(this.formatRef(`${redirect.targetDomain}.${redirect.targetMember}`), true);
+ return;
+ }
+
// In-page navigation: if domain is already rendered, scroll to member without DOM re-render
if (this._renderedDomain === domain && this._contentElement.firstChild && member) {
const canonicalTitle = `${domain}.${member}`;
document.title = `${canonicalTitle} - DevTools Protocol`;
- this._search.setDefaultValue(canonicalTitle);
const titleId = ProtocolRenderer.titleId(domain, member);
const elem = this._contentElement.querySelector('#' + titleId);
if (elem) {
@@ -319,8 +349,6 @@ export class App {
const canonicalTitle = member ? `${domain}.${member}` : domain;
document.title = `${canonicalTitle} - DevTools Protocol`;
- const searchDefault = member ? `${domain}.${member}` : domain;
- this._search.setDefaultValue(searchDefault);
this._search.cancelSearch();
this._contentElement.textContent = '';
@@ -373,7 +401,6 @@ export class App {
*/
_onNavigateHome(anchorId = null) {
document.title = 'DevTools Protocol Viewer';
- this._search.setDefaultValue('');
this._search.cancelSearch();
this._contentElement.textContent = '';
diff --git a/src/opensearch.xml b/src/opensearch.xml
new file mode 100644
index 000000000..454527e25
--- /dev/null
+++ b/src/opensearch.xml
@@ -0,0 +1,8 @@
+
+
+ DevTools Protocol
+ Search Chrome DevTools Protocol Viewer
+ UTF-8
+ https://chromedevtools.github.io/devtools-protocol/images/logo.png
+
+
diff --git a/src/protocol-model.js b/src/protocol-model.js
index 8b50afda1..951623ded 100644
--- a/src/protocol-model.js
+++ b/src/protocol-model.js
@@ -284,274 +284,60 @@ export function computeBackReferences(domains) {
return domains;
}
-const ROUTE_BASE_URL = 'https://cdp.internal';
-
-const hasURLPattern = typeof URLPattern !== 'undefined';
-
-const legacyAnchorPattern = hasURLPattern
- ? new URLPattern({ hash: ':prefix(method|type|event)-:member' })
- : null;
-
-const legacyPathPattern = hasURLPattern
- ? new URLPattern({
- pathname:
- '{/:repo(devtools-protocol|debugger-protocol-viewer)}?/:target(tot|v8|1-3|1-2|stable)/:domain{/}*',
- baseURL: ROUTE_BASE_URL,
- })
- : null;
-
-const hashTargetMemberPattern = hasURLPattern
- ? new URLPattern({
- hash: '#/:target(tot|v8|1-3|1-2|stable)/:domain.:member',
- })
- : null;
-const hashTargetDomainPattern = hasURLPattern
- ? new URLPattern({
- hash: '#/:target(tot|v8|1-3|1-2|stable)/:domain{/}*',
- })
- : null;
-const hashTargetOnlyPattern = hasURLPattern
- ? new URLPattern({ hash: '#/:target(tot|v8|1-3|1-2|stable){/}*' })
- : null;
-
-const hashMemberPattern = hasURLPattern ? new URLPattern({ hash: '#/:domain.:member' }) : null;
-const hashDomainPattern = hasURLPattern ? new URLPattern({ hash: '#/:domain{/}*' }) : null;
-const hashDirectPattern = hasURLPattern ? new URLPattern({ hash: '#:domain' }) : null;
-
-const queryMemberPattern = hasURLPattern ? new URLPattern({ search: '?:domain.:member' }) : null;
-const queryDomainPattern = hasURLPattern ? new URLPattern({ search: '?:domain' }) : null;
+/**
+ * Resolves where a redirected command now lives. Only commands carry `redirect` in the protocol.
+ * Matches a pluralized name in the destination (Page.deleteCookie -> Network.deleteCookies).
+ * @param {Map | undefined} domains
+ * @param {string} domainName
+ * @param {string} memberName
+ * @returns {{ targetDomain: string, targetMember: string } | null}
+ */
+export function getRedirect(domains, domainName, memberName) {
+ const command = domains?.get(domainName)?.commands?.find((c) => c.name === memberName);
+ if (!command?.redirect) return null;
+ const match = domains
+ ?.get(command.redirect)
+ ?.commands?.find((c) => c.name === memberName || c.name === `${memberName}s`);
+ return { targetDomain: command.redirect, targetMember: match?.name ?? memberName };
+}
/**
* Creates canonical RouteInfo, mapping lowercase landing anchors to section.
* @param {TargetKind} target
* @param {string|null} domain
* @param {string|null} member
- * @param {string|null} [section]
* @returns {RouteInfo}
*/
-function createRouteInfo(target, domain, member, section = null) {
+function createRouteInfo(target, domain, member) {
if (domain && !/^[A-Z][a-zA-Z0-9]*$/.test(domain)) {
const full = member ? `${domain}.${member}` : domain;
- const sec = full === 'http-endpoints' ? 'endpoints' : full;
- return { target, domain: null, member: null, section: sec };
+ const section = full === 'http-endpoints' ? 'endpoints' : full;
+ return { target, domain: null, member: null, section };
}
- /** @type {RouteInfo} */
- const res = { target, domain, member };
- if (section) {
- res.section = section;
- }
- return res;
+ return { target, domain, member };
}
/**
- * Parses any incoming route variant into a canonical RouteInfo object.
- * Uses standard URLPattern when available, with a regex/string fallback.
+ * Parses a hash route into a canonical RouteInfo object.
+ * Legacy static URLs (/tot/Page/#method-navigate, /1-3/..., /v8/...) are rewritten to hash
+ * routes by the generated domain stubs and 404.html before the app ever sees them.
*
- * Supported route structures:
- * - Modern hash routes: #/Page.navigate, #/Page, #/v8/Runtime.evaluate, #/stable/Network.getCookies
- * - Legacy paths: /tot/Page/#method-navigate, /1-3/Page/#method-navigate, /1-2/Network/
- * - Base-path prefixed: /devtools-protocol/tot/Page/#method-navigate, /debugger-protocol-viewer/tot/Page/#method-navigate
- * - Isolated legacy anchors: #method-navigate, #type-Node, #event-requestWillBeSent
- * - Query format: ?Page.navigate, ?Network
+ * Supported: #/Page.navigate, #/Page, #/v8/Runtime.evaluate, #/stable/Network, #/v8/, #faq, #/endpoints
*
- * @param {string|null} [routeString]
+ * @param {string|null} [hash]
* @returns {RouteInfo}
*/
-export function parseRoute(routeString) {
- if (!routeString || typeof routeString !== 'string') {
- return createRouteInfo('tot', null, null);
- }
-
- const trimmed = routeString.trim();
- if (!trimmed || trimmed === '#' || trimmed === '#/' || trimmed === '/') {
- return createRouteInfo('tot', null, null);
- }
-
- if (hasURLPattern && legacyAnchorPattern && legacyPathPattern) {
- const url =
- trimmed.startsWith('#') || trimmed.startsWith('?') || trimmed.startsWith('/')
- ? new URL(trimmed, ROUTE_BASE_URL)
- : new URL('/' + trimmed, ROUTE_BASE_URL);
-
- const legacyAnchorMatch = legacyAnchorPattern.exec(url);
- const legacyMember = legacyAnchorMatch?.hash.groups.member ?? null;
-
- const legacyPathMatch = legacyPathPattern.exec(url);
- if (
- legacyPathMatch?.pathname.groups.domain &&
- !legacyPathMatch.pathname.groups.domain.endsWith('.html')
- ) {
- return createRouteInfo(
- normalizeTarget(legacyPathMatch.pathname.groups.target),
- legacyPathMatch.pathname.groups.domain,
- legacyMember,
- );
- }
-
- if (legacyMember) {
- return createRouteInfo('tot', null, legacyMember);
- }
-
- const htm = hashTargetMemberPattern?.exec(url);
- if (htm?.hash.groups.domain && htm.hash.groups.member) {
- return createRouteInfo(
- normalizeTarget(htm.hash.groups.target),
- htm.hash.groups.domain,
- htm.hash.groups.member,
- );
- }
-
- const htd = hashTargetDomainPattern?.exec(url);
- if (htd?.hash.groups.domain) {
- return createRouteInfo(
- normalizeTarget(htd.hash.groups.target),
- htd.hash.groups.domain,
- null,
- );
- }
-
- const hto = hashTargetOnlyPattern?.exec(url);
- if (hto?.hash.groups.target) {
- return createRouteInfo(
- normalizeTarget(hto.hash.groups.target),
- null,
- null,
- );
- }
-
- const hm = hashMemberPattern?.exec(url);
- if (hm?.hash.groups.domain && hm.hash.groups.member) {
- return createRouteInfo(
- 'tot',
- hm.hash.groups.domain,
- hm.hash.groups.member,
- );
- }
-
- const hd = hashDomainPattern?.exec(url);
- if (hd?.hash.groups.domain) {
- return createRouteInfo(
- 'tot',
- hd.hash.groups.domain,
- null,
- );
- }
-
- const hdir = hashDirectPattern?.exec(url);
- if (hdir?.hash.groups.domain) {
- return createRouteInfo(
- 'tot',
- hdir.hash.groups.domain,
- null,
- );
- }
-
- const qm = queryMemberPattern?.exec(url);
- if (qm?.search.groups.domain && qm.search.groups.member) {
- return createRouteInfo(
- 'tot',
- qm.search.groups.domain,
- qm.search.groups.member,
- );
- }
-
- const qd = queryDomainPattern?.exec(url);
- if (qd?.search.groups.domain) {
- return createRouteInfo(
- 'tot',
- qd.search.groups.domain,
- null,
- );
- }
-
- return createRouteInfo('tot', null, null);
- }
-
- // Fallback string/regex parser for environments without URLPattern
- const isolatedLegacyMatch = trimmed.match(/^#(?:method|type|event)-([\w-]+)$/);
- if (isolatedLegacyMatch) {
- return createRouteInfo('tot', null, isolatedLegacyMatch[1]);
- }
-
- const hashIndex = trimmed.indexOf('#');
- let pathPart = '';
- let hashPart = '';
-
- if (hashIndex !== -1) {
- pathPart = trimmed.slice(0, hashIndex);
- hashPart = trimmed.slice(hashIndex + 1);
- } else if (trimmed.startsWith('?')) {
- hashPart = trimmed.slice(1);
- } else {
- pathPart = trimmed;
- }
-
- const legacyHashMatch = hashPart.match(/^(?:method|type|event)-([\w-]+)$/);
- const legacyMember = legacyHashMatch ? legacyHashMatch[1] : null;
-
- const pathSegments = pathPart
- .split('/')
- .map((s) => s.trim())
- .filter((s) => Boolean(s) && !s.endsWith('.html'));
-
- if (
- pathSegments.length > 0 &&
- (pathSegments[0] === 'devtools-protocol' || pathSegments[0] === 'debugger-protocol-viewer')
- ) {
- pathSegments.shift();
- }
-
- if (pathSegments.length > 0) {
- /** @type {TargetKind} */
- let target = 'tot';
- let domain = null;
-
- const targetIndex = pathSegments.findIndex((s) => TARGET_MAP.has(s.toLowerCase()));
- if (targetIndex !== -1) {
- target = normalizeTarget(pathSegments[targetIndex]);
- if (pathSegments.length > targetIndex + 1) {
- domain = pathSegments[targetIndex + 1];
- }
- } else {
- domain = pathSegments[0];
- }
-
- return createRouteInfo(target, domain, legacyMember);
- }
-
- let cleanHash = hashPart;
- if (cleanHash.startsWith('/')) cleanHash = cleanHash.slice(1);
- if (!cleanHash) return createRouteInfo('tot', null, null);
-
- /** @type {TargetKind} */
- let target = 'tot';
- let targetAndRest = cleanHash;
-
- const slashIndex = cleanHash.indexOf('/');
- if (slashIndex !== -1) {
- const potentialTarget = cleanHash.slice(0, slashIndex).toLowerCase();
- if (TARGET_MAP.has(potentialTarget)) {
- target = normalizeTarget(potentialTarget);
- targetAndRest = cleanHash.slice(slashIndex + 1);
- }
- } else if (TARGET_MAP.has(cleanHash.toLowerCase())) {
- target = normalizeTarget(cleanHash);
- targetAndRest = '';
- }
-
- targetAndRest = targetAndRest.replace(/\/+$/, '');
- if (!targetAndRest) return createRouteInfo(target, null, null);
-
- const dotIndex = targetAndRest.indexOf('.');
- if (dotIndex !== -1) {
- return createRouteInfo(
- target,
- targetAndRest.slice(0, dotIndex),
- targetAndRest.slice(dotIndex + 1) || null,
- );
- }
-
- return createRouteInfo(target, targetAndRest, null);
+export function parseRoute(hash) {
+ const path = (hash ?? '').trim().replace(/^#\/?/, '').replace(/\/+$/, '');
+ const [first = '', ...rest] = path.split('/');
+ const matchedTarget = TARGET_MAP.get(first.toLowerCase());
+ const target = matchedTarget ?? 'tot';
+ const ref = matchedTarget ? rest.join('/') : path;
+ if (!ref) return createRouteInfo(target, null, null);
+
+ const dot = ref.indexOf('.');
+ if (dot === -1) return createRouteInfo(target, ref, null);
+ return createRouteInfo(target, ref.slice(0, dot), ref.slice(dot + 1) || null);
}
/**
diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js
index b7f866732..c8c2a73b0 100644
--- a/src/protocol_renderer.js
+++ b/src/protocol_renderer.js
@@ -218,6 +218,21 @@ export class ProtocolRenderer {
*/
let renderEventOrMethodEntry = (method, container) =>
ProtocolRenderer.renderTableOfContentsEntry(domain.domain, method.name, container);
+ /**
+ * @param {ProtocolCommand} command
+ * @param {HTMLElement} container
+ */
+ let renderCommandEntry = (command, container) => {
+ const row = renderEventOrMethodEntry(command, container);
+ const redirect = window.app?.redirectFor(domain.domain, command.name);
+ if (redirect) {
+ const hint = document.createElement('span');
+ hint.className = 'toc-redirect-hint';
+ hint.textContent = `➔ ${redirect.targetDomain}`;
+ row.appendChild(hint);
+ }
+ return row;
+ };
/**
* @param {ProtocolType} type
* @param {HTMLElement} container
@@ -238,7 +253,7 @@ export class ProtocolRenderer {
'Methods',
'method',
domain.commands,
- renderEventOrMethodEntry,
+ renderCommandEntry,
toc,
isDomainExp,
);
@@ -338,6 +353,14 @@ export class ProtocolRenderer {
Boolean(domain.experimental),
),
);
+ const redirect = !isEvent && window.app?.redirectFor(domain.domain, method.name);
+ if (redirect) {
+ const p = document.createElement('p');
+ p.className = 'redirect-notice';
+ p.append('Moved to ', ProtocolRenderer.renderRef(`${redirect.targetDomain}.${redirect.targetMember}`), '.');
+ main.appendChild(p);
+ return main;
+ }
if (method.description) {
ProtocolRenderer.renderDescription(method.description, main);
}
diff --git a/src/search.js b/src/search.js
index b302ea94c..5f71c0c31 100644
--- a/src/search.js
+++ b/src/search.js
@@ -77,7 +77,7 @@ export class Search {
/**
* @param {Element} searchHeader
* @param {Element} resultsElement
- * @param {{ navigate?: (route: string) => void, formatRef?: (ref: string) => string, focusContent?: () => void }} [app]
+ * @param {{ navigate?: (route: string) => void, formatRef?: (ref: string) => string, focusContent?: () => void, redirectFor?: (domain: string, member: string) => { targetDomain: string, targetMember: string } | null }} [app]
*/
constructor(searchHeader, resultsElement, app) {
this._app = app;
@@ -90,7 +90,6 @@ export class Search {
this._items = [];
/** @type {Element|null} */
this._selectedElement = null;
- this._defaultValue = '';
this._searchInput.addEventListener('input', this._onInput.bind(this), false);
this._searchInput.addEventListener('keydown', this._onKeyDown.bind(this), false);
this._resultsElement = resultsElement;
@@ -115,7 +114,7 @@ export class Search {
!event.altKey &&
/\S/.test(event.key)
) {
- if (event.key !== '.') this._searchInput.value = '';
+ this._searchInput.value = '';
this._searchInput.focus();
}
});
@@ -150,8 +149,12 @@ export class Search {
const formatRef = this._app?.formatRef;
for (const domain of domains) {
for (const command of domain.commands || []) {
+ const redirect = this._app?.redirectFor?.(domain.domain, command.name);
+ const description = redirect
+ ? `Moved to ${redirect.targetDomain}.${redirect.targetMember}.`
+ : command.description;
this._items.push(
- new SearchItem(domain.domain, command.name, 'method', command.description, formatRef),
+ new SearchItem(domain.domain, command.name, 'method', description, formatRef),
);
}
for (const event of domain.events || []) {
@@ -167,25 +170,30 @@ export class Search {
}
}
+ /**
+ * Runs a search for `query` and opens the results dropdown.
+ * @param {string} query
+ */
+ search(query) {
+ this._searchInput.value = query;
+ this._searchInput.focus();
+ this._onInput();
+ }
+
cancelSearch() {
this._searchInput.blur();
/** @type {HTMLElement} */ (this._resultsElement).style.setProperty('display', 'none');
- this._searchInput.value = this._defaultValue;
+ this._searchInput.value = '';
+ setQueryParam('');
if (this._app?.focusContent) this._app.focusContent();
else if (typeof window !== 'undefined' && window.app?.focusContent) window.app.focusContent();
}
- /**
- * @param {string} value
- */
- setDefaultValue(value) {
- this._defaultValue = value;
- }
-
_onInput() {
this._selectedElement = null;
/** @type {HTMLElement} */ (this._resultsElement).style.setProperty('display', 'block');
let query = this._searchInput.value.trim();
+ setQueryParam(query);
let items = this._items;
let results = this._doSearch(items, query);
if (results.length === 0) {
@@ -338,6 +346,16 @@ export class Search {
}
}
+/**
+ * Mirrors the search query into `?q=` (or removes it) without adding history entries.
+ * @param {string} query
+ */
+function setQueryParam(query) {
+ const search = query ? `?q=${encodeURIComponent(query)}` : '';
+ if (search === location.search) return;
+ history.replaceState(null, '', location.pathname + search + location.hash);
+}
+
/**
* @param {SearchResult} searchResult
* @returns {Element}
diff --git a/src/style.css b/src/style.css
index 7074073c8..b90acd1d0 100644
--- a/src/style.css
+++ b/src/style.css
@@ -862,6 +862,16 @@ span.deprecated {
background-color: #e65100;
}
+.redirect-notice {
+ color: var(--color-subtle);
+}
+
+.toc-redirect-hint {
+ font-size: 11px;
+ margin-left: 4px;
+ color: var(--color-subtle);
+}
+
/* Domain-level badging: if the whole domain is experimental or deprecated,
badge the top card heading and suppress redundant per-item badges inside */
.domain-experimental span.experimental {
diff --git a/test/e2e.test.js b/test/e2e.test.js
index 14cefccd7..69d9971a7 100644
--- a/test/e2e.test.js
+++ b/test/e2e.test.js
@@ -194,10 +194,14 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => {
'--no-first-run',
'--no-sandbox',
'--disable-dev-shm-usage',
+ // Machine-wide extensions (e.g. Chrome Remote Desktop) spawn native helpers that outlive a
+ // SIGKILLed Chrome and keep its stderr pipe open, which keeps this process alive.
+ '--disable-extensions',
+ '--disable-component-extensions-with-background-pages',
`--user-data-dir=${tmpUserDataDir}`,
'about:blank',
],
- { stdio: ['ignore', 'pipe', 'pipe'] },
+ { stdio: ['ignore', 'ignore', 'pipe'] },
);
/** @type {WebSocket|null} */
@@ -778,6 +782,109 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => {
);
assert.strictEqual(legacyInspectorExists, true, 'Expected get-devtoolsinspector.html heading with title-link');
});
+
+ await t.test('13. Auto-redirect moved commands', async (t) => {
+ const cases = [
+ ['#/DOM.highlightNode', '#/Overlay.highlightNode', 'Overlay_highlightNode'],
+ ['#/Page.deleteCookie', '#/Network.deleteCookies', 'Network_deleteCookies'],
+ ];
+ for (const [from, to, headingId] of cases) {
+ await t.test(`${from} -> ${to}`, async () => {
+ await page.Page.navigate({ url: `${baseUrl}/${from}` });
+ const hash = await client.pollEvaluate(
+ 'window.location.hash',
+ (/** @type {any} */ h) => h === to,
+ sessionId,
+ );
+ assert.strictEqual(hash, to);
+ const headingExists = await client.pollEvaluate(
+ `Boolean(document.getElementById(${JSON.stringify(headingId)}))`,
+ (/** @type {any} */ val) => Boolean(val),
+ sessionId,
+ );
+ assert.strictEqual(headingExists, true, `Expected #${headingId} heading in DOM`);
+ });
+ }
+ });
+
+ await t.test('14. Search permalinks (?q=evaluate#/v8)', async () => {
+ await page.Page.navigate({ url: `${baseUrl}/?q=evaluate#/v8` });
+ const state = await client.pollEvaluate(
+ `({
+ value: document.getElementById('search')?.value,
+ target: document.getElementById('target-selector')?.value,
+ results: document.querySelectorAll('#sresults .search-item').length,
+ search: location.search,
+ })`,
+ (/** @type {any} */ s) => s?.value === 'evaluate' && s.results > 0,
+ sessionId,
+ );
+ assert.strictEqual(state.target, 'v8', 'Expected target from hash');
+ assert.strictEqual(state.search, '?q=evaluate', 'Expected ?q= to stay in the URL while results show');
+
+ // Typing updates the permalink
+ const typed = await client.evaluate(
+ `(() => {
+ const input = document.getElementById('search');
+ input.value = 'enable';
+ input.dispatchEvent(new Event('input'));
+ return location.search;
+ })()`,
+ sessionId,
+ );
+ assert.strictEqual(typed, '?q=enable', 'Expected typing to update ?q=');
+
+ // Picking a result ends the search: clears ?q= and empties the input
+ await client.evaluate(`document.querySelector('#sresults .search-item').click()`, sessionId);
+ const after = await client.pollEvaluate(
+ `({ search: location.search, hash: location.hash, value: document.getElementById('search').value })`,
+ (/** @type {any} */ s) => s?.hash.length > 2,
+ sessionId,
+ );
+ assert.strictEqual(after.search, '', 'Expected ?q= to be cleared after picking a result');
+ assert.strictEqual(after.value, '', 'Expected search input to be emptied after picking a result');
+ });
+
+ await t.test('15. Home is the bare path, with working Back/Forward', async () => {
+ await page.Page.navigate({ url: `${baseUrl}/#/Page` });
+ await client.pollEvaluate('document.title', (/** @type {any} */ v) => v?.startsWith('Page'), sessionId);
+
+ await client.evaluate(`document.querySelector('.brand-link').click()`, sessionId);
+ const home = await client.pollEvaluate(
+ '({ href: location.href, title: document.title })',
+ (/** @type {any} */ s) => s?.title === 'DevTools Protocol Viewer',
+ sessionId,
+ );
+ assert.strictEqual(home.href, `${baseUrl}/`, 'Expected home URL without a hash');
+
+ // Typing on home gives a clean ?q= permalink
+ const typed = await client.evaluate(
+ `(() => {
+ const input = document.getElementById('search');
+ input.value = 'cookie';
+ input.dispatchEvent(new Event('input'));
+ return location.href;
+ })()`,
+ sessionId,
+ );
+ assert.strictEqual(typed, `${baseUrl}/?q=cookie`);
+
+ await client.evaluate('history.back()', sessionId);
+ const back = await client.pollEvaluate(
+ 'document.title',
+ (/** @type {any} */ v) => v?.startsWith('Page'),
+ sessionId,
+ );
+ assert.ok(back.startsWith('Page'), 'Expected Back to return to #/Page');
+
+ await client.evaluate('history.forward()', sessionId);
+ const forward = await client.pollEvaluate(
+ 'document.title',
+ (/** @type {any} */ v) => v === 'DevTools Protocol Viewer',
+ sessionId,
+ );
+ assert.strictEqual(forward, 'DevTools Protocol Viewer', 'Expected Forward to return home');
+ });
} finally {
if (targetId && browserApi) {
try {
@@ -790,6 +897,7 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => {
} catch {}
}
chromeProcess.kill('SIGKILL');
+ chromeProcess.stderr.destroy();
server.close();
try {
fs.rmSync(tmpUserDataDir, { recursive: true, force: true });
diff --git a/test/protocol-model.test.js b/test/protocol-model.test.js
index d49d2f68a..ec89bbe75 100644
--- a/test/protocol-model.test.js
+++ b/test/protocol-model.test.js
@@ -7,6 +7,7 @@ import {
parseRoute,
formatRoute,
normalizeTarget,
+ getRedirect,
} from '../src/protocol-model.js';
/** @import { TestContext } from 'node:test' */
@@ -229,44 +230,6 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty
expected: { target: 'stable', domain: 'Network', member: 'getCookies' },
},
- // Composite legacy URLs
- {
- input: '/tot/Page/#method-navigate',
- expected: { target: 'tot', domain: 'Page', member: 'navigate' },
- },
- {
- input: '/1-3/Page/#method-navigate',
- expected: { target: 'stable', domain: 'Page', member: 'navigate' },
- },
- {
- input: '/1-2/Network/',
- expected: { target: 'stable', domain: 'Network', member: null },
- },
-
- // Isolated legacy anchors
- {
- input: '#method-navigate',
- expected: { target: 'tot', domain: null, member: 'navigate' },
- },
- {
- input: '#type-Node',
- expected: { target: 'tot', domain: null, member: 'Node' },
- },
- {
- input: '#event-requestWillBeSent',
- expected: { target: 'tot', domain: null, member: 'requestWillBeSent' },
- },
-
- // Query format fallbacks
- {
- input: '?Page.navigate',
- expected: { target: 'tot', domain: 'Page', member: 'navigate' },
- },
- {
- input: '?Network',
- expected: { target: 'tot', domain: 'Network', member: null },
- },
-
// Deep links and landing anchors
{
input: '#/endpoints',
@@ -314,45 +277,6 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty
input: '#',
expected: { target: 'tot', domain: null, member: null },
},
- {
- input: '/',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/index.html',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/tot/index.html',
- expected: { target: 'tot', domain: null, member: null },
- },
-
- // Base path prefix stripping (/devtools-protocol/ and /debugger-protocol-viewer/)
- {
- input: '/devtools-protocol/',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/devtools-protocol/index.html',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/devtools-protocol/tot/Page/#method-navigate',
- expected: { target: 'tot', domain: 'Page', member: 'navigate' },
- },
- {
- input: '/debugger-protocol-viewer/',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/debugger-protocol-viewer/index.html',
- expected: { target: 'tot', domain: null, member: null },
- },
- {
- input: '/debugger-protocol-viewer/tot/Page/#method-navigate',
- expected: { target: 'tot', domain: 'Page', member: 'navigate' },
- },
-
// Trailing slashes
{
input: '#/Page/',
@@ -423,3 +347,27 @@ test('formatRoute: canonical route formatting', () => {
// Default options
assert.equal(formatRoute(), '#/');
});
+
+test('getRedirect: resolves redirected commands', () => {
+ const domains = new Map(
+ [
+ { domain: 'DOM', commands: [{ name: 'highlightNode', redirect: 'Overlay' }, { name: 'getDocument' }] },
+ { domain: 'Page', commands: [{ name: 'deleteCookie', redirect: 'Network' }] },
+ { domain: 'Overlay', commands: [{ name: 'highlightNode' }] },
+ { domain: 'Network', commands: [{ name: 'deleteCookies' }] },
+ ].map((d) => [d.domain, d]),
+ );
+
+ assert.deepEqual(getRedirect(domains, 'DOM', 'highlightNode'), {
+ targetDomain: 'Overlay',
+ targetMember: 'highlightNode',
+ });
+ // Pluralized destination: deleteCookie -> deleteCookies
+ assert.deepEqual(getRedirect(domains, 'Page', 'deleteCookie'), {
+ targetDomain: 'Network',
+ targetMember: 'deleteCookies',
+ });
+ assert.equal(getRedirect(domains, 'DOM', 'getDocument'), null);
+ assert.equal(getRedirect(domains, 'NonExistent', 'foo'), null);
+ assert.equal(getRedirect(undefined, 'DOM', 'highlightNode'), null);
+});
diff --git a/test/stubs.test.js b/test/stubs.test.js
index ec0a927b1..9cfa10bc2 100644
--- a/test/stubs.test.js
+++ b/test/stubs.test.js
@@ -86,6 +86,7 @@ test('generateStubs: end-to-end stub generation in temporary directory', async (
'search.js',
'fuzzy_search.js',
'style.css',
+ 'opensearch.xml',
'favicons',
'images',
];