From 69f38240a3d0d52e3bb83fb143e6de43026643d5 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 14 Sep 2026 17:51:36 -0700 Subject: [PATCH 01/15] feat: auto-redirect moved protocol commands and add url-based search with opensearch --- src/index.html | 1 + src/main.js | 24 ++++++++++ src/opensearch.xml | 8 ++++ src/protocol-model.js | 64 +++++++++++++++++++++++-- src/protocol_renderer.js | 46 ++++++++++++++++-- src/search.js | 34 +++++++++++-- src/style.css | 44 +++++++++++++++++ test/e2e.test.js | 61 +++++++++++++++++++++++ test/protocol-model.test.js | 96 +++++++++++++++++++++++++++++++++++++ test/stubs.test.js | 1 + types/protocol-schema.d.ts | 1 + types/types.d.ts | 2 + 12 files changed, 371 insertions(+), 11 deletions(-) create mode 100644 src/opensearch.xml 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..40691f7fc 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'; @@ -284,6 +285,14 @@ export class App { this._renderSidebar(this._activeDomains); } + // URL-based search query (?q=foo or #q=foo) + if (route.query) { + this._renderedDomain = null; + this._onNavigateHome(null); + this._search.search(route.query); + return; + } + const { domain, member, section } = route; if (!domain) { @@ -292,6 +301,21 @@ export class App { return; } + // Auto-redirect if command/event/type has moved to another domain + if (member) { + const redirect = getRedirect(this._activeDomains, domain, member); + if (redirect) { + this.navigate( + formatRoute({ + target: route.target, + domain: redirect.targetDomain, + member: redirect.targetMember, + }), + ); + 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}`; 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..56201f001 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -321,15 +321,54 @@ const hashDirectPattern = hasURLPattern ? new URLPattern({ hash: '#:domain' }) : const queryMemberPattern = hasURLPattern ? new URLPattern({ search: '?:domain.:member' }) : null; const queryDomainPattern = hasURLPattern ? new URLPattern({ search: '?:domain' }) : null; +/** + * Resolves whether a member in a domain redirects to another domain. + * @param {Map | Record | ProtocolDomain[] | null | undefined} domains + * @param {string|null|undefined} domainName + * @param {string|null|undefined} memberName + * @returns {{ targetDomain: string, targetMember: string } | null} + */ +export function getRedirect(domains, domainName, memberName) { + if (!domainName || !memberName || !domains) return null; + /** @type {ProtocolDomain | undefined} */ + let domain; + if (domains instanceof Map) { + domain = domains.get(domainName); + } else if (Array.isArray(domains)) { + domain = domains.find((d) => d.domain === domainName); + } else if (typeof domains === 'object') { + domain = domains[domainName]; + } + if (!domain) return null; + + const cmd = domain.commands?.find((c) => c.name === memberName); + if (cmd?.redirect) { + return { targetDomain: cmd.redirect, targetMember: memberName }; + } + const evt = domain.events?.find((e) => e.name === memberName); + if (evt?.redirect) { + return { targetDomain: evt.redirect, targetMember: memberName }; + } + const typ = domain.types?.find((t) => t.id === memberName); + if (typ?.redirect) { + return { targetDomain: typ.redirect, targetMember: memberName }; + } + return null; +} + /** * Creates canonical RouteInfo, mapping lowercase landing anchors to section. * @param {TargetKind} target * @param {string|null} domain * @param {string|null} member * @param {string|null} [section] + * @param {string|null} [query] * @returns {RouteInfo} */ -function createRouteInfo(target, domain, member, section = null) { +function createRouteInfo(target, domain, member, section = null, query = null) { + if (query) { + return { target, domain: null, member: null, query }; + } if (domain && !/^[A-Z][a-zA-Z0-9]*$/.test(domain)) { const full = member ? `${domain}.${member}` : domain; const sec = full === 'http-endpoints' ? 'endpoints' : full; @@ -349,6 +388,7 @@ function createRouteInfo(target, domain, member, section = null) { * * Supported route structures: * - Modern hash routes: #/Page.navigate, #/Page, #/v8/Runtime.evaluate, #/stable/Network.getCookies + * - URL search queries: ?q=foo, ?search=foo, #/v8?q=foo, #q=foo * - 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 @@ -367,6 +407,21 @@ export function parseRoute(routeString) { return createRouteInfo('tot', null, null); } + // Detect explicit URL-based search query: ?q=foo, ?search=foo, #q=foo, #/target?q=foo, etc. + const queryMatch = trimmed.match(/[?&#](?:q|search)=([^&#]*)/i); + if (queryMatch) { + const rawVal = queryMatch[1] ?? ''; + const query = decodeURIComponent(rawVal.replace(/\+/g, ' ')).trim(); + let target = /** @type {TargetKind} */ ('tot'); + const targetMatch = + trimmed.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || + trimmed.match(/[?&#]target=(tot|v8|1-3|1-2|stable)/i); + if (targetMatch) { + target = normalizeTarget(targetMatch[1]); + } + return createRouteInfo(target, null, null, null, query); + } + if (hasURLPattern && legacyAnchorPattern && legacyPathPattern) { const url = trimmed.startsWith('#') || trimmed.startsWith('?') || trimmed.startsWith('/') @@ -556,13 +611,16 @@ export function parseRoute(routeString) { /** * Formats canonical hash route from components. - * @param {{ target?: string|null, domain?: string|null, member?: string|null, section?: string|null }} [route] + * @param {{ target?: string|null, domain?: string|null, member?: string|null, section?: string|null, query?: string|null }} [route] * @returns {string} Canonical hash route, e.g. '#/Page.navigate' */ -export function formatRoute({ target = 'tot', domain = null, member = null, section = null } = {}) { +export function formatRoute({ target = 'tot', domain = null, member = null, section = null, query = null } = {}) { const normTarget = normalizeTarget(target); const targetPrefix = normTarget === 'tot' ? '' : `${normTarget}/`; + if (query) { + return `#/${targetPrefix}?q=${encodeURIComponent(query)}`; + } if (section) { return `#/${targetPrefix}${section}`; } diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index b7f866732..1d5e9764e 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -217,7 +217,12 @@ export class ProtocolRenderer { * @param {HTMLElement} container */ let renderEventOrMethodEntry = (method, container) => - ProtocolRenderer.renderTableOfContentsEntry(domain.domain, method.name, container); + ProtocolRenderer.renderTableOfContentsEntry( + domain.domain, + method.name, + container, + /** @type {any} */ (method).redirect ?? null, + ); /** * @param {ProtocolType} type * @param {HTMLElement} container @@ -306,15 +311,26 @@ export class ProtocolRenderer { * @param {string} domainName * @param {string} name * @param {HTMLElement} container + * @param {string|null} [redirectDomain] * @returns {HTMLElement} */ - static renderTableOfContentsEntry(domainName, name, container) { + static renderTableOfContentsEntry(domainName, name, container, redirectDomain = null) { const row = document.createElement('div'); row.className = 'toc-link'; + if (redirectDomain) row.classList.add('toc-redirect'); container.appendChild(row); - let id = `${domainName}.${name}`; - let link = ProtocolRenderer.renderRef(id); + const targetRef = redirectDomain ? `${redirectDomain}.${name}` : `${domainName}.${name}`; + let link = ProtocolRenderer.renderRef(targetRef); link.classList.add('monospace'); + if (redirectDomain) { + link.textContent = name; + const arrow = document.createElement('span'); + arrow.className = 'toc-redirect-hint'; + arrow.textContent = ` ➔ ${redirectDomain}`; + row.appendChild(link); + row.appendChild(arrow); + return row; + } row.appendChild(link); return row; } @@ -329,6 +345,7 @@ export class ProtocolRenderer { const main = document.createElement('div'); main.className = 'method'; if (method.deprecated) main.classList.add('deprecated-bg'); + if (/** @type {any} */ (method).redirect) main.classList.add('redirect-bg'); main.appendChild( ProtocolRenderer.renderTitle( domain.domain, @@ -338,6 +355,19 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); + const redirectDomain = /** @type {any} */ (method).redirect; + if (redirectDomain) { + const p = document.createElement('p'); + p.className = 'redirect-notice'; + p.textContent = 'This method has moved. Redirects to '; + const link = document.createElement('a'); + link.href = ProtocolRenderer.formatRef(`${redirectDomain}.${method.name}`); + link.textContent = `${redirectDomain}.${method.name}`; + p.appendChild(link); + p.append('.'); + main.appendChild(p); + return main; + } if (method.description) { ProtocolRenderer.renderDescription(method.description, main); } @@ -461,7 +491,13 @@ export class ProtocolRenderer { */ static applyMarks(item, element, isParentDomainExperimental = false) { if (!item) return; - if (item.experimental) { + if (/** @type {any} */ (item).redirect) { + const redSpan = document.createElement('span'); + redSpan.className = 'redirect-badge'; + redSpan.textContent = `redirect: ${/** @type {any} */ (item).redirect}`; + redSpan.title = `Redirects to ${/** @type {any} */ (item).redirect}`; + element.appendChild(redSpan); + } else if (item.experimental) { if (isParentDomainExperimental) { return; } diff --git a/src/search.js b/src/search.js index b302ea94c..e2d900657 100644 --- a/src/search.js +++ b/src/search.js @@ -41,15 +41,18 @@ class SearchItem { * @param {SearchItemKind} itemType * @param {string} [description] * @param {(ref: string) => string} [formatRef] + * @param {string|null} [redirectDomain] */ - constructor(domainName, domainEntry, itemType, description, formatRef) { + constructor(domainName, domainEntry, itemType, description, formatRef, redirectDomain = null) { this.domainName = domainName; this.domainEntry = domainEntry; this.type = itemType; this.description = description || ''; this.title = this.domainName + '.' + this.domainEntry; + this.redirectDomain = redirectDomain; const refFormatter = formatRef || (typeof window !== 'undefined' && window.app?.formatRef); - this.route = refFormatter ? refFormatter(this.title) : '#/' + this.title; + const targetRef = redirectDomain ? `${redirectDomain}.${this.domainEntry}` : this.title; + this.route = refFormatter ? refFormatter(targetRef) : '#/' + targetRef; } } @@ -151,7 +154,16 @@ export class Search { for (const domain of domains) { for (const command of domain.commands || []) { this._items.push( - new SearchItem(domain.domain, command.name, 'method', command.description, formatRef), + new SearchItem( + domain.domain, + command.name, + 'method', + command.redirect + ? `Redirects to ${command.redirect}.${command.name}. ${command.description || ''}` + : command.description, + formatRef, + command.redirect ?? null, + ), ); } for (const event of domain.events || []) { @@ -167,6 +179,16 @@ export class Search { } } + /** + * Performs an immediate search with the given query, opening 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'); @@ -371,6 +393,12 @@ function renderSearchResult(searchResult) { item.title.length, ), ); + if (item.redirectDomain) { + const redirectBadge = document.createElement('span'); + redirectBadge.className = 'search-redirect-badge'; + redirectBadge.textContent = ` ➔ ${item.redirectDomain}.${item.domainEntry}`; + p1.appendChild(redirectBadge); + } let p2 = document.createElement('div'); p2.className = 'search-item-description'; p2.textContent = item.description; diff --git a/src/style.css b/src/style.css index 7074073c8..bcdefed7b 100644 --- a/src/style.css +++ b/src/style.css @@ -862,6 +862,50 @@ span.deprecated { background-color: #e65100; } +span.redirect-badge { + font-size: 10px; + line-height: 12px; + text-transform: uppercase; + background-color: #f59e0b; + padding: 2px 5px; + cursor: help; + color: #ffffff; + vertical-align: middle; + font-weight: 600; + letter-spacing: 0.4px; + font-family: var(--font-sans); + margin-left: 8px; + user-select: none; + border-radius: 3px; + display: inline-block; +} + +.redirect-notice { + font-size: 14px; + margin: 8px 0; + color: var(--color-subtle); +} + +.redirect-notice a { + color: var(--color-primary); + font-weight: 600; +} + +.toc-redirect-hint { + font-size: 11px; + color: var(--color-subtle); + margin-left: 4px; + font-family: var(--font-sans); +} + +.search-redirect-badge { + font-size: 11px; + color: var(--color-primary); + font-weight: 500; + margin-left: 6px; + font-family: var(--font-sans); +} + /* 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..d15c57f02 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -778,6 +778,67 @@ 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 items (#/DOM.highlightNode -> #/Overlay.highlightNode)', async () => { + await page.Page.navigate({ url: `${baseUrl}/#/DOM.highlightNode` }); + + const finalHash = await client.pollEvaluate( + 'window.location.hash', + (/** @type {any} */ hash) => hash === '#/Overlay.highlightNode', + sessionId, + ); + assert.strictEqual(finalHash, '#/Overlay.highlightNode', 'Expected hash to auto-redirect to canonical domain'); + + const activeDomain = await client.pollEvaluate( + 'document.querySelector(".domain-link.active-link")?.getAttribute("data-domain")', + (/** @type {any} */ domain) => domain === 'Overlay', + sessionId, + ); + assert.strictEqual(activeDomain, 'Overlay', 'Expected Overlay domain to be active in sidebar'); + + const headingExists = await client.pollEvaluate( + 'Boolean(document.getElementById("Overlay_highlightNode"))', + (/** @type {any} */ val) => Boolean(val), + sessionId, + ); + assert.strictEqual(headingExists, true, 'Expected canonical #Overlay_highlightNode heading in DOM'); + }); + + await t.test('14. URL-based search query (?q=evaluate)', async () => { + await page.Page.navigate({ url: `${baseUrl}/?q=evaluate` }); + + const inputValue = await client.pollEvaluate( + 'document.getElementById("search")?.value', + (/** @type {any} */ val) => val === 'evaluate', + sessionId, + ); + assert.strictEqual(inputValue, 'evaluate', 'Expected search input to be populated with query'); + + const resultsDisplayed = await client.pollEvaluate( + 'document.getElementById("sresults")?.style.display', + (/** @type {any} */ display) => display === 'block', + sessionId, + ); + assert.strictEqual(resultsDisplayed, 'block', 'Expected search results dropdown to be visible'); + + const resultCount = await client.pollEvaluate( + 'document.querySelectorAll("#sresults .search-item").length', + (/** @type {any} */ count) => count > 0, + sessionId, + ); + assert.ok(resultCount > 0, 'Expected search results to be rendered'); + }); + + await t.test('15. OpenSearch autodiscovery link in index.html', async () => { + const openSearchLink = await client.evaluate( + 'document.querySelector("link[rel=\'search\'][type=\'application/opensearchdescription+xml\']")?.getAttribute("href")', + sessionId, + ); + assert.ok( + openSearchLink && openSearchLink.includes('opensearch.xml'), + `Expected opensearch.xml link tag in head, got ${openSearchLink}`, + ); + }); } finally { if (targetId && browserApi) { try { diff --git a/test/protocol-model.test.js b/test/protocol-model.test.js index d49d2f68a..1c0cfad41 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' */ @@ -378,6 +379,28 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty input: '#/stable/', expected: { target: 'stable', domain: null, member: null }, }, + + // URL-based search queries + { + input: '?q=evaluate', + expected: { target: 'tot', domain: null, member: null, query: 'evaluate' }, + }, + { + input: '?search=Runtime.evaluate', + expected: { target: 'tot', domain: null, member: null, query: 'Runtime.evaluate' }, + }, + { + input: '#q=Network.enable', + expected: { target: 'tot', domain: null, member: null, query: 'Network.enable' }, + }, + { + input: '#/v8?q=evaluate', + expected: { target: 'v8', domain: null, member: null, query: 'evaluate' }, + }, + { + input: '#/stable?q=getCookies', + expected: { target: 'stable', domain: null, member: null, query: 'getCookies' }, + }, ]; for (const { input, expected } of cases) { @@ -420,6 +443,79 @@ test('formatRoute: canonical route formatting', () => { assert.equal(formatRoute({ section: 'endpoints' }), '#/endpoints'); assert.equal(formatRoute({ target: 'v8', section: 'faq' }), '#/v8/faq'); + // Query routes + assert.equal(formatRoute({ query: 'evaluate' }), '#/?q=evaluate'); + assert.equal(formatRoute({ target: 'v8', query: 'evaluate' }), '#/v8/?q=evaluate'); + // Default options assert.equal(formatRoute(), '#/'); }); + +test('getRedirect: resolves redirected commands, events, and types', () => { + const domains = [ + { + domain: 'DOM', + commands: [ + { name: 'highlightNode', redirect: 'Overlay' }, + { name: 'getDocument' }, + ], + events: [ + { name: 'inspectNodeRequested' }, + ], + types: [], + }, + { + domain: 'Page', + commands: [ + { name: 'deleteCookie', redirect: 'Network' }, + ], + events: [ + { name: 'screencastFrame', redirect: 'HeadlessExperimental' }, + ], + types: [ + { id: 'Cookie', redirect: 'Network' }, + ], + }, + { + domain: 'Overlay', + commands: [ + { name: 'highlightNode' }, + ], + }, + ]; + + // Command redirect + assert.deepEqual(getRedirect(domains, 'DOM', 'highlightNode'), { + targetDomain: 'Overlay', + targetMember: 'highlightNode', + }); + + // Event redirect + assert.deepEqual(getRedirect(domains, 'Page', 'screencastFrame'), { + targetDomain: 'HeadlessExperimental', + targetMember: 'screencastFrame', + }); + + // Type redirect + assert.deepEqual(getRedirect(domains, 'Page', 'Cookie'), { + targetDomain: 'Network', + targetMember: 'Cookie', + }); + + // Non-redirected member + assert.equal(getRedirect(domains, 'DOM', 'getDocument'), null); + + // Non-existent domain or member + assert.equal(getRedirect(domains, 'NonExistent', 'foo'), null); + assert.equal(getRedirect(domains, 'DOM', 'nonExistent'), null); + assert.equal(getRedirect(null, 'DOM', 'highlightNode'), null); + assert.equal(getRedirect(domains, '', ''), null); + + // Supports Map input + const domainMap = new Map(domains.map((d) => [d.domain, d])); + assert.deepEqual(getRedirect(domainMap, 'DOM', 'highlightNode'), { + targetDomain: 'Overlay', + targetMember: 'highlightNode', + }); +}); + 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', ]; diff --git a/types/protocol-schema.d.ts b/types/protocol-schema.d.ts index aa61bd08f..6cda88791 100644 --- a/types/protocol-schema.d.ts +++ b/types/protocol-schema.d.ts @@ -13,6 +13,7 @@ export namespace Protocol { export interface ExtraInformation { deprecated?: boolean; experimental?: boolean; + redirect?: string; } export interface Domain extends ExtraInformation { diff --git a/types/types.d.ts b/types/types.d.ts index cb03899fa..7799e1796 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -36,6 +36,7 @@ export interface ProtocolType { enum?: string[]; items?: ProtocolParameter; referencedBy?: ProtocolBackReference[]; + redirect?: string; } export interface ProtocolDomain { @@ -72,4 +73,5 @@ export interface RouteInfo { domain: string | null; member: string | null; section?: string | null; + query?: string | null; } From 44896860726697c2d4cec328c47e5e2189fc7808 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Mon, 14 Sep 2026 17:56:35 -0700 Subject: [PATCH 02/15] fix: replace history on auto-redirect, harden query decoding, and refine redirect ui --- src/main.js | 10 ++++++++-- src/protocol-model.js | 11 +++++++++-- src/protocol_renderer.js | 16 +++++++++------- src/search.js | 22 ++++++++++++++++++++-- test/protocol-model.test.js | 16 ++++++++++++++++ types/types.d.ts | 2 ++ 6 files changed, 64 insertions(+), 13 deletions(-) diff --git a/src/main.js b/src/main.js index 40691f7fc..ff85be32f 100644 --- a/src/main.js +++ b/src/main.js @@ -118,11 +118,16 @@ 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; + if (replace) { + window.location.replace(cleanRoute); + } else { + window.location.hash = cleanRoute; + } } else { this._onRoute(); } @@ -311,6 +316,7 @@ export class App { domain: redirect.targetDomain, member: redirect.targetMember, }), + true, ); return; } diff --git a/src/protocol-model.js b/src/protocol-model.js index 56201f001..e71b33347 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -411,10 +411,17 @@ export function parseRoute(routeString) { const queryMatch = trimmed.match(/[?&#](?:q|search)=([^&#]*)/i); if (queryMatch) { const rawVal = queryMatch[1] ?? ''; - const query = decodeURIComponent(rawVal.replace(/\+/g, ' ')).trim(); + let query = rawVal.replace(/\+/g, ' ').trim(); + try { + query = decodeURIComponent(query); + } catch { + // Retain raw input when percent-decoding fails + } let target = /** @type {TargetKind} */ ('tot'); + const queryIdx = trimmed.search(/[?&#](?:q|search)=/i); + const routePrefix = queryIdx !== -1 ? trimmed.slice(0, queryIdx) : trimmed; const targetMatch = - trimmed.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || + routePrefix.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || trimmed.match(/[?&#]target=(tot|v8|1-3|1-2|stable)/i); if (targetMatch) { target = normalizeTarget(targetMatch[1]); diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 1d5e9764e..90445aa10 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -221,7 +221,7 @@ export class ProtocolRenderer { domain.domain, method.name, container, - /** @type {any} */ (method).redirect ?? null, + method.redirect ?? null, ); /** * @param {ProtocolType} type @@ -302,7 +302,9 @@ export class ProtocolRenderer { sectionWrapper.appendChild(section); for (let entry of entries) { let row = renderer(entry, section); - ProtocolRenderer.applyMarks(entry, row, isDomainExp); + if (!entry.redirect) { + ProtocolRenderer.applyMarks(entry, row, isDomainExp); + } } return section; } @@ -345,7 +347,7 @@ export class ProtocolRenderer { const main = document.createElement('div'); main.className = 'method'; if (method.deprecated) main.classList.add('deprecated-bg'); - if (/** @type {any} */ (method).redirect) main.classList.add('redirect-bg'); + if (method.redirect) main.classList.add('redirect-bg'); main.appendChild( ProtocolRenderer.renderTitle( domain.domain, @@ -355,7 +357,7 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); - const redirectDomain = /** @type {any} */ (method).redirect; + const redirectDomain = method.redirect; if (redirectDomain) { const p = document.createElement('p'); p.className = 'redirect-notice'; @@ -491,11 +493,11 @@ export class ProtocolRenderer { */ static applyMarks(item, element, isParentDomainExperimental = false) { if (!item) return; - if (/** @type {any} */ (item).redirect) { + if (item.redirect) { const redSpan = document.createElement('span'); redSpan.className = 'redirect-badge'; - redSpan.textContent = `redirect: ${/** @type {any} */ (item).redirect}`; - redSpan.title = `Redirects to ${/** @type {any} */ (item).redirect}`; + redSpan.textContent = `redirect: ${item.redirect}`; + redSpan.title = `Redirects to ${item.redirect}`; element.appendChild(redSpan); } else if (item.experimental) { if (isParentDomainExperimental) { diff --git a/src/search.js b/src/search.js index e2d900657..70ba1a61f 100644 --- a/src/search.js +++ b/src/search.js @@ -168,12 +168,30 @@ export class Search { } for (const event of domain.events || []) { this._items.push( - new SearchItem(domain.domain, event.name, 'event', event.description, formatRef), + new SearchItem( + domain.domain, + event.name, + 'event', + event.redirect + ? `Redirects to ${event.redirect}.${event.name}. ${event.description || ''}` + : event.description, + formatRef, + event.redirect ?? null, + ), ); } for (const type of domain.types || []) { this._items.push( - new SearchItem(domain.domain, type.id, 'type', type.description, formatRef), + new SearchItem( + domain.domain, + type.id, + 'type', + type.redirect + ? `Redirects to ${type.redirect}.${type.id}. ${type.description || ''}` + : type.description, + formatRef, + type.redirect ?? null, + ), ); } } diff --git a/test/protocol-model.test.js b/test/protocol-model.test.js index 1c0cfad41..32ca04e41 100644 --- a/test/protocol-model.test.js +++ b/test/protocol-model.test.js @@ -401,6 +401,22 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty input: '#/stable?q=getCookies', expected: { target: 'stable', domain: null, member: null, query: 'getCookies' }, }, + { + input: '?q=100%', + expected: { target: 'tot', domain: null, member: null, query: '100%' }, + }, + { + input: '?q=tot/DOM', + expected: { target: 'tot', domain: null, member: null, query: 'tot/DOM' }, + }, + { + input: '?q=Runtime/v8', + expected: { target: 'tot', domain: null, member: null, query: 'Runtime/v8' }, + }, + { + input: '#/v8?q=tot/DOM', + expected: { target: 'v8', domain: null, member: null, query: 'tot/DOM' }, + }, ]; for (const { input, expected } of cases) { diff --git a/types/types.d.ts b/types/types.d.ts index 7799e1796..1f890e970 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -23,6 +23,7 @@ export interface ProtocolParameter { items?: ProtocolParameter; enum?: string[]; properties?: ProtocolParameter[]; + redirect?: string; } /** Flattened domain type representation including runtime back-references */ @@ -48,6 +49,7 @@ export interface ProtocolDomain { types?: ProtocolType[]; commands?: ProtocolCommand[]; events?: ProtocolEvent[]; + redirect?: string; } export interface NormalizedProtocolDomain extends ProtocolDomain { From 4b4d61735202f646de76aabc824f7fb8338bfb68 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 11:27:07 -0700 Subject: [PATCH 03/15] fix: resolve pluralized redirect members and synchronize target across search queries --- src/main.js | 14 ++++--- src/protocol-model.js | 78 ++++++++++++++++++++++++++++++------- src/protocol_renderer.js | 38 ++++++++++++------ src/search.js | 35 +++++++++++------ test/e2e.test.js | 25 ++++++++++++ test/protocol-model.test.js | 35 ++++++++++++++++- 6 files changed, 182 insertions(+), 43 deletions(-) diff --git a/src/main.js b/src/main.js index ff85be32f..6179d55c6 100644 --- a/src/main.js +++ b/src/main.js @@ -226,9 +226,9 @@ export class App { if (this._targetSelector) { this._targetSelector.addEventListener('change', () => { const target = normalizeTarget(this._targetSelector.value); - const { domain, section } = parseRoute(window.location.hash); + const { domain, section, query } = parseRoute(window.location.hash || window.location.search); const validDomain = domain && this._targetStore[target]?.has(domain) ? domain : null; - this.navigate(formatRoute({ target, domain: validDomain, section })); + this.navigate(formatRoute({ target, domain: validDomain, section, query })); }); } } @@ -269,9 +269,13 @@ export class App { } _onRoute() { - let rawRoute = window.location.hash; - if (window.location.search && !rawRoute) { - rawRoute = window.location.search; + const hash = window.location.hash || ''; + const search = window.location.search || ''; + let rawRoute = hash; + if (search) { + rawRoute = !hash || hash === '#' || hash === '#/' + ? search + : `${hash}${hash.includes('?') ? '&' : '?'}${search.slice(1)}`; } else if (!rawRoute || /^#(?:method|type|event)-/.test(rawRoute)) { rawRoute = window.location.pathname + (rawRoute || ''); } diff --git a/src/protocol-model.js b/src/protocol-model.js index e71b33347..5193a71c9 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -321,6 +321,58 @@ const hashDirectPattern = hasURLPattern ? new URLPattern({ hash: '#:domain' }) : const queryMemberPattern = hasURLPattern ? new URLPattern({ search: '?:domain.:member' }) : null; const queryDomainPattern = hasURLPattern ? new URLPattern({ search: '?:domain' }) : null; +/** + * Looks up a domain by name in Map, Array, or Record. + * @param {Map | Record | ProtocolDomain[] | null | undefined} domains + * @param {string} domainName + * @returns {ProtocolDomain | undefined} + */ +function findDomain(domains, domainName) { + if (!domains) return undefined; + if (domains instanceof Map) return domains.get(domainName); + if (Array.isArray(domains)) return domains.find((d) => d.domain === domainName); + if (typeof domains === 'object') return domains[domainName]; + return undefined; +} + +/** + * Resolves canonical member name in target domain (handling plurals like deleteCookie -> deleteCookies). + * @param {Map | Record | ProtocolDomain[] | null | undefined} domains + * @param {string} targetDomainName + * @param {string} memberName + * @param {'command' | 'event' | 'type'} kind + * @returns {string} + */ +function resolveTargetMember(domains, targetDomainName, memberName, kind) { + const targetDomain = findDomain(domains, targetDomainName); + if (!targetDomain) return memberName; + + /** @type {Array<{ name?: string, id?: string }> | undefined} */ + let list; + if (kind === 'command') list = targetDomain.commands; + else if (kind === 'event') list = targetDomain.events; + else if (kind === 'type') list = targetDomain.types; + + if (!list || !list.length) return memberName; + + const exact = list.find((m) => (m.name || m.id) === memberName); + if (exact) return exact.name || exact.id || memberName; + + const plural = list.find((m) => (m.name || m.id) === memberName + 's'); + if (plural) return plural.name || plural.id || memberName; + + if (memberName.endsWith('s')) { + const singular = list.find((m) => (m.name || m.id) === memberName.slice(0, -1)); + if (singular) return singular.name || singular.id || memberName; + } + + const lower = memberName.toLowerCase(); + const ci = list.find((m) => (m.name || m.id)?.toLowerCase() === lower); + if (ci) return ci.name || ci.id || memberName; + + return memberName; +} + /** * Resolves whether a member in a domain redirects to another domain. * @param {Map | Record | ProtocolDomain[] | null | undefined} domains @@ -330,28 +382,23 @@ const queryDomainPattern = hasURLPattern ? new URLPattern({ search: '?:domain' } */ export function getRedirect(domains, domainName, memberName) { if (!domainName || !memberName || !domains) return null; - /** @type {ProtocolDomain | undefined} */ - let domain; - if (domains instanceof Map) { - domain = domains.get(domainName); - } else if (Array.isArray(domains)) { - domain = domains.find((d) => d.domain === domainName); - } else if (typeof domains === 'object') { - domain = domains[domainName]; - } + const domain = findDomain(domains, domainName); if (!domain) return null; const cmd = domain.commands?.find((c) => c.name === memberName); if (cmd?.redirect) { - return { targetDomain: cmd.redirect, targetMember: memberName }; + const targetMember = resolveTargetMember(domains, cmd.redirect, memberName, 'command'); + return { targetDomain: cmd.redirect, targetMember }; } const evt = domain.events?.find((e) => e.name === memberName); if (evt?.redirect) { - return { targetDomain: evt.redirect, targetMember: memberName }; + const targetMember = resolveTargetMember(domains, evt.redirect, memberName, 'event'); + return { targetDomain: evt.redirect, targetMember }; } const typ = domain.types?.find((t) => t.id === memberName); if (typ?.redirect) { - return { targetDomain: typ.redirect, targetMember: memberName }; + const targetMember = resolveTargetMember(domains, typ.redirect, memberName, 'type'); + return { targetDomain: typ.redirect, targetMember }; } return null; } @@ -409,6 +456,7 @@ export function parseRoute(routeString) { // Detect explicit URL-based search query: ?q=foo, ?search=foo, #q=foo, #/target?q=foo, etc. const queryMatch = trimmed.match(/[?&#](?:q|search)=([^&#]*)/i); + const standaloneTargetMatch = trimmed.match(/[?&#]target=(tot|v8|1-3|1-2|stable)(?:[&#]|$)/i); if (queryMatch) { const rawVal = queryMatch[1] ?? ''; let query = rawVal.replace(/\+/g, ' ').trim(); @@ -422,13 +470,17 @@ export function parseRoute(routeString) { const routePrefix = queryIdx !== -1 ? trimmed.slice(0, queryIdx) : trimmed; const targetMatch = routePrefix.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || - trimmed.match(/[?&#]target=(tot|v8|1-3|1-2|stable)/i); + standaloneTargetMatch; if (targetMatch) { target = normalizeTarget(targetMatch[1]); } return createRouteInfo(target, null, null, null, query); } + if (standaloneTargetMatch) { + return createRouteInfo(normalizeTarget(standaloneTargetMatch[1]), null, null); + } + if (hasURLPattern && legacyAnchorPattern && legacyPathPattern) { const url = trimmed.startsWith('#') || trimmed.startsWith('?') || trimmed.startsWith('/') diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 90445aa10..1d68f392e 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -3,6 +3,7 @@ */ /** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolType, ProtocolCommand, ProtocolEvent, ProtocolParameter, ProtocolBackReference } from '../types/types.d.ts' */ +import { getRedirect } from './protocol-model.js'; export class ProtocolRenderer { /** @@ -216,19 +217,30 @@ export class ProtocolRenderer { * @param {ProtocolCommand | ProtocolEvent} method * @param {HTMLElement} container */ - let renderEventOrMethodEntry = (method, container) => - ProtocolRenderer.renderTableOfContentsEntry( + let renderEventOrMethodEntry = (method, container) => { + const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.name); + return ProtocolRenderer.renderTableOfContentsEntry( domain.domain, method.name, container, - method.redirect ?? null, + redirect?.targetDomain ?? null, + redirect?.targetMember ?? null, ); + }; /** * @param {ProtocolType} type * @param {HTMLElement} container */ - let renderTypeEntry = (type, container) => - ProtocolRenderer.renderTableOfContentsEntry(domain.domain, type.id, container); + let renderTypeEntry = (type, container) => { + const redirect = getRedirect(window.app?._activeDomains, domain.domain, type.id); + return ProtocolRenderer.renderTableOfContentsEntry( + domain.domain, + type.id, + container, + redirect?.targetDomain ?? null, + redirect?.targetMember ?? null, + ); + }; if ( (domain.commands && domain.commands.length) || @@ -314,21 +326,23 @@ export class ProtocolRenderer { * @param {string} name * @param {HTMLElement} container * @param {string|null} [redirectDomain] + * @param {string|null} [redirectMember] * @returns {HTMLElement} */ - static renderTableOfContentsEntry(domainName, name, container, redirectDomain = null) { + static renderTableOfContentsEntry(domainName, name, container, redirectDomain = null, redirectMember = null) { const row = document.createElement('div'); row.className = 'toc-link'; if (redirectDomain) row.classList.add('toc-redirect'); container.appendChild(row); - const targetRef = redirectDomain ? `${redirectDomain}.${name}` : `${domainName}.${name}`; + const targetMember = redirectMember || name; + const targetRef = redirectDomain ? `${redirectDomain}.${targetMember}` : `${domainName}.${name}`; let link = ProtocolRenderer.renderRef(targetRef); link.classList.add('monospace'); if (redirectDomain) { link.textContent = name; const arrow = document.createElement('span'); arrow.className = 'toc-redirect-hint'; - arrow.textContent = ` ➔ ${redirectDomain}`; + arrow.textContent = ` ➔ ${redirectDomain}${targetMember !== name ? '.' + targetMember : ''}`; row.appendChild(link); row.appendChild(arrow); return row; @@ -357,14 +371,14 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); - const redirectDomain = method.redirect; - if (redirectDomain) { + const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.name); + if (redirect) { const p = document.createElement('p'); p.className = 'redirect-notice'; p.textContent = 'This method has moved. Redirects to '; const link = document.createElement('a'); - link.href = ProtocolRenderer.formatRef(`${redirectDomain}.${method.name}`); - link.textContent = `${redirectDomain}.${method.name}`; + link.href = ProtocolRenderer.formatRef(`${redirect.targetDomain}.${redirect.targetMember}`); + link.textContent = `${redirect.targetDomain}.${redirect.targetMember}`; p.appendChild(link); p.append('.'); main.appendChild(p); diff --git a/src/search.js b/src/search.js index 70ba1a61f..618ef268e 100644 --- a/src/search.js +++ b/src/search.js @@ -6,6 +6,7 @@ import { FuzzySearch } from './fuzzy_search.js'; import { ProtocolRenderer } from './protocol_renderer.js'; +import { getRedirect } from './protocol-model.js'; // Number of search results to render immediately. const SEARCH_RENDER_COUNT = 50; @@ -42,16 +43,19 @@ class SearchItem { * @param {string} [description] * @param {(ref: string) => string} [formatRef] * @param {string|null} [redirectDomain] + * @param {string|null} [redirectMember] */ - constructor(domainName, domainEntry, itemType, description, formatRef, redirectDomain = null) { + constructor(domainName, domainEntry, itemType, description, formatRef, redirectDomain = null, redirectMember = null) { this.domainName = domainName; this.domainEntry = domainEntry; this.type = itemType; this.description = description || ''; this.title = this.domainName + '.' + this.domainEntry; this.redirectDomain = redirectDomain; + this.redirectMember = redirectMember; const refFormatter = formatRef || (typeof window !== 'undefined' && window.app?.formatRef); - const targetRef = redirectDomain ? `${redirectDomain}.${this.domainEntry}` : this.title; + const targetMember = redirectMember || this.domainEntry; + const targetRef = redirectDomain ? `${redirectDomain}.${targetMember}` : this.title; this.route = refFormatter ? refFormatter(targetRef) : '#/' + targetRef; } } @@ -153,44 +157,50 @@ export class Search { const formatRef = this._app?.formatRef; for (const domain of domains) { for (const command of domain.commands || []) { + const redirect = getRedirect(domains, domain.domain, command.name); this._items.push( new SearchItem( domain.domain, command.name, 'method', - command.redirect - ? `Redirects to ${command.redirect}.${command.name}. ${command.description || ''}` + redirect + ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${command.description || ''}` : command.description, formatRef, - command.redirect ?? null, + redirect?.targetDomain ?? null, + redirect?.targetMember ?? null, ), ); } for (const event of domain.events || []) { + const redirect = getRedirect(domains, domain.domain, event.name); this._items.push( new SearchItem( domain.domain, event.name, 'event', - event.redirect - ? `Redirects to ${event.redirect}.${event.name}. ${event.description || ''}` + redirect + ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${event.description || ''}` : event.description, formatRef, - event.redirect ?? null, + redirect?.targetDomain ?? null, + redirect?.targetMember ?? null, ), ); } for (const type of domain.types || []) { + const redirect = getRedirect(domains, domain.domain, type.id); this._items.push( new SearchItem( domain.domain, type.id, 'type', - type.redirect - ? `Redirects to ${type.redirect}.${type.id}. ${type.description || ''}` + redirect + ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${type.description || ''}` : type.description, formatRef, - type.redirect ?? null, + redirect?.targetDomain ?? null, + redirect?.targetMember ?? null, ), ); } @@ -414,7 +424,8 @@ function renderSearchResult(searchResult) { if (item.redirectDomain) { const redirectBadge = document.createElement('span'); redirectBadge.className = 'search-redirect-badge'; - redirectBadge.textContent = ` ➔ ${item.redirectDomain}.${item.domainEntry}`; + const targetMember = item.redirectMember || item.domainEntry; + redirectBadge.textContent = ` ➔ ${item.redirectDomain}.${targetMember}`; p1.appendChild(redirectBadge); } let p2 = document.createElement('div'); diff --git a/test/e2e.test.js b/test/e2e.test.js index d15c57f02..b9941725b 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -802,6 +802,22 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { sessionId, ); assert.strictEqual(headingExists, true, 'Expected canonical #Overlay_highlightNode heading in DOM'); + + // Test renamed/plural redirect: Page.deleteCookie -> Network.deleteCookies + await page.Page.navigate({ url: `${baseUrl}/#/Page.deleteCookie` }); + const deleteCookiesHash = await client.pollEvaluate( + 'window.location.hash', + (/** @type {any} */ hash) => hash === '#/Network.deleteCookies', + sessionId, + ); + assert.strictEqual(deleteCookiesHash, '#/Network.deleteCookies', 'Expected Page.deleteCookie to redirect to plural #/Network.deleteCookies'); + + const deleteCookiesHeading = await client.pollEvaluate( + 'Boolean(document.getElementById("Network_deleteCookies"))', + (/** @type {any} */ val) => Boolean(val), + sessionId, + ); + assert.strictEqual(deleteCookiesHeading, true, 'Expected #Network_deleteCookies heading in DOM'); }); await t.test('14. URL-based search query (?q=evaluate)', async () => { @@ -827,6 +843,15 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { sessionId, ); assert.ok(resultCount > 0, 'Expected search results to be rendered'); + + // Target-scoped search: #/v8?q=evaluate synchronizes target-selector + await page.Page.navigate({ url: `${baseUrl}/#/v8?q=evaluate` }); + const v8Target = await client.pollEvaluate( + 'document.getElementById("target-selector")?.value', + (/** @type {any} */ val) => val === 'v8', + sessionId, + ); + assert.strictEqual(v8Target, 'v8', 'Expected target selector to switch to v8'); }); await t.test('15. OpenSearch autodiscovery link in index.html', async () => { diff --git a/test/protocol-model.test.js b/test/protocol-model.test.js index 32ca04e41..4bd36fe4e 100644 --- a/test/protocol-model.test.js +++ b/test/protocol-model.test.js @@ -417,6 +417,18 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty input: '#/v8?q=tot/DOM', expected: { target: 'v8', domain: null, member: null, query: 'tot/DOM' }, }, + { + input: '?target=v8', + expected: { target: 'v8', domain: null, member: null }, + }, + { + input: '?target=v8&q=evaluate', + expected: { target: 'v8', domain: null, member: null, query: 'evaluate' }, + }, + { + input: '?q=evaluate&target=stable', + expected: { target: 'stable', domain: null, member: null, query: 'evaluate' }, + }, ]; for (const { input, expected } of cases) { @@ -498,14 +510,35 @@ test('getRedirect: resolves redirected commands, events, and types', () => { { name: 'highlightNode' }, ], }, + { + domain: 'Network', + commands: [ + { name: 'deleteCookies' }, + ], + types: [ + { id: 'Cookie' }, + ], + }, + { + domain: 'HeadlessExperimental', + events: [ + { name: 'screencastFrame' }, + ], + }, ]; - // Command redirect + // Command redirect (exact match) assert.deepEqual(getRedirect(domains, 'DOM', 'highlightNode'), { targetDomain: 'Overlay', targetMember: 'highlightNode', }); + // Command redirect (pluralized match: deleteCookie -> deleteCookies) + assert.deepEqual(getRedirect(domains, 'Page', 'deleteCookie'), { + targetDomain: 'Network', + targetMember: 'deleteCookies', + }); + // Event redirect assert.deepEqual(getRedirect(domains, 'Page', 'screencastFrame'), { targetDomain: 'HeadlessExperimental', From d0ebf8d5dfb4eebd906a05676c0bbb522951e507 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 11:29:26 -0700 Subject: [PATCH 04/15] fix: isolate target extraction from search query in parseRoute --- src/protocol-model.js | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/protocol-model.js b/src/protocol-model.js index 5193a71c9..cba97e2ec 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -466,10 +466,9 @@ export function parseRoute(routeString) { // Retain raw input when percent-decoding fails } let target = /** @type {TargetKind} */ ('tot'); - const queryIdx = trimmed.search(/[?&#](?:q|search)=/i); - const routePrefix = queryIdx !== -1 ? trimmed.slice(0, queryIdx) : trimmed; + const stripped = trimmed.replace(queryMatch[0], ''); const targetMatch = - routePrefix.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || + stripped.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || standaloneTargetMatch; if (targetMatch) { target = normalizeTarget(targetMatch[1]); From 9ae258c17bdff5173a9c9441b9980900d126fd09 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 11:35:34 -0700 Subject: [PATCH 05/15] fix: route cross-target redirects to tot when destination domain is experimental --- src/main.js | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/src/main.js b/src/main.js index 6179d55c6..60a4df123 100644 --- a/src/main.js +++ b/src/main.js @@ -109,7 +109,9 @@ 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 }); } focusContent() { @@ -312,11 +314,14 @@ export class App { // Auto-redirect if command/event/type has moved to another domain if (member) { - const redirect = getRedirect(this._activeDomains, domain, member); + const redirect = + getRedirect(this._activeDomains, domain, member) || + getRedirect(this._targetStore.tot, domain, member); if (redirect) { + const target = this._activeDomains.has(redirect.targetDomain) ? route.target : 'tot'; this.navigate( formatRoute({ - target: route.target, + target, domain: redirect.targetDomain, member: redirect.targetMember, }), From 388fb75168e2c3bbc9b6e11cf2d42865560042b0 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 11:49:03 -0700 Subject: [PATCH 06/15] refactor: simplify routing with URLSearchParams, unify redirect resolution, and trim boilerplate --- src/protocol-model.js | 124 ++++++++++++++++----------------------- src/protocol_renderer.js | 29 +++------ src/search.js | 74 ++++++++--------------- src/style.css | 29 +++------ 4 files changed, 92 insertions(+), 164 deletions(-) diff --git a/src/protocol-model.js b/src/protocol-model.js index cba97e2ec..f9e1d8d57 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -3,7 +3,7 @@ * Browser-agnostic, zero-DOM ES module. */ -/** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolRoot, NormalizedProtocolRoot, TargetKind, RouteInfo } from '../types/types.d.ts' */ +/** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolCommand, ProtocolEvent, ProtocolType, ProtocolRoot, NormalizedProtocolRoot, TargetKind, RouteInfo } from '../types/types.d.ts' */ /** @type {Map} */ const TARGET_MAP = new Map([ @@ -324,15 +324,14 @@ const queryDomainPattern = hasURLPattern ? new URLPattern({ search: '?:domain' } /** * Looks up a domain by name in Map, Array, or Record. * @param {Map | Record | ProtocolDomain[] | null | undefined} domains - * @param {string} domainName + * @param {string} name * @returns {ProtocolDomain | undefined} */ -function findDomain(domains, domainName) { +function findDomain(domains, name) { if (!domains) return undefined; - if (domains instanceof Map) return domains.get(domainName); - if (Array.isArray(domains)) return domains.find((d) => d.domain === domainName); - if (typeof domains === 'object') return domains[domainName]; - return undefined; + if (domains instanceof Map) return domains.get(name); + if (Array.isArray(domains)) return domains.find((d) => d.domain === name); + return typeof domains === 'object' ? domains[name] : undefined; } /** @@ -345,32 +344,13 @@ function findDomain(domains, domainName) { */ function resolveTargetMember(domains, targetDomainName, memberName, kind) { const targetDomain = findDomain(domains, targetDomainName); - if (!targetDomain) return memberName; - - /** @type {Array<{ name?: string, id?: string }> | undefined} */ - let list; - if (kind === 'command') list = targetDomain.commands; - else if (kind === 'event') list = targetDomain.events; - else if (kind === 'type') list = targetDomain.types; - - if (!list || !list.length) return memberName; - - const exact = list.find((m) => (m.name || m.id) === memberName); - if (exact) return exact.name || exact.id || memberName; - - const plural = list.find((m) => (m.name || m.id) === memberName + 's'); - if (plural) return plural.name || plural.id || memberName; - - if (memberName.endsWith('s')) { - const singular = list.find((m) => (m.name || m.id) === memberName.slice(0, -1)); - if (singular) return singular.name || singular.id || memberName; - } - - const lower = memberName.toLowerCase(); - const ci = list.find((m) => (m.name || m.id)?.toLowerCase() === lower); - if (ci) return ci.name || ci.id || memberName; - - return memberName; + const list = targetDomain?.[kind === 'command' ? 'commands' : kind === 'event' ? 'events' : 'types']; + if (!list) return memberName; + const match = list.find((m) => { + const n = 'name' in m ? m.name : m.id; + return n === memberName || n === memberName + 's' || (memberName.endsWith('s') && n === memberName.slice(0, -1)); + }); + return (match ? ('name' in match ? match.name : match.id) : memberName) || memberName; } /** @@ -385,22 +365,22 @@ export function getRedirect(domains, domainName, memberName) { const domain = findDomain(domains, domainName); if (!domain) return null; - const cmd = domain.commands?.find((c) => c.name === memberName); - if (cmd?.redirect) { - const targetMember = resolveTargetMember(domains, cmd.redirect, memberName, 'command'); - return { targetDomain: cmd.redirect, targetMember }; - } - const evt = domain.events?.find((e) => e.name === memberName); - if (evt?.redirect) { - const targetMember = resolveTargetMember(domains, evt.redirect, memberName, 'event'); - return { targetDomain: evt.redirect, targetMember }; - } - const typ = domain.types?.find((t) => t.id === memberName); - if (typ?.redirect) { - const targetMember = resolveTargetMember(domains, typ.redirect, memberName, 'type'); - return { targetDomain: typ.redirect, targetMember }; - } - return null; + let kind = /** @type {'command' | 'event' | 'type'} */ ('command'); + /** @type {ProtocolCommand | ProtocolEvent | ProtocolType | undefined} */ + let item = domain.commands?.find((c) => c.name === memberName); + if (!item) { + kind = 'event'; + item = domain.events?.find((e) => e.name === memberName); + } + if (!item) { + kind = 'type'; + item = domain.types?.find((t) => t.id === memberName); + } + if (!item?.redirect) return null; + return { + targetDomain: item.redirect, + targetMember: resolveTargetMember(domains, item.redirect, memberName, kind), + }; } /** @@ -454,38 +434,32 @@ export function parseRoute(routeString) { return createRouteInfo('tot', null, null); } - // Detect explicit URL-based search query: ?q=foo, ?search=foo, #q=foo, #/target?q=foo, etc. - const queryMatch = trimmed.match(/[?&#](?:q|search)=([^&#]*)/i); - const standaloneTargetMatch = trimmed.match(/[?&#]target=(tot|v8|1-3|1-2|stable)(?:[&#]|$)/i); - if (queryMatch) { - const rawVal = queryMatch[1] ?? ''; - let query = rawVal.replace(/\+/g, ' ').trim(); - try { - query = decodeURIComponent(query); - } catch { - // Retain raw input when percent-decoding fails - } - let target = /** @type {TargetKind} */ ('tot'); - const stripped = trimmed.replace(queryMatch[0], ''); - const targetMatch = - stripped.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/?&#]|$)/i) || - standaloneTargetMatch; - if (targetMatch) { - target = normalizeTarget(targetMatch[1]); - } - return createRouteInfo(target, null, null, null, query); - } - - if (standaloneTargetMatch) { - return createRouteInfo(normalizeTarget(standaloneTargetMatch[1]), 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); + // Native URLSearchParams query parsing: ?q=foo, ?search=foo, #/target?q=foo, #q=foo + const hashQ = url.hash.indexOf('?'); + const hashParams = hashQ !== -1 ? new URLSearchParams(url.hash.slice(hashQ)) : (url.hash.startsWith('#q=') ? new URLSearchParams(url.hash.slice(1)) : null); + const query = url.searchParams.get('q') ?? url.searchParams.get('search') ?? hashParams?.get('q') ?? hashParams?.get('search'); + const targetParam = url.searchParams.get('target') ?? hashParams?.get('target'); + + if (query !== null && query !== undefined) { + let target = targetParam ? normalizeTarget(targetParam) : 'tot'; + if (!targetParam) { + const pathPart = hashQ !== -1 ? url.hash.slice(0, hashQ) : (url.pathname !== '/' ? url.pathname : ''); + const targetMatch = pathPart.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/#]|$)/i); + if (targetMatch) target = normalizeTarget(targetMatch[1]); + } + return createRouteInfo(target, null, null, null, query.trim()); + } + + if (targetParam && (trimmed.startsWith('?target=') || trimmed.startsWith('&target='))) { + return createRouteInfo(normalizeTarget(targetParam), null, null); + } + const legacyAnchorMatch = legacyAnchorPattern.exec(url); const legacyMember = legacyAnchorMatch?.hash.groups.member ?? null; diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 1d68f392e..66ba923e5 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -214,28 +214,15 @@ export class ProtocolRenderer { static renderTableOfContents(domain, container) { const isDomainExp = Boolean(domain.experimental); /** - * @param {ProtocolCommand | ProtocolEvent} method + * @param {ProtocolCommand | ProtocolEvent | ProtocolType} entity * @param {HTMLElement} container */ - let renderEventOrMethodEntry = (method, container) => { - const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.name); + const renderEntry = (entity, container) => { + const name = 'name' in entity ? entity.name : entity.id; + const redirect = getRedirect(window.app?._activeDomains, domain.domain, name); return ProtocolRenderer.renderTableOfContentsEntry( domain.domain, - method.name, - container, - redirect?.targetDomain ?? null, - redirect?.targetMember ?? null, - ); - }; - /** - * @param {ProtocolType} type - * @param {HTMLElement} container - */ - let renderTypeEntry = (type, container) => { - const redirect = getRedirect(window.app?._activeDomains, domain.domain, type.id); - return ProtocolRenderer.renderTableOfContentsEntry( - domain.domain, - type.id, + name, container, redirect?.targetDomain ?? null, redirect?.targetMember ?? null, @@ -255,7 +242,7 @@ export class ProtocolRenderer { 'Methods', 'method', domain.commands, - renderEventOrMethodEntry, + renderEntry, toc, isDomainExp, ); @@ -264,7 +251,7 @@ export class ProtocolRenderer { 'Events', 'event', domain.events, - renderEventOrMethodEntry, + renderEntry, toc, isDomainExp, ); @@ -273,7 +260,7 @@ export class ProtocolRenderer { 'Types', 'type', domain.types, - renderTypeEntry, + renderEntry, toc, isDomainExp, ); diff --git a/src/search.js b/src/search.js index 618ef268e..79ee656d0 100644 --- a/src/search.js +++ b/src/search.js @@ -2,7 +2,7 @@ * @fileoverview Fuzzy search controller and UI rendering for protocol entities. */ -/** @import { ProtocolDomain } from '../types/types.d.ts' */ +/** @import { ProtocolDomain, ProtocolCommand, ProtocolEvent, ProtocolType } from '../types/types.d.ts' */ import { FuzzySearch } from './fuzzy_search.js'; import { ProtocolRenderer } from './protocol_renderer.js'; @@ -156,53 +156,31 @@ export class Search { this._items = []; const formatRef = this._app?.formatRef; for (const domain of domains) { - for (const command of domain.commands || []) { - const redirect = getRedirect(domains, domain.domain, command.name); - this._items.push( - new SearchItem( - domain.domain, - command.name, - 'method', - redirect - ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${command.description || ''}` - : command.description, - formatRef, - redirect?.targetDomain ?? null, - redirect?.targetMember ?? null, - ), - ); - } - for (const event of domain.events || []) { - const redirect = getRedirect(domains, domain.domain, event.name); - this._items.push( - new SearchItem( - domain.domain, - event.name, - 'event', - redirect - ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${event.description || ''}` - : event.description, - formatRef, - redirect?.targetDomain ?? null, - redirect?.targetMember ?? null, - ), - ); - } - for (const type of domain.types || []) { - const redirect = getRedirect(domains, domain.domain, type.id); - this._items.push( - new SearchItem( - domain.domain, - type.id, - 'type', - redirect - ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${type.description || ''}` - : type.description, - formatRef, - redirect?.targetDomain ?? null, - redirect?.targetMember ?? null, - ), - ); + /** @type {Array<[ProtocolCommand[] | ProtocolEvent[] | ProtocolType[] | undefined, SearchItemKind]>} */ + const lists = [ + [domain.commands, 'method'], + [domain.events, 'event'], + [domain.types, 'type'], + ]; + for (const [list, type] of lists) { + for (const entity of list || []) { + const name = 'name' in entity ? entity.name : entity.id; + const redirect = getRedirect(domains, domain.domain, name); + const desc = redirect + ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${entity.description || ''}` + : entity.description; + this._items.push( + new SearchItem( + domain.domain, + name, + type, + desc, + formatRef, + redirect?.targetDomain, + redirect?.targetMember, + ), + ); + } } } } diff --git a/src/style.css b/src/style.css index bcdefed7b..e5383a097 100644 --- a/src/style.css +++ b/src/style.css @@ -840,7 +840,8 @@ tr:hover td { /* Badges & Entity Icons */ span.experimental, -span.deprecated { +span.deprecated, +span.redirect-badge { font-size: 10px; line-height: 12px; text-transform: uppercase; @@ -863,21 +864,7 @@ span.deprecated { } span.redirect-badge { - font-size: 10px; - line-height: 12px; - text-transform: uppercase; background-color: #f59e0b; - padding: 2px 5px; - cursor: help; - color: #ffffff; - vertical-align: middle; - font-weight: 600; - letter-spacing: 0.4px; - font-family: var(--font-sans); - margin-left: 8px; - user-select: none; - border-radius: 3px; - display: inline-block; } .redirect-notice { @@ -891,19 +878,21 @@ span.redirect-badge { font-weight: 600; } -.toc-redirect-hint { +.toc-redirect-hint, +.search-redirect-badge { font-size: 11px; - color: var(--color-subtle); - margin-left: 4px; font-family: var(--font-sans); + margin-left: 4px; +} + +.toc-redirect-hint { + color: var(--color-subtle); } .search-redirect-badge { - font-size: 11px; color: var(--color-primary); font-weight: 500; margin-left: 6px; - font-family: var(--font-sans); } /* Domain-level badging: if the whole domain is experimental or deprecated, From 86c0fdfaaebb83803e957c7a69cc93403d8f526d Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 11:49:33 -0700 Subject: [PATCH 07/15] style: streamline redirect notice element assembly --- src/protocol_renderer.js | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 66ba923e5..1d3f6970c 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -362,12 +362,11 @@ export class ProtocolRenderer { if (redirect) { const p = document.createElement('p'); p.className = 'redirect-notice'; - p.textContent = 'This method has moved. Redirects to '; + const dest = `${redirect.targetDomain}.${redirect.targetMember}`; const link = document.createElement('a'); - link.href = ProtocolRenderer.formatRef(`${redirect.targetDomain}.${redirect.targetMember}`); - link.textContent = `${redirect.targetDomain}.${redirect.targetMember}`; - p.appendChild(link); - p.append('.'); + link.href = ProtocolRenderer.formatRef(dest); + link.textContent = dest; + p.append('This method has moved. Redirects to ', link, '.'); main.appendChild(p); return main; } From 67cfb177e922dbb6a284196304d9e7c4729bda03 Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 12:03:03 -0700 Subject: [PATCH 08/15] fix: address red-team findings on query parsing, popstate, and type redirects --- src/main.js | 42 +++++++++++++++++------------------ src/protocol-model.js | 48 +++++++++++++++++++++------------------- src/protocol_renderer.js | 13 +++++++++++ 3 files changed, 58 insertions(+), 45 deletions(-) diff --git a/src/main.js b/src/main.js index 60a4df123..604e513f8 100644 --- a/src/main.js +++ b/src/main.js @@ -21,27 +21,6 @@ const PROTOCOL_URLS = { v8: new URL('./data/v8.json', import.meta.url).href, }; -document.addEventListener('DOMContentLoaded', () => { - const sidebarElement = $('#sidebar'); - const domainListElement = $('#domain-list'); - const contentElement = $('#content'); - const searchElement = $('#search'); - const searchResultsElement = $('#sresults'); - const targetSelector = /** @type {HTMLSelectElement} */ ($('#target-selector')); - const drawerToggle = $('#drawer-toggle'); - const drawerBackdrop = $('#drawer-backdrop'); - - window.app = new App({ - sidebarElement, - domainListElement, - contentElement, - searchElement, - searchResultsElement, - targetSelector, - drawerToggle, - drawerBackdrop, - }); -}); /** * @typedef {Object} AppElements @@ -267,7 +246,6 @@ export class App { _setupRoutingEvents() { window.addEventListener('hashchange', () => this._onRoute()); - window.addEventListener('popstate', () => this._onRoute()); } _onRoute() { @@ -493,6 +471,26 @@ export class App { } } +const sidebarElement = $('#sidebar'); +const domainListElement = $('#domain-list'); +const contentElement = $('#content'); +const searchElement = $('#search'); +const searchResultsElement = $('#sresults'); +const targetSelector = /** @type {HTMLSelectElement} */ ($('#target-selector')); +const drawerToggle = $('#drawer-toggle'); +const drawerBackdrop = $('#drawer-backdrop'); + +window.app = new App({ + sidebarElement, + domainListElement, + contentElement, + searchElement, + searchResultsElement, + targetSelector, + drawerToggle, + drawerBackdrop, +}); + /** * @param {string} error * @returns {Element} diff --git a/src/protocol-model.js b/src/protocol-model.js index f9e1d8d57..4785cc753 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -434,31 +434,33 @@ export function parseRoute(routeString) { 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); - - // Native URLSearchParams query parsing: ?q=foo, ?search=foo, #/target?q=foo, #q=foo - const hashQ = url.hash.indexOf('?'); - const hashParams = hashQ !== -1 ? new URLSearchParams(url.hash.slice(hashQ)) : (url.hash.startsWith('#q=') ? new URLSearchParams(url.hash.slice(1)) : null); - const query = url.searchParams.get('q') ?? url.searchParams.get('search') ?? hashParams?.get('q') ?? hashParams?.get('search'); - const targetParam = url.searchParams.get('target') ?? hashParams?.get('target'); - - if (query !== null && query !== undefined) { - let target = targetParam ? normalizeTarget(targetParam) : 'tot'; - if (!targetParam) { - const pathPart = hashQ !== -1 ? url.hash.slice(0, hashQ) : (url.pathname !== '/' ? url.pathname : ''); - const targetMatch = pathPart.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/#]|$)/i); - if (targetMatch) target = normalizeTarget(targetMatch[1]); - } - return createRouteInfo(target, null, null, null, query.trim()); + const url = + trimmed.startsWith('#') || trimmed.startsWith('?') || trimmed.startsWith('/') + ? new URL(trimmed, ROUTE_BASE_URL) + : new URL('/' + trimmed, ROUTE_BASE_URL); + + // Native URLSearchParams query parsing: ?q=foo, ?search=foo, #/target?q=foo, #q=foo, #target=v8&q=foo + const hashQ = url.hash.indexOf('?'); + const hashQueryStr = hashQ !== -1 ? url.hash.slice(hashQ + 1) : (url.hash.includes('=') ? url.hash.replace(/^#\/?/, '') : ''); + const hashParams = hashQueryStr ? new URLSearchParams(hashQueryStr) : null; + const query = url.searchParams.get('q') ?? url.searchParams.get('search') ?? hashParams?.get('q') ?? hashParams?.get('search'); + const targetParam = url.searchParams.get('target') ?? hashParams?.get('target'); + + if (query !== null && query !== undefined) { + let target = targetParam ? normalizeTarget(targetParam) : 'tot'; + if (!targetParam) { + const pathPart = hashQ !== -1 ? url.hash.slice(0, hashQ) : (url.pathname !== '/' ? url.pathname : ''); + const targetMatch = pathPart.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/#]|$)/i); + if (targetMatch) target = normalizeTarget(targetMatch[1]); } + return createRouteInfo(target, null, null, null, query.trim()); + } - if (targetParam && (trimmed.startsWith('?target=') || trimmed.startsWith('&target='))) { - return createRouteInfo(normalizeTarget(targetParam), null, null); - } + if (targetParam && (trimmed.startsWith('?target=') || trimmed.startsWith('&target='))) { + return createRouteInfo(normalizeTarget(targetParam), null, null); + } + + if (hasURLPattern && legacyAnchorPattern && legacyPathPattern) { const legacyAnchorMatch = legacyAnchorPattern.exec(url); const legacyMember = legacyAnchorMatch?.hash.groups.member ?? null; diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 1d3f6970c..83a5a59f1 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -115,6 +115,7 @@ export class ProtocolRenderer { const main = document.createElement('div'); main.className = 'type'; if (type.deprecated) main.classList.add('deprecated-bg'); + if (type.redirect) main.classList.add('redirect-bg'); main.appendChild( ProtocolRenderer.renderTitle( domain.domain, @@ -124,6 +125,18 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); + const redirect = getRedirect(window.app?._activeDomains, domain.domain, type.id); + if (redirect) { + const p = document.createElement('p'); + p.className = 'redirect-notice'; + const dest = `${redirect.targetDomain}.${redirect.targetMember}`; + const link = document.createElement('a'); + link.href = ProtocolRenderer.formatRef(dest); + link.textContent = dest; + p.append('This type has moved. Redirects to ', link, '.'); + main.appendChild(p); + return main; + } if (type.type) { const p = document.createElement('p'); p.textContent = 'Type: '; From d8d5733f986a2cbc904c34f4ee8ba97e2246c3bc Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 12:23:36 -0700 Subject: [PATCH 09/15] routing: parse hash routes only, make ?q= a one-shot entry, trim redirect handling to commands --- src/main.js | 68 +++---- src/protocol-model.js | 372 ++++-------------------------------- src/protocol_renderer.js | 85 +++----- src/search.js | 65 +++---- src/style.css | 28 +-- test/e2e.test.js | 117 ++++-------- test/protocol-model.test.js | 219 ++------------------- types/types.d.ts | 4 - 8 files changed, 160 insertions(+), 798 deletions(-) diff --git a/src/main.js b/src/main.js index 604e513f8..b34922102 100644 --- a/src/main.js +++ b/src/main.js @@ -21,6 +21,27 @@ const PROTOCOL_URLS = { v8: new URL('./data/v8.json', import.meta.url).href, }; +document.addEventListener('DOMContentLoaded', () => { + const sidebarElement = $('#sidebar'); + const domainListElement = $('#domain-list'); + const contentElement = $('#content'); + const searchElement = $('#search'); + const searchResultsElement = $('#sresults'); + const targetSelector = /** @type {HTMLSelectElement} */ ($('#target-selector')); + const drawerToggle = $('#drawer-toggle'); + const drawerBackdrop = $('#drawer-backdrop'); + + window.app = new App({ + sidebarElement, + domainListElement, + contentElement, + searchElement, + searchResultsElement, + targetSelector, + drawerToggle, + drawerBackdrop, + }); +}); /** * @typedef {Object} AppElements @@ -207,9 +228,9 @@ export class App { if (this._targetSelector) { this._targetSelector.addEventListener('change', () => { const target = normalizeTarget(this._targetSelector.value); - const { domain, section, query } = parseRoute(window.location.hash || window.location.search); + const { domain, section } = parseRoute(window.location.hash); const validDomain = domain && this._targetStore[target]?.has(domain) ? domain : null; - this.navigate(formatRoute({ target, domain: validDomain, section, query })); + this.navigate(formatRoute({ target, domain: validDomain, section })); }); } } @@ -234,7 +255,7 @@ export class App { ) { return; } - if (href && (href.startsWith('#') || href.startsWith('?'))) { + if (href?.startsWith('#')) { event.preventDefault(); this._closeDrawer(); this.navigate(href); @@ -249,18 +270,7 @@ export class App { } _onRoute() { - const hash = window.location.hash || ''; - const search = window.location.search || ''; - let rawRoute = hash; - if (search) { - rawRoute = !hash || hash === '#' || hash === '#/' - ? search - : `${hash}${hash.includes('?') ? '&' : '?'}${search.slice(1)}`; - } 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; @@ -274,11 +284,13 @@ export class App { this._renderSidebar(this._activeDomains); } - // URL-based search query (?q=foo or #q=foo) - if (route.query) { + // OpenSearch entry point: ?q=foo (target via hash, e.g. ?q=foo#/v8). Strip it so it doesn't stick. + const query = new URLSearchParams(window.location.search).get('q'); + if (query) { + history.replaceState(null, '', window.location.pathname + window.location.hash); this._renderedDomain = null; this._onNavigateHome(null); - this._search.search(route.query); + this._search.search(query); return; } @@ -471,26 +483,6 @@ export class App { } } -const sidebarElement = $('#sidebar'); -const domainListElement = $('#domain-list'); -const contentElement = $('#content'); -const searchElement = $('#search'); -const searchResultsElement = $('#sresults'); -const targetSelector = /** @type {HTMLSelectElement} */ ($('#target-selector')); -const drawerToggle = $('#drawer-toggle'); -const drawerBackdrop = $('#drawer-backdrop'); - -window.app = new App({ - sidebarElement, - domainListElement, - contentElement, - searchElement, - searchResultsElement, - targetSelector, - drawerToggle, - drawerBackdrop, -}); - /** * @param {string} error * @returns {Element} diff --git a/src/protocol-model.js b/src/protocol-model.js index 4785cc753..317428755 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -3,7 +3,7 @@ * Browser-agnostic, zero-DOM ES module. */ -/** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolCommand, ProtocolEvent, ProtocolType, ProtocolRoot, NormalizedProtocolRoot, TargetKind, RouteInfo } from '../types/types.d.ts' */ +/** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolRoot, NormalizedProtocolRoot, TargetKind, RouteInfo } from '../types/types.d.ts' */ /** @type {Map} */ const TARGET_MAP = new Map([ @@ -284,103 +284,21 @@ 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; - -/** - * Looks up a domain by name in Map, Array, or Record. - * @param {Map | Record | ProtocolDomain[] | null | undefined} domains - * @param {string} name - * @returns {ProtocolDomain | undefined} - */ -function findDomain(domains, name) { - if (!domains) return undefined; - if (domains instanceof Map) return domains.get(name); - if (Array.isArray(domains)) return domains.find((d) => d.domain === name); - return typeof domains === 'object' ? domains[name] : undefined; -} - /** - * Resolves canonical member name in target domain (handling plurals like deleteCookie -> deleteCookies). - * @param {Map | Record | ProtocolDomain[] | null | undefined} domains - * @param {string} targetDomainName + * 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 - * @param {'command' | 'event' | 'type'} kind - * @returns {string} - */ -function resolveTargetMember(domains, targetDomainName, memberName, kind) { - const targetDomain = findDomain(domains, targetDomainName); - const list = targetDomain?.[kind === 'command' ? 'commands' : kind === 'event' ? 'events' : 'types']; - if (!list) return memberName; - const match = list.find((m) => { - const n = 'name' in m ? m.name : m.id; - return n === memberName || n === memberName + 's' || (memberName.endsWith('s') && n === memberName.slice(0, -1)); - }); - return (match ? ('name' in match ? match.name : match.id) : memberName) || memberName; -} - -/** - * Resolves whether a member in a domain redirects to another domain. - * @param {Map | Record | ProtocolDomain[] | null | undefined} domains - * @param {string|null|undefined} domainName - * @param {string|null|undefined} memberName * @returns {{ targetDomain: string, targetMember: string } | null} */ export function getRedirect(domains, domainName, memberName) { - if (!domainName || !memberName || !domains) return null; - const domain = findDomain(domains, domainName); - if (!domain) return null; - - let kind = /** @type {'command' | 'event' | 'type'} */ ('command'); - /** @type {ProtocolCommand | ProtocolEvent | ProtocolType | undefined} */ - let item = domain.commands?.find((c) => c.name === memberName); - if (!item) { - kind = 'event'; - item = domain.events?.find((e) => e.name === memberName); - } - if (!item) { - kind = 'type'; - item = domain.types?.find((t) => t.id === memberName); - } - if (!item?.redirect) return null; - return { - targetDomain: item.redirect, - targetMember: resolveTargetMember(domains, item.redirect, memberName, kind), - }; + 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 }; } /** @@ -388,273 +306,49 @@ export function getRedirect(domains, domainName, memberName) { * @param {TargetKind} target * @param {string|null} domain * @param {string|null} member - * @param {string|null} [section] - * @param {string|null} [query] * @returns {RouteInfo} */ -function createRouteInfo(target, domain, member, section = null, query = null) { - if (query) { - return { target, domain: null, member: null, query }; - } +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 - * - URL search queries: ?q=foo, ?search=foo, #/v8?q=foo, #q=foo - * - 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); - } - - const url = - trimmed.startsWith('#') || trimmed.startsWith('?') || trimmed.startsWith('/') - ? new URL(trimmed, ROUTE_BASE_URL) - : new URL('/' + trimmed, ROUTE_BASE_URL); - - // Native URLSearchParams query parsing: ?q=foo, ?search=foo, #/target?q=foo, #q=foo, #target=v8&q=foo - const hashQ = url.hash.indexOf('?'); - const hashQueryStr = hashQ !== -1 ? url.hash.slice(hashQ + 1) : (url.hash.includes('=') ? url.hash.replace(/^#\/?/, '') : ''); - const hashParams = hashQueryStr ? new URLSearchParams(hashQueryStr) : null; - const query = url.searchParams.get('q') ?? url.searchParams.get('search') ?? hashParams?.get('q') ?? hashParams?.get('search'); - const targetParam = url.searchParams.get('target') ?? hashParams?.get('target'); - - if (query !== null && query !== undefined) { - let target = targetParam ? normalizeTarget(targetParam) : 'tot'; - if (!targetParam) { - const pathPart = hashQ !== -1 ? url.hash.slice(0, hashQ) : (url.pathname !== '/' ? url.pathname : ''); - const targetMatch = pathPart.match(/(?:^|[/#])(tot|v8|1-3|1-2|stable)(?:[/#]|$)/i); - if (targetMatch) target = normalizeTarget(targetMatch[1]); - } - return createRouteInfo(target, null, null, null, query.trim()); - } - - if (targetParam && (trimmed.startsWith('?target=') || trimmed.startsWith('&target='))) { - return createRouteInfo(normalizeTarget(targetParam), null, null); - } - - if (hasURLPattern && legacyAnchorPattern && legacyPathPattern) { - - 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 hasTarget = TARGET_MAP.has(first.toLowerCase()); + const target = hasTarget ? normalizeTarget(first) : 'tot'; + const ref = hasTarget ? 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); } /** * Formats canonical hash route from components. - * @param {{ target?: string|null, domain?: string|null, member?: string|null, section?: string|null, query?: string|null }} [route] + * @param {{ target?: string|null, domain?: string|null, member?: string|null, section?: string|null }} [route] * @returns {string} Canonical hash route, e.g. '#/Page.navigate' */ -export function formatRoute({ target = 'tot', domain = null, member = null, section = null, query = null } = {}) { +export function formatRoute({ target = 'tot', domain = null, member = null, section = null } = {}) { const normTarget = normalizeTarget(target); const targetPrefix = normTarget === 'tot' ? '' : `${normTarget}/`; - if (query) { - return `#/${targetPrefix}?q=${encodeURIComponent(query)}`; - } if (section) { return `#/${targetPrefix}${section}`; } diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 83a5a59f1..dd85588f4 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -115,7 +115,6 @@ export class ProtocolRenderer { const main = document.createElement('div'); main.className = 'type'; if (type.deprecated) main.classList.add('deprecated-bg'); - if (type.redirect) main.classList.add('redirect-bg'); main.appendChild( ProtocolRenderer.renderTitle( domain.domain, @@ -125,18 +124,6 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); - const redirect = getRedirect(window.app?._activeDomains, domain.domain, type.id); - if (redirect) { - const p = document.createElement('p'); - p.className = 'redirect-notice'; - const dest = `${redirect.targetDomain}.${redirect.targetMember}`; - const link = document.createElement('a'); - link.href = ProtocolRenderer.formatRef(dest); - link.textContent = dest; - p.append('This type has moved. Redirects to ', link, '.'); - main.appendChild(p); - return main; - } if (type.type) { const p = document.createElement('p'); p.textContent = 'Type: '; @@ -227,20 +214,26 @@ export class ProtocolRenderer { static renderTableOfContents(domain, container) { const isDomainExp = Boolean(domain.experimental); /** - * @param {ProtocolCommand | ProtocolEvent | ProtocolType} entity + * @param {ProtocolCommand | ProtocolEvent} method * @param {HTMLElement} container */ - const renderEntry = (entity, container) => { - const name = 'name' in entity ? entity.name : entity.id; - const redirect = getRedirect(window.app?._activeDomains, domain.domain, name); - return ProtocolRenderer.renderTableOfContentsEntry( - domain.domain, - name, - container, - redirect?.targetDomain ?? null, - redirect?.targetMember ?? null, - ); + let renderEventOrMethodEntry = (method, container) => { + const row = ProtocolRenderer.renderTableOfContentsEntry(domain.domain, method.name, container); + const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.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 + */ + let renderTypeEntry = (type, container) => + ProtocolRenderer.renderTableOfContentsEntry(domain.domain, type.id, container); if ( (domain.commands && domain.commands.length) || @@ -255,7 +248,7 @@ export class ProtocolRenderer { 'Methods', 'method', domain.commands, - renderEntry, + renderEventOrMethodEntry, toc, isDomainExp, ); @@ -264,7 +257,7 @@ export class ProtocolRenderer { 'Events', 'event', domain.events, - renderEntry, + renderEventOrMethodEntry, toc, isDomainExp, ); @@ -273,7 +266,7 @@ export class ProtocolRenderer { 'Types', 'type', domain.types, - renderEntry, + renderTypeEntry, toc, isDomainExp, ); @@ -314,9 +307,7 @@ export class ProtocolRenderer { sectionWrapper.appendChild(section); for (let entry of entries) { let row = renderer(entry, section); - if (!entry.redirect) { - ProtocolRenderer.applyMarks(entry, row, isDomainExp); - } + ProtocolRenderer.applyMarks(entry, row, isDomainExp); } return section; } @@ -325,28 +316,15 @@ export class ProtocolRenderer { * @param {string} domainName * @param {string} name * @param {HTMLElement} container - * @param {string|null} [redirectDomain] - * @param {string|null} [redirectMember] * @returns {HTMLElement} */ - static renderTableOfContentsEntry(domainName, name, container, redirectDomain = null, redirectMember = null) { + static renderTableOfContentsEntry(domainName, name, container) { const row = document.createElement('div'); row.className = 'toc-link'; - if (redirectDomain) row.classList.add('toc-redirect'); container.appendChild(row); - const targetMember = redirectMember || name; - const targetRef = redirectDomain ? `${redirectDomain}.${targetMember}` : `${domainName}.${name}`; - let link = ProtocolRenderer.renderRef(targetRef); + let id = `${domainName}.${name}`; + let link = ProtocolRenderer.renderRef(id); link.classList.add('monospace'); - if (redirectDomain) { - link.textContent = name; - const arrow = document.createElement('span'); - arrow.className = 'toc-redirect-hint'; - arrow.textContent = ` ➔ ${redirectDomain}${targetMember !== name ? '.' + targetMember : ''}`; - row.appendChild(link); - row.appendChild(arrow); - return row; - } row.appendChild(link); return row; } @@ -361,7 +339,6 @@ export class ProtocolRenderer { const main = document.createElement('div'); main.className = 'method'; if (method.deprecated) main.classList.add('deprecated-bg'); - if (method.redirect) main.classList.add('redirect-bg'); main.appendChild( ProtocolRenderer.renderTitle( domain.domain, @@ -375,11 +352,7 @@ export class ProtocolRenderer { if (redirect) { const p = document.createElement('p'); p.className = 'redirect-notice'; - const dest = `${redirect.targetDomain}.${redirect.targetMember}`; - const link = document.createElement('a'); - link.href = ProtocolRenderer.formatRef(dest); - link.textContent = dest; - p.append('This method has moved. Redirects to ', link, '.'); + p.append('Moved to ', ProtocolRenderer.renderRef(`${redirect.targetDomain}.${redirect.targetMember}`), '.'); main.appendChild(p); return main; } @@ -506,13 +479,7 @@ export class ProtocolRenderer { */ static applyMarks(item, element, isParentDomainExperimental = false) { if (!item) return; - if (item.redirect) { - const redSpan = document.createElement('span'); - redSpan.className = 'redirect-badge'; - redSpan.textContent = `redirect: ${item.redirect}`; - redSpan.title = `Redirects to ${item.redirect}`; - element.appendChild(redSpan); - } else if (item.experimental) { + if (item.experimental) { if (isParentDomainExperimental) { return; } diff --git a/src/search.js b/src/search.js index 79ee656d0..7c4ea2879 100644 --- a/src/search.js +++ b/src/search.js @@ -2,7 +2,7 @@ * @fileoverview Fuzzy search controller and UI rendering for protocol entities. */ -/** @import { ProtocolDomain, ProtocolCommand, ProtocolEvent, ProtocolType } from '../types/types.d.ts' */ +/** @import { ProtocolDomain } from '../types/types.d.ts' */ import { FuzzySearch } from './fuzzy_search.js'; import { ProtocolRenderer } from './protocol_renderer.js'; @@ -42,21 +42,15 @@ class SearchItem { * @param {SearchItemKind} itemType * @param {string} [description] * @param {(ref: string) => string} [formatRef] - * @param {string|null} [redirectDomain] - * @param {string|null} [redirectMember] */ - constructor(domainName, domainEntry, itemType, description, formatRef, redirectDomain = null, redirectMember = null) { + constructor(domainName, domainEntry, itemType, description, formatRef) { this.domainName = domainName; this.domainEntry = domainEntry; this.type = itemType; this.description = description || ''; this.title = this.domainName + '.' + this.domainEntry; - this.redirectDomain = redirectDomain; - this.redirectMember = redirectMember; const refFormatter = formatRef || (typeof window !== 'undefined' && window.app?.formatRef); - const targetMember = redirectMember || this.domainEntry; - const targetRef = redirectDomain ? `${redirectDomain}.${targetMember}` : this.title; - this.route = refFormatter ? refFormatter(targetRef) : '#/' + targetRef; + this.route = refFormatter ? refFormatter(this.title) : '#/' + this.title; } } @@ -155,38 +149,32 @@ export class Search { setDomains(domains) { this._items = []; const formatRef = this._app?.formatRef; + const byName = new Map(domains.map((d) => [d.domain, d])); for (const domain of domains) { - /** @type {Array<[ProtocolCommand[] | ProtocolEvent[] | ProtocolType[] | undefined, SearchItemKind]>} */ - const lists = [ - [domain.commands, 'method'], - [domain.events, 'event'], - [domain.types, 'type'], - ]; - for (const [list, type] of lists) { - for (const entity of list || []) { - const name = 'name' in entity ? entity.name : entity.id; - const redirect = getRedirect(domains, domain.domain, name); - const desc = redirect - ? `Redirects to ${redirect.targetDomain}.${redirect.targetMember}. ${entity.description || ''}` - : entity.description; - this._items.push( - new SearchItem( - domain.domain, - name, - type, - desc, - formatRef, - redirect?.targetDomain, - redirect?.targetMember, - ), - ); - } + for (const command of domain.commands || []) { + const redirect = getRedirect(byName, 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', description, formatRef), + ); + } + for (const event of domain.events || []) { + this._items.push( + new SearchItem(domain.domain, event.name, 'event', event.description, formatRef), + ); + } + for (const type of domain.types || []) { + this._items.push( + new SearchItem(domain.domain, type.id, 'type', type.description, formatRef), + ); } } } /** - * Performs an immediate search with the given query, opening the results dropdown. + * Runs a search for `query` and opens the results dropdown. * @param {string} query */ search(query) { @@ -399,13 +387,6 @@ function renderSearchResult(searchResult) { item.title.length, ), ); - if (item.redirectDomain) { - const redirectBadge = document.createElement('span'); - redirectBadge.className = 'search-redirect-badge'; - const targetMember = item.redirectMember || item.domainEntry; - redirectBadge.textContent = ` ➔ ${item.redirectDomain}.${targetMember}`; - p1.appendChild(redirectBadge); - } let p2 = document.createElement('div'); p2.className = 'search-item-description'; p2.textContent = item.description; diff --git a/src/style.css b/src/style.css index e5383a097..4d1b9078a 100644 --- a/src/style.css +++ b/src/style.css @@ -840,8 +840,7 @@ tr:hover td { /* Badges & Entity Icons */ span.experimental, -span.deprecated, -span.redirect-badge { +span.deprecated { font-size: 10px; line-height: 12px; text-transform: uppercase; @@ -863,38 +862,15 @@ span.deprecated { background-color: #e65100; } -span.redirect-badge { - background-color: #f59e0b; -} - .redirect-notice { - font-size: 14px; - margin: 8px 0; color: var(--color-subtle); } -.redirect-notice a { - color: var(--color-primary); - font-weight: 600; -} - -.toc-redirect-hint, -.search-redirect-badge { - font-size: 11px; - font-family: var(--font-sans); - margin-left: 4px; -} - .toc-redirect-hint { + font-size: 11px; color: var(--color-subtle); } -.search-redirect-badge { - color: var(--color-primary); - font-weight: 500; - margin-left: 6px; -} - /* 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 b9941725b..a4129790a 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -779,90 +779,43 @@ 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 items (#/DOM.highlightNode -> #/Overlay.highlightNode)', async () => { - await page.Page.navigate({ url: `${baseUrl}/#/DOM.highlightNode` }); - - const finalHash = await client.pollEvaluate( - 'window.location.hash', - (/** @type {any} */ hash) => hash === '#/Overlay.highlightNode', - sessionId, - ); - assert.strictEqual(finalHash, '#/Overlay.highlightNode', 'Expected hash to auto-redirect to canonical domain'); - - const activeDomain = await client.pollEvaluate( - 'document.querySelector(".domain-link.active-link")?.getAttribute("data-domain")', - (/** @type {any} */ domain) => domain === 'Overlay', - sessionId, - ); - assert.strictEqual(activeDomain, 'Overlay', 'Expected Overlay domain to be active in sidebar'); - - const headingExists = await client.pollEvaluate( - 'Boolean(document.getElementById("Overlay_highlightNode"))', - (/** @type {any} */ val) => Boolean(val), - sessionId, - ); - assert.strictEqual(headingExists, true, 'Expected canonical #Overlay_highlightNode heading in DOM'); - - // Test renamed/plural redirect: Page.deleteCookie -> Network.deleteCookies - await page.Page.navigate({ url: `${baseUrl}/#/Page.deleteCookie` }); - const deleteCookiesHash = await client.pollEvaluate( - 'window.location.hash', - (/** @type {any} */ hash) => hash === '#/Network.deleteCookies', - sessionId, - ); - assert.strictEqual(deleteCookiesHash, '#/Network.deleteCookies', 'Expected Page.deleteCookie to redirect to plural #/Network.deleteCookies'); - - const deleteCookiesHeading = await client.pollEvaluate( - 'Boolean(document.getElementById("Network_deleteCookies"))', - (/** @type {any} */ val) => Boolean(val), - sessionId, - ); - assert.strictEqual(deleteCookiesHeading, true, 'Expected #Network_deleteCookies heading in DOM'); - }); - - await t.test('14. URL-based search query (?q=evaluate)', async () => { - await page.Page.navigate({ url: `${baseUrl}/?q=evaluate` }); - - const inputValue = await client.pollEvaluate( - 'document.getElementById("search")?.value', - (/** @type {any} */ val) => val === 'evaluate', - sessionId, - ); - assert.strictEqual(inputValue, 'evaluate', 'Expected search input to be populated with query'); - - const resultsDisplayed = await client.pollEvaluate( - 'document.getElementById("sresults")?.style.display', - (/** @type {any} */ display) => display === 'block', - sessionId, - ); - assert.strictEqual(resultsDisplayed, 'block', 'Expected search results dropdown to be visible'); - - const resultCount = await client.pollEvaluate( - 'document.querySelectorAll("#sresults .search-item").length', - (/** @type {any} */ count) => count > 0, - sessionId, - ); - assert.ok(resultCount > 0, 'Expected search results to be rendered'); - - // Target-scoped search: #/v8?q=evaluate synchronizes target-selector - await page.Page.navigate({ url: `${baseUrl}/#/v8?q=evaluate` }); - const v8Target = await client.pollEvaluate( - 'document.getElementById("target-selector")?.value', - (/** @type {any} */ val) => val === 'v8', - sessionId, - ); - assert.strictEqual(v8Target, 'v8', 'Expected target selector to switch to v8'); + await t.test('13. Auto-redirect moved commands', async () => { + for (const [from, to, headingId] of [ + ['#/DOM.highlightNode', '#/Overlay.highlightNode', 'Overlay_highlightNode'], + ['#/Page.deleteCookie', '#/Network.deleteCookies', 'Network_deleteCookies'], + ]) { + 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, `Expected ${from} to redirect to ${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('15. OpenSearch autodiscovery link in index.html', async () => { - const openSearchLink = await client.evaluate( - 'document.querySelector("link[rel=\'search\'][type=\'application/opensearchdescription+xml\']")?.getAttribute("href")', - sessionId, - ); - assert.ok( - openSearchLink && openSearchLink.includes('opensearch.xml'), - `Expected opensearch.xml link tag in head, got ${openSearchLink}`, - ); + await t.test('14. OpenSearch query (?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.value, 'evaluate', 'Expected search input to be populated with query'); + assert.strictEqual(state.target, 'v8', 'Expected target from hash'); + assert.ok(state.results > 0, 'Expected search results to be rendered'); + assert.strictEqual(state.search, '', 'Expected ?q= to be stripped from the URL'); }); } finally { if (targetId && browserApi) { diff --git a/test/protocol-model.test.js b/test/protocol-model.test.js index 4bd36fe4e..ec89bbe75 100644 --- a/test/protocol-model.test.js +++ b/test/protocol-model.test.js @@ -230,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', @@ -315,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/', @@ -379,56 +302,6 @@ test('parseRoute: dynamic native subtests for all route formats', async (/** @ty input: '#/stable/', expected: { target: 'stable', domain: null, member: null }, }, - - // URL-based search queries - { - input: '?q=evaluate', - expected: { target: 'tot', domain: null, member: null, query: 'evaluate' }, - }, - { - input: '?search=Runtime.evaluate', - expected: { target: 'tot', domain: null, member: null, query: 'Runtime.evaluate' }, - }, - { - input: '#q=Network.enable', - expected: { target: 'tot', domain: null, member: null, query: 'Network.enable' }, - }, - { - input: '#/v8?q=evaluate', - expected: { target: 'v8', domain: null, member: null, query: 'evaluate' }, - }, - { - input: '#/stable?q=getCookies', - expected: { target: 'stable', domain: null, member: null, query: 'getCookies' }, - }, - { - input: '?q=100%', - expected: { target: 'tot', domain: null, member: null, query: '100%' }, - }, - { - input: '?q=tot/DOM', - expected: { target: 'tot', domain: null, member: null, query: 'tot/DOM' }, - }, - { - input: '?q=Runtime/v8', - expected: { target: 'tot', domain: null, member: null, query: 'Runtime/v8' }, - }, - { - input: '#/v8?q=tot/DOM', - expected: { target: 'v8', domain: null, member: null, query: 'tot/DOM' }, - }, - { - input: '?target=v8', - expected: { target: 'v8', domain: null, member: null }, - }, - { - input: '?target=v8&q=evaluate', - expected: { target: 'v8', domain: null, member: null, query: 'evaluate' }, - }, - { - input: '?q=evaluate&target=stable', - expected: { target: 'stable', domain: null, member: null, query: 'evaluate' }, - }, ]; for (const { input, expected } of cases) { @@ -471,100 +344,30 @@ test('formatRoute: canonical route formatting', () => { assert.equal(formatRoute({ section: 'endpoints' }), '#/endpoints'); assert.equal(formatRoute({ target: 'v8', section: 'faq' }), '#/v8/faq'); - // Query routes - assert.equal(formatRoute({ query: 'evaluate' }), '#/?q=evaluate'); - assert.equal(formatRoute({ target: 'v8', query: 'evaluate' }), '#/v8/?q=evaluate'); - // Default options assert.equal(formatRoute(), '#/'); }); -test('getRedirect: resolves redirected commands, events, and types', () => { - const domains = [ - { - domain: 'DOM', - commands: [ - { name: 'highlightNode', redirect: 'Overlay' }, - { name: 'getDocument' }, - ], - events: [ - { name: 'inspectNodeRequested' }, - ], - types: [], - }, - { - domain: 'Page', - commands: [ - { name: 'deleteCookie', redirect: 'Network' }, - ], - events: [ - { name: 'screencastFrame', redirect: 'HeadlessExperimental' }, - ], - types: [ - { id: 'Cookie', redirect: 'Network' }, - ], - }, - { - domain: 'Overlay', - commands: [ - { name: 'highlightNode' }, - ], - }, - { - domain: 'Network', - commands: [ - { name: 'deleteCookies' }, - ], - types: [ - { id: 'Cookie' }, - ], - }, - { - domain: 'HeadlessExperimental', - events: [ - { name: 'screencastFrame' }, - ], - }, - ]; +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]), + ); - // Command redirect (exact match) assert.deepEqual(getRedirect(domains, 'DOM', 'highlightNode'), { targetDomain: 'Overlay', targetMember: 'highlightNode', }); - - // Command redirect (pluralized match: deleteCookie -> deleteCookies) + // Pluralized destination: deleteCookie -> deleteCookies assert.deepEqual(getRedirect(domains, 'Page', 'deleteCookie'), { targetDomain: 'Network', targetMember: 'deleteCookies', }); - - // Event redirect - assert.deepEqual(getRedirect(domains, 'Page', 'screencastFrame'), { - targetDomain: 'HeadlessExperimental', - targetMember: 'screencastFrame', - }); - - // Type redirect - assert.deepEqual(getRedirect(domains, 'Page', 'Cookie'), { - targetDomain: 'Network', - targetMember: 'Cookie', - }); - - // Non-redirected member assert.equal(getRedirect(domains, 'DOM', 'getDocument'), null); - - // Non-existent domain or member assert.equal(getRedirect(domains, 'NonExistent', 'foo'), null); - assert.equal(getRedirect(domains, 'DOM', 'nonExistent'), null); - assert.equal(getRedirect(null, 'DOM', 'highlightNode'), null); - assert.equal(getRedirect(domains, '', ''), null); - - // Supports Map input - const domainMap = new Map(domains.map((d) => [d.domain, d])); - assert.deepEqual(getRedirect(domainMap, 'DOM', 'highlightNode'), { - targetDomain: 'Overlay', - targetMember: 'highlightNode', - }); + assert.equal(getRedirect(undefined, 'DOM', 'highlightNode'), null); }); - diff --git a/types/types.d.ts b/types/types.d.ts index 1f890e970..cb03899fa 100644 --- a/types/types.d.ts +++ b/types/types.d.ts @@ -23,7 +23,6 @@ export interface ProtocolParameter { items?: ProtocolParameter; enum?: string[]; properties?: ProtocolParameter[]; - redirect?: string; } /** Flattened domain type representation including runtime back-references */ @@ -37,7 +36,6 @@ export interface ProtocolType { enum?: string[]; items?: ProtocolParameter; referencedBy?: ProtocolBackReference[]; - redirect?: string; } export interface ProtocolDomain { @@ -49,7 +47,6 @@ export interface ProtocolDomain { types?: ProtocolType[]; commands?: ProtocolCommand[]; events?: ProtocolEvent[]; - redirect?: string; } export interface NormalizedProtocolDomain extends ProtocolDomain { @@ -75,5 +72,4 @@ export interface RouteInfo { domain: string | null; member: string | null; section?: string | null; - query?: string | null; } From 0d6285b1e1583501b97060bfbc56f97cb094dcbb Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 14:53:53 -0700 Subject: [PATCH 10/15] search: keep ?q= in sync with the search box for permalinks --- src/main.js | 13 +++---------- src/search.js | 12 ++++++++++++ test/e2e.test.js | 27 +++++++++++++++++++++++---- 3 files changed, 38 insertions(+), 14 deletions(-) diff --git a/src/main.js b/src/main.js index b34922102..f038414c8 100644 --- a/src/main.js +++ b/src/main.js @@ -154,7 +154,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); @@ -284,16 +287,6 @@ export class App { this._renderSidebar(this._activeDomains); } - // OpenSearch entry point: ?q=foo (target via hash, e.g. ?q=foo#/v8). Strip it so it doesn't stick. - const query = new URLSearchParams(window.location.search).get('q'); - if (query) { - history.replaceState(null, '', window.location.pathname + window.location.hash); - this._renderedDomain = null; - this._onNavigateHome(null); - this._search.search(query); - return; - } - const { domain, member, section } = route; if (!domain) { diff --git a/src/search.js b/src/search.js index 7c4ea2879..082d2fec4 100644 --- a/src/search.js +++ b/src/search.js @@ -187,6 +187,7 @@ export class Search { this._searchInput.blur(); /** @type {HTMLElement} */ (this._resultsElement).style.setProperty('display', 'none'); this._searchInput.value = this._defaultValue; + setQueryParam(''); if (this._app?.focusContent) this._app.focusContent(); else if (typeof window !== 'undefined' && window.app?.focusContent) window.app.focusContent(); } @@ -202,6 +203,7 @@ export class Search { 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) { @@ -354,6 +356,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/test/e2e.test.js b/test/e2e.test.js index a4129790a..c43257ed4 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -800,7 +800,7 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { } }); - await t.test('14. OpenSearch query (?q=evaluate#/v8)', async () => { + await t.test('14. Search permalinks (?q=evaluate#/v8)', async () => { await page.Page.navigate({ url: `${baseUrl}/?q=evaluate#/v8` }); const state = await client.pollEvaluate( `({ @@ -812,10 +812,29 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { (/** @type {any} */ s) => s?.value === 'evaluate' && s.results > 0, sessionId, ); - assert.strictEqual(state.value, 'evaluate', 'Expected search input to be populated with query'); assert.strictEqual(state.target, 'v8', 'Expected target from hash'); - assert.ok(state.results > 0, 'Expected search results to be rendered'); - assert.strictEqual(state.search, '', 'Expected ?q= to be stripped from the URL'); + 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 = 'getCookies'; + input.dispatchEvent(new Event('input')); + return location.search; + })()`, + sessionId, + ); + assert.strictEqual(typed, '?q=getCookies', 'Expected typing to update ?q='); + + // Picking a result / navigating ends the search and clears ?q= + await client.evaluate(`location.hash = '#/v8/Runtime'`, sessionId); + const cleared = await client.pollEvaluate( + 'location.search', + (/** @type {any} */ s) => s === '', + sessionId, + ); + assert.strictEqual(cleared, '', 'Expected ?q= to be cleared after navigating'); }); } finally { if (targetId && browserApi) { From f6313ccf7e08a16bad37d48612d884ce2746c8da Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 15:03:21 -0700 Subject: [PATCH 11/15] routing: make / the canonical home url and route on popstate --- src/main.js | 19 +++++++++++-------- test/e2e.test.js | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 8 deletions(-) diff --git a/src/main.js b/src/main.js index f038414c8..4f201ad93 100644 --- a/src/main.js +++ b/src/main.js @@ -124,14 +124,16 @@ export class App { */ navigate(route, replace = false) { const cleanRoute = formatRoute(parseRoute(route)); - if (window.location.hash !== cleanRoute) { - if (replace) { - window.location.replace(cleanRoute); - } else { - 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(); + } else if (window.location.hash === cleanRoute) { + this._onRoute(); + } else if (replace) { + window.location.replace(cleanRoute); + } else { + window.location.hash = cleanRoute; } } @@ -269,7 +271,8 @@ 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() { diff --git a/test/e2e.test.js b/test/e2e.test.js index c43257ed4..ed004ca42 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -836,6 +836,47 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { ); assert.strictEqual(cleared, '', 'Expected ?q= to be cleared after navigating'); }); + + 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 { From a2e2affaaee74f8c3ee413f9ef4d311d3812e0cb Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 15:22:18 -0700 Subject: [PATCH 12/15] search: clear the input after picking a result --- src/main.js | 4 ---- src/search.js | 12 ++---------- test/e2e.test.js | 17 +++++++++-------- 3 files changed, 11 insertions(+), 22 deletions(-) diff --git a/src/main.js b/src/main.js index 4f201ad93..075f7fc1c 100644 --- a/src/main.js +++ b/src/main.js @@ -321,7 +321,6 @@ export class App { 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) { @@ -344,8 +343,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 = ''; @@ -398,7 +395,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/search.js b/src/search.js index 082d2fec4..3f649b9c2 100644 --- a/src/search.js +++ b/src/search.js @@ -91,7 +91,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; @@ -116,7 +115,7 @@ export class Search { !event.altKey && /\S/.test(event.key) ) { - if (event.key !== '.') this._searchInput.value = ''; + this._searchInput.value = ''; this._searchInput.focus(); } }); @@ -186,19 +185,12 @@ export class Search { 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'); diff --git a/test/e2e.test.js b/test/e2e.test.js index ed004ca42..52edfee53 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -819,22 +819,23 @@ test('Chrome DevTools Protocol Viewer E2E Tests', async (t) => { const typed = await client.evaluate( `(() => { const input = document.getElementById('search'); - input.value = 'getCookies'; + input.value = 'enable'; input.dispatchEvent(new Event('input')); return location.search; })()`, sessionId, ); - assert.strictEqual(typed, '?q=getCookies', 'Expected typing to update ?q='); + assert.strictEqual(typed, '?q=enable', 'Expected typing to update ?q='); - // Picking a result / navigating ends the search and clears ?q= - await client.evaluate(`location.hash = '#/v8/Runtime'`, sessionId); - const cleared = await client.pollEvaluate( - 'location.search', - (/** @type {any} */ s) => s === '', + // 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(cleared, '', 'Expected ?q= to be cleared after navigating'); + 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 () => { From 8be9df0ef49dbbcda92d7f6125c75d617230e96f Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 15:25:57 -0700 Subject: [PATCH 13/15] test: stop e2e hang from chrome helpers holding stderr open --- test/e2e.test.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/test/e2e.test.js b/test/e2e.test.js index 52edfee53..2494d5698 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} */ @@ -890,6 +894,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 }); From ecd805773cf41ecd0d54aea91085bf5835f3214d Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 17:31:35 -0700 Subject: [PATCH 14/15] cleanup: unify redirect lookup, scope it to commands, drop duplicate schema field --- src/main.js | 48 +++++++++++++++++++++----------------- src/protocol-model.js | 6 ++--- src/protocol_renderer.js | 17 +++++++++----- src/search.js | 6 ++--- test/e2e.test.js | 35 ++++++++++++++------------- types/protocol-schema.d.ts | 1 - 6 files changed, 62 insertions(+), 51 deletions(-) diff --git a/src/main.js b/src/main.js index 075f7fc1c..4e8e91618 100644 --- a/src/main.js +++ b/src/main.js @@ -114,6 +114,20 @@ export class App { 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() { this._contentElement.focus(); } @@ -128,13 +142,17 @@ export class App { // 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(); - } else if (window.location.hash === cleanRoute) { + return; + } + if (window.location.hash === cleanRoute) { this._onRoute(); - } else if (replace) { + return; + } + if (replace) { window.location.replace(cleanRoute); - } else { - window.location.hash = cleanRoute; + return; } + window.location.hash = cleanRoute; } /** @@ -298,23 +316,11 @@ export class App { return; } - // Auto-redirect if command/event/type has moved to another domain - if (member) { - const redirect = - getRedirect(this._activeDomains, domain, member) || - getRedirect(this._targetStore.tot, domain, member); - if (redirect) { - const target = this._activeDomains.has(redirect.targetDomain) ? route.target : 'tot'; - this.navigate( - formatRoute({ - target, - domain: redirect.targetDomain, - member: redirect.targetMember, - }), - true, - ); - 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 diff --git a/src/protocol-model.js b/src/protocol-model.js index 317428755..951623ded 100644 --- a/src/protocol-model.js +++ b/src/protocol-model.js @@ -330,9 +330,9 @@ function createRouteInfo(target, domain, member) { export function parseRoute(hash) { const path = (hash ?? '').trim().replace(/^#\/?/, '').replace(/\/+$/, ''); const [first = '', ...rest] = path.split('/'); - const hasTarget = TARGET_MAP.has(first.toLowerCase()); - const target = hasTarget ? normalizeTarget(first) : 'tot'; - const ref = hasTarget ? rest.join('/') : path; + 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('.'); diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index dd85588f4..22e61bb41 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -3,7 +3,6 @@ */ /** @import { ProtocolDomain, NormalizedProtocolDomain, ProtocolType, ProtocolCommand, ProtocolEvent, ProtocolParameter, ProtocolBackReference } from '../types/types.d.ts' */ -import { getRedirect } from './protocol-model.js'; export class ProtocolRenderer { /** @@ -217,9 +216,15 @@ export class ProtocolRenderer { * @param {ProtocolCommand | ProtocolEvent} method * @param {HTMLElement} container */ - let renderEventOrMethodEntry = (method, container) => { - const row = ProtocolRenderer.renderTableOfContentsEntry(domain.domain, method.name, container); - const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.name); + 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'; @@ -248,7 +253,7 @@ export class ProtocolRenderer { 'Methods', 'method', domain.commands, - renderEventOrMethodEntry, + renderCommandEntry, toc, isDomainExp, ); @@ -348,7 +353,7 @@ export class ProtocolRenderer { Boolean(domain.experimental), ), ); - const redirect = getRedirect(window.app?._activeDomains, domain.domain, method.name); + const redirect = !isEvent && window.app?.redirectFor(domain.domain, method.name); if (redirect) { const p = document.createElement('p'); p.className = 'redirect-notice'; diff --git a/src/search.js b/src/search.js index 3f649b9c2..5f71c0c31 100644 --- a/src/search.js +++ b/src/search.js @@ -6,7 +6,6 @@ import { FuzzySearch } from './fuzzy_search.js'; import { ProtocolRenderer } from './protocol_renderer.js'; -import { getRedirect } from './protocol-model.js'; // Number of search results to render immediately. const SEARCH_RENDER_COUNT = 50; @@ -78,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; @@ -148,10 +147,9 @@ export class Search { setDomains(domains) { this._items = []; const formatRef = this._app?.formatRef; - const byName = new Map(domains.map((d) => [d.domain, d])); for (const domain of domains) { for (const command of domain.commands || []) { - const redirect = getRedirect(byName, domain.domain, command.name); + const redirect = this._app?.redirectFor?.(domain.domain, command.name); const description = redirect ? `Moved to ${redirect.targetDomain}.${redirect.targetMember}.` : command.description; diff --git a/test/e2e.test.js b/test/e2e.test.js index 2494d5698..69d9971a7 100644 --- a/test/e2e.test.js +++ b/test/e2e.test.js @@ -783,24 +783,27 @@ 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 () => { - for (const [from, to, headingId] of [ + await t.test('13. Auto-redirect moved commands', async (t) => { + const cases = [ ['#/DOM.highlightNode', '#/Overlay.highlightNode', 'Overlay_highlightNode'], ['#/Page.deleteCookie', '#/Network.deleteCookies', 'Network_deleteCookies'], - ]) { - 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, `Expected ${from} to redirect to ${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`); + ]; + 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`); + }); } }); diff --git a/types/protocol-schema.d.ts b/types/protocol-schema.d.ts index 6cda88791..aa61bd08f 100644 --- a/types/protocol-schema.d.ts +++ b/types/protocol-schema.d.ts @@ -13,7 +13,6 @@ export namespace Protocol { export interface ExtraInformation { deprecated?: boolean; experimental?: boolean; - redirect?: string; } export interface Domain extends ExtraInformation { From a29294dd1b42eb92356f44407c32fa5a4d4ec6be Mon Sep 17 00:00:00 2001 From: Paul Irish Date: Sat, 3 Oct 2026 17:35:43 -0700 Subject: [PATCH 15/15] style: space the toc redirect hint --- src/protocol_renderer.js | 2 +- src/style.css | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/protocol_renderer.js b/src/protocol_renderer.js index 22e61bb41..c8c2a73b0 100644 --- a/src/protocol_renderer.js +++ b/src/protocol_renderer.js @@ -228,7 +228,7 @@ export class ProtocolRenderer { if (redirect) { const hint = document.createElement('span'); hint.className = 'toc-redirect-hint'; - hint.textContent = ` ➔ ${redirect.targetDomain}`; + hint.textContent = `➔ ${redirect.targetDomain}`; row.appendChild(hint); } return row; diff --git a/src/style.css b/src/style.css index 4d1b9078a..b90acd1d0 100644 --- a/src/style.css +++ b/src/style.css @@ -868,6 +868,7 @@ span.deprecated { .toc-redirect-hint { font-size: 11px; + margin-left: 4px; color: var(--color-subtle); }