| // Copyright 2021 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Platform from '../../../../core/platform/platform.js'; |
| import * as SDK from '../../../../core/sdk/sdk.js'; |
| import type * as Protocol from '../../../../generated/protocol.js'; |
| import * as Bindings from '../../../../models/bindings/bindings.js'; |
| import * as Breakpoints from '../../../../models/breakpoints/breakpoints.js'; |
| import * as Workspace from '../../../../models/workspace/workspace.js'; |
| import {findMenuItemWithLabel} from '../../../../testing/ContextMenuHelpers.js'; |
| import { |
| createTarget, |
| describeWithEnvironment, |
| } from '../../../../testing/EnvironmentHelpers.js'; |
| import { |
| describeWithMockConnection, |
| dispatchEvent, |
| } from '../../../../testing/MockConnection.js'; |
| import {MockProtocolBackend} from '../../../../testing/MockScopeChain.js'; |
| import * as UI from '../../legacy.js'; |
| |
| import * as Components from './utils.js'; |
| |
| const {urlString} = Platform.DevToolsPath; |
| const scriptId1 = '1' as Protocol.Runtime.ScriptId; |
| const scriptId2 = '2' as Protocol.Runtime.ScriptId; |
| const executionContextId = 1234 as Protocol.Runtime.ExecutionContextId; |
| |
| const simpleScriptContent = ` |
| function foo(x) { |
| const y = x + 3; |
| return y; |
| } |
| `; |
| |
| describeWithMockConnection('Linkifier', () => { |
| function setUpEnvironment() { |
| const target = createTarget(); |
| const linkifier = new Components.Linkifier.Linkifier(100, false); |
| linkifier.targetAdded(target); |
| const workspace = Workspace.Workspace.WorkspaceImpl.instance(); |
| const forceNew = true; |
| const targetManager = target.targetManager(); |
| const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace); |
| const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({ |
| forceNew, |
| resourceMapping, |
| targetManager, |
| }); |
| Bindings.IgnoreListManager.IgnoreListManager.instance({forceNew, debuggerWorkspaceBinding}); |
| Breakpoints.BreakpointManager.BreakpointManager.instance( |
| {forceNew, targetManager, workspace, debuggerWorkspaceBinding}); |
| const backend = new MockProtocolBackend(); |
| return {target, linkifier, backend}; |
| } |
| |
| describe('Linkifier.linkifyURL', () => { |
| it('prefers text over the URL if it is present', async () => { |
| const url = urlString`http://www.example.com`; |
| const link = Components.Linkifier.Linkifier.linkifyURL(url, { |
| text: 'foo', |
| showColumnNumber: false, |
| inlineFrameIndex: 1, |
| }); |
| assert.strictEqual(link.innerText, 'foo'); |
| }); |
| |
| it('falls back to the URL if given an empty text value', async () => { |
| const url = urlString`http://www.example.com`; |
| const link = Components.Linkifier.Linkifier.linkifyURL(url, { |
| text: '', |
| showColumnNumber: false, |
| inlineFrameIndex: 1, |
| }); |
| assert.strictEqual(link.innerText, 'www.example.com'); |
| }); |
| |
| it('falls back to unknown if the URL and text are empty', async () => { |
| const url = urlString``; |
| const link = Components.Linkifier.Linkifier.linkifyURL(url, { |
| text: '', |
| showColumnNumber: false, |
| inlineFrameIndex: 1, |
| }); |
| assert.strictEqual(link.innerText, '(unknown)'); |
| }); |
| }); |
| |
| it('creates an empty placeholder anchor if the debugger is disabled and no url exists', () => { |
| const {target, linkifier} = setUpEnvironment(); |
| |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| assert.exists(debuggerModel); |
| void debuggerModel.suspendModel(); |
| |
| const lineNumber = 4; |
| const url = Platform.DevToolsPath.EmptyUrlString; |
| const anchor = linkifier.maybeLinkifyScriptLocation(target, scriptId1, url, lineNumber); |
| assert.exists(anchor); |
| assert.strictEqual(anchor.textContent, ''); |
| |
| const info = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(info); |
| assert.isNull(info.uiLocation); |
| }); |
| |
| it('resolves url and updates link as soon as debugger is enabled', done => { |
| const {target, linkifier} = setUpEnvironment(); |
| |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| assert.exists(debuggerModel); |
| void debuggerModel.suspendModel(); |
| |
| const lineNumber = 4; |
| // Explicitly set url to empty string and let it resolve through the live location. |
| const url = Platform.DevToolsPath.EmptyUrlString; |
| const anchor = linkifier.maybeLinkifyScriptLocation(target, scriptId1, url, lineNumber); |
| assert.exists(anchor); |
| assert.strictEqual(anchor.textContent, ''); |
| |
| void debuggerModel.resumeModel(); |
| const scriptParsedEvent: Protocol.Debugger.ScriptParsedEvent = { |
| scriptId: scriptId1, |
| url: 'https://www.google.com/script.js', |
| startLine: 0, |
| startColumn: 0, |
| endLine: 10, |
| endColumn: 10, |
| executionContextId, |
| hash: '', |
| buildId: '', |
| isLiveEdit: false, |
| sourceMapURL: undefined, |
| hasSourceURL: false, |
| length: 10, |
| }; |
| dispatchEvent(target, 'Debugger.scriptParsed', scriptParsedEvent); |
| |
| const callback: MutationCallback = function(mutations: MutationRecord[]) { |
| for (const mutation of mutations) { |
| if (mutation.type === 'childList') { |
| const info = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(info); |
| assert.exists(info.uiLocation); |
| assert.strictEqual(anchor.textContent, `script.js:${lineNumber + 1}`); |
| observer.disconnect(); |
| done(); |
| } |
| } |
| }; |
| const observer = new MutationObserver(callback); |
| observer.observe(anchor, {childList: true}); |
| }); |
| |
| it('always favors script ID over url', done => { |
| const {target, linkifier} = setUpEnvironment(); |
| const lineNumber = 4; |
| const url = 'https://www.google.com/script.js'; |
| |
| const scriptParsedEvent1: Protocol.Debugger.ScriptParsedEvent = { |
| scriptId: scriptId1, |
| url, |
| startLine: 0, |
| startColumn: 0, |
| endLine: 10, |
| endColumn: 10, |
| executionContextId, |
| hash: '', |
| buildId: '', |
| isLiveEdit: false, |
| sourceMapURL: undefined, |
| hasSourceURL: false, |
| length: 10, |
| }; |
| dispatchEvent(target, 'Debugger.scriptParsed', scriptParsedEvent1); |
| |
| // Ask for a link to a script that has not been registered yet, but has the same url. |
| const anchor = linkifier.maybeLinkifyScriptLocation(target, scriptId2, urlString`${url}`, lineNumber); |
| assert.exists(anchor); |
| |
| // This link should not pick up the first script with the same url, since there's no |
| // warranty that the first script has anything to do with this one (other than having |
| // the same url). |
| const info = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(info); |
| assert.isNull(info.uiLocation); |
| |
| const scriptParsedEvent2: Protocol.Debugger.ScriptParsedEvent = { |
| scriptId: scriptId2, |
| url, |
| startLine: 0, |
| startColumn: 0, |
| endLine: 10, |
| endColumn: 10, |
| executionContextId, |
| hash: '', |
| buildId: '', |
| isLiveEdit: false, |
| sourceMapURL: undefined, |
| hasSourceURL: false, |
| length: 10, |
| }; |
| dispatchEvent(target, 'Debugger.scriptParsed', scriptParsedEvent2); |
| |
| const callback: MutationCallback = function(mutations: MutationRecord[]) { |
| for (const mutation of mutations) { |
| if (mutation.type === 'childList') { |
| const info = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(info); |
| assert.exists(info.uiLocation); |
| |
| // Make sure that a uiSourceCode is linked to that anchor. |
| assert.exists(info.uiLocation.uiSourceCode); |
| observer.disconnect(); |
| done(); |
| } |
| } |
| }; |
| const observer = new MutationObserver(callback); |
| observer.observe(anchor, {childList: true}); |
| }); |
| |
| it('optionally shows column numbers in the link text', done => { |
| const {target, linkifier} = setUpEnvironment(); |
| |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| assert.exists(debuggerModel); |
| void debuggerModel.suspendModel(); |
| |
| const lineNumber = 4; |
| const options = {columnNumber: 8, showColumnNumber: true, inlineFrameIndex: 0}; |
| // Explicitly set url to empty string and let it resolve through the live location. |
| const url = Platform.DevToolsPath.EmptyUrlString; |
| const anchor = linkifier.maybeLinkifyScriptLocation(target, scriptId1, url, lineNumber, options); |
| assert.exists(anchor); |
| assert.strictEqual(anchor.textContent, ''); |
| |
| void debuggerModel.resumeModel(); |
| const scriptParsedEvent: Protocol.Debugger.ScriptParsedEvent = { |
| scriptId: scriptId1, |
| url: 'https://www.google.com/script.js', |
| startLine: 0, |
| startColumn: 0, |
| endLine: 10, |
| endColumn: 10, |
| executionContextId, |
| hash: '', |
| buildId: '', |
| isLiveEdit: false, |
| sourceMapURL: undefined, |
| hasSourceURL: false, |
| length: 10, |
| }; |
| dispatchEvent(target, 'Debugger.scriptParsed', scriptParsedEvent); |
| |
| const callback: MutationCallback = function(mutations: MutationRecord[]) { |
| for (const mutation of mutations) { |
| if (mutation.type === 'childList') { |
| const info = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(info); |
| assert.exists(info.uiLocation); |
| assert.strictEqual(anchor.textContent, `script.js:${lineNumber + 1}:${options.columnNumber + 1}`); |
| observer.disconnect(); |
| done(); |
| } |
| } |
| }; |
| const observer = new MutationObserver(callback); |
| observer.observe(anchor, {childList: true}); |
| }); |
| |
| it('linkifyStackTraceTopFrame without a target returns an initiator link', () => { |
| const lineNumber = 165; |
| const {linkifier} = setUpEnvironment(); |
| |
| const anchor = linkifier.linkifyStackTraceTopFrame(null, { |
| callFrames: [{ |
| url: 'https://w.com/a.js', |
| functionName: 'wow', |
| scriptId: '1' as Protocol.Runtime.ScriptId, |
| lineNumber, |
| columnNumber: 15, |
| }], |
| }); |
| |
| assert.exists(anchor); |
| assert.strictEqual(anchor.textContent, `w.com/a.js:${lineNumber + 1}`); |
| }); |
| |
| describe('maybeLinkifyScriptLocation', () => { |
| it('uses the BreakLocation as a revealable if the option is provided and a breakpoint is at the given location', |
| async () => { |
| const {target, linkifier, backend} = setUpEnvironment(); |
| const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance(); |
| const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(); |
| const lineNumber = 1; |
| const columnNumber = 0; |
| const url = urlString`https://www.google.com/script.js`; |
| |
| const script = await backend.addScript(target, {content: simpleScriptContent, url}, null); |
| const uiSourceCode = debuggerWorkspaceBinding.uiSourceCodeForScript(script); |
| assert.exists(uiSourceCode); |
| |
| const responder = backend.responderToBreakpointByUrlRequest(url, lineNumber); |
| void responder({ |
| breakpointId: 'BREAK_ID' as Protocol.Debugger.BreakpointId, |
| locations: [ |
| { |
| scriptId: script.scriptId, |
| lineNumber, |
| columnNumber, |
| }, |
| ], |
| }); |
| const breakpoint = await breakpointManager.setBreakpoint( |
| uiSourceCode, lineNumber, columnNumber, 'x' as Breakpoints.BreakpointManager.UserCondition, |
| /* enabled */ true, /* isLogpoint */ true, Breakpoints.BreakpointManager.BreakpointOrigin.USER_ACTION); |
| assert.exists(breakpoint); |
| |
| // Create a link that matches exactly the breakpoint location. |
| const anchor = linkifier.maybeLinkifyScriptLocation( |
| target, script.scriptId, url, lineNumber, {inlineFrameIndex: 0, revealBreakpoint: true}); |
| assert.exists(anchor); |
| |
| await debuggerWorkspaceBinding.pendingLiveLocationChangesPromise(); |
| |
| // Assert that the linkinfo has the `BreakLocation` as its revealable. |
| // When clicking the link, `revealables` have predecence over e.g. the |
| // UILocation or url. |
| const linkInfo = Components.Linkifier.Linkifier.linkInfo(anchor); |
| assert.exists(linkInfo); |
| assert.propertyVal(linkInfo.revealable, 'breakpoint', breakpoint); |
| }); |
| |
| it('fires the LiveLocationUpdate event for each LiveLocation update', async () => { |
| const {target, linkifier, backend} = setUpEnvironment(); |
| const eventCallback = sinon.stub(); |
| linkifier.addEventListener(Components.Linkifier.Events.LIVE_LOCATION_UPDATED, eventCallback); |
| const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance(); |
| const lineNumber = 1; |
| const url = urlString`https://www.google.com/script.js`; |
| const sourceMapContent = JSON.stringify({ |
| version: 3, |
| names: ['adder', 'param1', 'param2', 'result'], |
| sources: ['/original-script.js'], |
| sourcesContent: |
| ['function adder(param1, param2) {\n const result = param1 + param2;\n return result;\n}\n\n'], |
| mappings: 'AAAA,SAASA,MAAMC,EAAQC,GACrB,MAAMC,EAASF,EAASC,EACxB,OAAOC,CACT', |
| }); |
| |
| const script = await backend.addScript(target, {content: 'function adder(n,r){const t=n+r;return t}', url}, { |
| url: 'https://www.google.com/script.js.map', |
| content: sourceMapContent, |
| }); |
| |
| linkifier.maybeLinkifyScriptLocation(target, script.scriptId, url, lineNumber); |
| |
| await debuggerWorkspaceBinding.pendingLiveLocationChangesPromise(); |
| sinon.assert.calledOnce(eventCallback); |
| |
| // Detach the source map and check we get the update event. |
| const debuggerModel = target.model(SDK.DebuggerModel.DebuggerModel); |
| assert.exists(debuggerModel); |
| debuggerModel.sourceMapManager().detachSourceMap(script); |
| |
| await debuggerWorkspaceBinding.pendingLiveLocationChangesPromise(); |
| // We currently receive more than one event after detaching the source map. |
| // This is also valid but might constitute unnecessary work. |
| assert.isTrue(eventCallback.callCount >= 2); |
| }); |
| }); |
| |
| describe('linkActions', () => { |
| const registrations: Components.Linkifier.LinkHandlerRegistration[] = []; |
| |
| afterEach(() => { |
| for (const registration of registrations) { |
| Components.Linkifier.Linkifier.unregisterLinkHandler(registration); |
| } |
| registrations.length = 0; |
| }); |
| |
| function registerHandler(registration: Components.Linkifier.LinkHandlerRegistration): void { |
| Components.Linkifier.Linkifier.registerLinkHandler(registration); |
| registrations.push(registration); |
| } |
| |
| it('returns no actions when no link handlers are registered', () => { |
| const url = urlString`foo-extension://node/1`; |
| const actions = Components.Linkifier.Linkifier.linkActions({ |
| url, |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null |
| }); |
| const openUsingActions = actions.filter(action => action.title.startsWith('Open using')); |
| assert.isEmpty(openUsingActions); |
| }); |
| |
| it('returns an action for a registered scheme-specific link handler', async () => { |
| const handler = sinon.spy(); |
| |
| registerHandler({ |
| title: 'Handler for foo-extension', |
| origin: urlString`foo-extension:abcdefghijklmnop`, |
| scheme: urlString`foo-extension:`, |
| handler, |
| shouldHandleOpenResource: (url, schemes) => |
| Components.Linkifier.Linkifier.shouldHandleOpenResource(urlString`foo-extension:`, url, schemes), |
| }); |
| |
| const url = urlString`foo-extension://node/1`; |
| const actions = Components.Linkifier.Linkifier.linkActions({ |
| url, |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null |
| }); |
| const openUsingAction = actions.find(action => action.title === 'Open using Handler for foo-extension'); |
| assert.exists(openUsingAction); |
| await openUsingAction?.handler(); |
| sinon.assert.called(handler); |
| }); |
| |
| it('returns the right action for multiple link handler', async () => { |
| // Register a (global) handler for the global: origin. |
| registerHandler({ |
| title: 'Global Handler', |
| origin: urlString`global:abcdefghijklmnop`, |
| scheme: undefined, |
| handler: () => {}, |
| shouldHandleOpenResource: (url, schemes) => |
| Components.Linkifier.Linkifier.shouldHandleOpenResource(null, url, schemes), |
| }); |
| |
| // Register a scheme-specific handler for the foo-extension: origin. |
| registerHandler({ |
| title: 'Handler for foo-extension', |
| origin: urlString`foo-extension:abcdefghijklmnop`, |
| scheme: urlString`foo-extension:`, |
| handler: () => {}, |
| shouldHandleOpenResource: (url, schemes) => |
| Components.Linkifier.Linkifier.shouldHandleOpenResource(urlString`foo-extension:`, url, schemes), |
| }); |
| |
| { |
| // Ensure that the foo-extension is the main handler for foo-extension links, and that the |
| // global handler doesn't take precedent. |
| const url = urlString`foo-extension://node/1`; |
| const actions = Components.Linkifier.Linkifier.linkActions({ |
| url, |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null |
| }); |
| assert.lengthOf(actions, 3); // Two fallback actions are always added. |
| |
| const openUsingAction = actions.find(action => action.title === 'Open using Handler for foo-extension'); |
| assert.exists(openUsingAction); |
| } |
| |
| { |
| // Ensure that the Global handler handles its own links. |
| const url = urlString`global://node/1`; |
| const actions = Components.Linkifier.Linkifier.linkActions({ |
| url, |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null |
| }); |
| assert.lengthOf(actions, 3); // One for our handler + 'Open in New Tab' and 'Copy link'. |
| |
| const openUsingAction = actions.find(action => action.title === 'Open using Global Handler'); |
| assert.exists(openUsingAction); |
| } |
| |
| { |
| // Ensure that the Global handler handles all other links. |
| const url = urlString`http://www.example.com`; |
| const actions = Components.Linkifier.Linkifier.linkActions({ |
| url, |
| icon: null, |
| enableDecorator: false, |
| uiLocation: null, |
| liveLocation: null, |
| lineNumber: null, |
| columnNumber: null, |
| inlineFrameIndex: 0, |
| revealable: null, |
| fallback: null |
| }); |
| assert.lengthOf(actions, 3); // One for our handler + 'Open in New Tab' and 'Copy link'. |
| |
| const openUsingAction = actions.find(action => action.title === 'Open using Global Handler'); |
| assert.exists(openUsingAction); |
| } |
| }); |
| }); |
| }); |
| |
| describeWithEnvironment('ContentProviderContextMenuProvider', () => { |
| it('does not add \'Open in new tab\'-entry for file URLs', async () => { |
| const provider = new Components.Linkifier.ContentProviderContextMenuProvider(); |
| |
| let contextMenu = new UI.ContextMenu.ContextMenu({} as Event); |
| let uiSourceCode = { |
| contentURL: () => 'https://www.example.com/index.html', |
| } as Workspace.UISourceCode.UISourceCode; |
| provider.appendApplicableItems({} as Event, contextMenu, uiSourceCode); |
| let openInNewTabItem = findMenuItemWithLabel(contextMenu.revealSection(), 'Open in new tab'); |
| assert.exists(openInNewTabItem); |
| |
| contextMenu = new UI.ContextMenu.ContextMenu({} as Event); |
| uiSourceCode = { |
| contentURL: () => 'file://usr/local/example/index.html', |
| } as Workspace.UISourceCode.UISourceCode; |
| provider.appendApplicableItems({} as Event, contextMenu, uiSourceCode); |
| openInNewTabItem = findMenuItemWithLabel(contextMenu.revealSection(), 'Open in new tab'); |
| assert.isUndefined(openInNewTabItem); |
| }); |
| }); |