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