| // Copyright 2021 The Chromium Authors |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../../core/common/common.js'; |
| import * as Host from '../../core/host/host.js'; |
| import * as Platform from '../../core/platform/platform.js'; |
| import * as Root from '../../core/root/root.js'; |
| import * as SDK from '../../core/sdk/sdk.js'; |
| import * as Protocol from '../../generated/protocol.js'; |
| import * as IssuesManager from '../../models/issues_manager/issues_manager.js'; |
| import * as TextUtils from '../../models/text_utils/text_utils.js'; |
| import {findMenuItemWithLabel, getContextMenuForElement} from '../../testing/ContextMenuHelpers.js'; |
| import {dispatchPasteEvent, renderElementIntoDOM} from '../../testing/DOMHelpers.js'; |
| import {createTarget, registerNoopActions, updateHostConfig} from '../../testing/EnvironmentHelpers.js'; |
| import {expectCall, expectCalled} from '../../testing/ExpectStubCall.js'; |
| import {stubFileManager} from '../../testing/FileManagerHelpers.js'; |
| import {describeWithMockConnection, dispatchEvent} from '../../testing/MockConnection.js'; |
| import * as TextEditor from '../../ui/components/text_editor/text_editor.js'; |
| import * as UI from '../../ui/legacy/legacy.js'; |
| import {AiCodeCompletionSummaryToolbar} from '../common/common.js'; |
| |
| import * as Console from './console.js'; |
| |
| const {urlString} = Platform.DevToolsPath; |
| |
| describeWithMockConnection('ConsoleView', () => { |
| let consoleView: Console.ConsoleView.ConsoleView; |
| |
| beforeEach(() => { |
| registerNoopActions(['console.clear', 'console.clear.history', 'console.create-pin']); |
| consoleView = Console.ConsoleView.ConsoleView.instance({forceNew: true, viewportThrottlerTimeout: 0}); |
| }); |
| |
| afterEach(() => { |
| consoleView.detach(); |
| }); |
| |
| it('adds a title to every checkbox label in the settings view', async () => { |
| const consoleSettingsCheckboxes = |
| consoleView.element.querySelector('devtools-toolbar')!.querySelectorAll('devtools-checkbox'); |
| assert.isOk(consoleSettingsCheckboxes, 'No checkbox found in console settings'); |
| for (const checkbox of consoleSettingsCheckboxes) { |
| assert.isTrue(checkbox.shadowRoot?.querySelector('.devtools-checkbox-text')?.hasAttribute('title')); |
| } |
| // This test transitively schedules a task which may cause errors if the task |
| // is run without the environments set in this test. Thus wait for its completion |
| // before proceding to the next test. |
| await consoleView.getScheduledRefreshPromiseForTest(); |
| }); |
| |
| function createConsoleMessage( |
| target: SDK.Target.Target, |
| message: string, |
| type: SDK.ConsoleModel.MessageType = Protocol.Runtime.ConsoleAPICalledEventType.Log, |
| level: Protocol.Log.LogEntryLevel|null = null, |
| ) { |
| return new SDK.ConsoleModel.ConsoleMessage( |
| target.model(SDK.RuntimeModel.RuntimeModel), Protocol.Log.LogEntrySource.Javascript, level, message, {type}); |
| } |
| |
| it('can save to file', async () => { |
| const tabTarget = createTarget({type: SDK.Target.Type.TAB}); |
| createTarget({parentTarget: tabTarget, subtype: 'prerender'}); |
| const target = createTarget({parentTarget: tabTarget}); |
| |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 1')); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 2')); |
| const messagesElement = consoleView.element.querySelector('#console-messages'); |
| assert.exists(messagesElement); |
| |
| const contextMenu = getContextMenuForElement(messagesElement); |
| const saveAsItem = findMenuItemWithLabel(contextMenu.saveSection(), 'Save as…'); |
| assert.exists(saveAsItem); |
| |
| const TIMESTAMP = 42; |
| const URL_HOST = 'example.com'; |
| sinon.stub(Date, 'now').returns(TIMESTAMP); |
| target.setInspectedURL(urlString`${`http://${URL_HOST}/foo`}`); |
| const FILENAME = `${URL_HOST}-${TIMESTAMP}.log` as Platform.DevToolsPath.RawPathString; |
| const fileManager = stubFileManager(); |
| const fileManagerCloseCall = expectCall(fileManager.close); |
| contextMenu.invokeHandler(saveAsItem.id()); |
| assert.isTrue(fileManager.save.calledOnceWith( |
| FILENAME, TextUtils.ContentData.EMPTY_TEXT_CONTENT_DATA, /* forceSaveAs=*/ true)); |
| await fileManagerCloseCall; |
| assert.isTrue(fileManager.append.calledOnceWith(FILENAME, sinon.match('message 1\nmessage 2\n'))); |
| }); |
| |
| it('can copy to clipboard', async () => { |
| const tabTarget = createTarget({type: SDK.Target.Type.TAB}); |
| createTarget({parentTarget: tabTarget, subtype: 'prerender'}); |
| const target = createTarget({parentTarget: tabTarget}); |
| |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 1')); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 2')); |
| const messagesElement = consoleView.element.querySelector('#console-messages'); |
| assert.exists(messagesElement); |
| |
| const contextMenu = getContextMenuForElement(messagesElement); |
| const copy = findMenuItemWithLabel(contextMenu.saveSection(), 'Copy console'); |
| assert.exists(copy); |
| |
| const copyText = sinon.stub(Host.InspectorFrontendHost.InspectorFrontendHostInstance, 'copyText').resolves(); |
| contextMenu.invokeHandler(copy.id()); |
| await expectCalled(copyText); |
| sinon.assert.callCount(copyText, 1); |
| assert.deepEqual(copyText.lastCall.args, ['message 1\nmessage 2\n']); |
| copyText.resetHistory(); |
| }); |
| |
| it('creates console history', () => { |
| const target = createTarget(); |
| const id = 1; |
| dispatchEvent(target, 'Runtime.executionContextCreated', { |
| context: { |
| id: id as Protocol.Runtime.ExecutionContextId, |
| origin: 'http://example.com', |
| name: `c${id}`, |
| uniqueId: `c${id}`, |
| auxData: { |
| frameId: 'f2', |
| }, |
| }, |
| }); |
| const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel); |
| assert.exists(runtimeModel); |
| const executionContext = runtimeModel.executionContext(id); |
| assert.exists(executionContext); |
| UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, executionContext); |
| |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| consoleModel.addMessage(createConsoleMessage(target, 'let x = 1;', SDK.ConsoleModel.FrontendMessageType.Command)); |
| consoleModel.addMessage(createConsoleMessage(target, 'let y = 100;', SDK.ConsoleModel.FrontendMessageType.Command)); |
| |
| const consoleHistory = consoleView.getConsoleMessageHistory(); |
| assert.deepEqual(consoleHistory, 'let x = 1;\n\nlet y = 100;\n\n'); |
| }); |
| |
| async function getConsoleMessages() { |
| const messagesElement = consoleView.element.querySelector('#console-messages'); |
| assert.exists(messagesElement); |
| |
| await new Promise(resolve => setTimeout(resolve, 0)); |
| return [...messagesElement.querySelectorAll('.console-message-text')].map(e => (e as HTMLElement).innerText); |
| } |
| |
| const messageTests = (inScope: boolean) => () => { |
| let target: SDK.Target.Target; |
| |
| beforeEach(() => { |
| target = createTarget(); |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(inScope ? target : null); |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| }); |
| |
| it('adds messages', async () => { |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| SDK.ConsoleModel.ConsoleModel.requestClearMessages(); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 1')); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 2')); |
| |
| const messages = await getConsoleMessages(); |
| assert.deepEqual(messages, inScope ? ['message 1', 'message 2'] : []); |
| }); |
| |
| it('prints results', async () => { |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel); |
| assert.exists(runtimeModel); |
| SDK.ConsoleModel.ConsoleModel.requestClearMessages(); |
| consoleModel.dispatchEventToListeners(SDK.ConsoleModel.Events.CommandEvaluated, { |
| result: new SDK.RemoteObject.RemoteObjectImpl(runtimeModel, undefined, 'number', undefined, 42), |
| commandMessage: createConsoleMessage(target, '[ultimateQuestionOfLife, theUniverse, everything].join()'), |
| }); |
| |
| const messages = await getConsoleMessages(); |
| assert.deepEqual(messages, inScope ? ['42'] : []); |
| }); |
| }; |
| |
| describe('in scope', messageTests(true)); |
| describe('out of scope', messageTests(false)); |
| |
| const handlesSwitchingScope = (preserveLog: boolean) => async () => { |
| Common.Settings.Settings.instance().moduleSetting('preserve-console-log').set(preserveLog); |
| const target = createTarget(); |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(target); |
| const anotherTarget = createTarget(); |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 1')); |
| consoleModel.addMessage(createConsoleMessage(target, 'message 2')); |
| |
| const anotherConsoleModel = anotherTarget.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(anotherConsoleModel); |
| anotherConsoleModel.addMessage(createConsoleMessage(anotherTarget, 'message 3')); |
| assert.deepEqual(await getConsoleMessages(), ['message 1', 'message 2']); |
| |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(anotherTarget); |
| assert.deepEqual(await getConsoleMessages(), preserveLog ? ['message 1', 'message 2', 'message 3'] : ['message 3']); |
| |
| Common.Settings.Settings.instance().moduleSetting('preserve-console-log').set(false); |
| }; |
| |
| it('replaces messages when switching scope with preserve log off', handlesSwitchingScope(false)); |
| it('appends messages when switching scope with preserve log on', handlesSwitchingScope(true)); |
| |
| describe('self-XSS warning', () => { |
| let target: SDK.Target.Target; |
| |
| beforeEach(() => { |
| target = createTarget(); |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(target); |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| }); |
| |
| it('shows', async () => { |
| const dt = new DataTransfer(); |
| dt.setData('text/plain', 'foo'); |
| |
| const messagesElement = consoleView.element.querySelector('#console-messages'); |
| assert.instanceOf(messagesElement, HTMLElement); |
| dispatchPasteEvent(messagesElement, {clipboardData: dt, bubbles: true}); |
| assert.strictEqual( |
| Common.Console.Console.instance().messages()[0].text, |
| 'Warning: Don’t paste code into the DevTools Console that you don’t understand or haven’t reviewed yourself. This could allow attackers to steal your identity or take control of your computer. Please type ‘allow pasting’ below and press Enter to allow pasting.'); |
| }); |
| |
| it('is turned off when console history reaches a length of 5', async () => { |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| const runtimeModel = target.model(SDK.RuntimeModel.RuntimeModel); |
| assert.exists(runtimeModel); |
| SDK.ConsoleModel.ConsoleModel.requestClearMessages(); |
| |
| const selfXssWarningDisabledSetting = Common.Settings.Settings.instance().createSetting( |
| 'disable-self-xss-warning', false, Common.Settings.SettingStorageType.SYNCED); |
| |
| for (let i = 0; i < 5; i++) { |
| assert.isFalse(selfXssWarningDisabledSetting.get()); |
| consoleModel.dispatchEventToListeners( |
| SDK.ConsoleModel.Events.MessageAdded, |
| createConsoleMessage(target, String(i), SDK.ConsoleModel.FrontendMessageType.Command)); |
| } |
| assert.isTrue(selfXssWarningDisabledSetting.get()); |
| }); |
| |
| it('is not shown when disabled via command line', () => { |
| const stub = sinon.stub(Root.Runtime.Runtime, 'queryParam'); |
| stub.withArgs('disableSelfXssWarnings').returns('true'); |
| |
| const dt = new DataTransfer(); |
| dt.setData('text/plain', 'foo'); |
| |
| const messagesElement = consoleView.element.querySelector('#console-messages'); |
| assert.instanceOf(messagesElement, HTMLElement); |
| dispatchPasteEvent(messagesElement, {clipboardData: dt, bubbles: true}); |
| |
| assert.lengthOf(Common.Console.Console.instance().messages(), 0); |
| stub.restore(); |
| }); |
| }); |
| |
| it('appends commands to the history right away', async () => { |
| const target = createTarget(); |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(target); |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| |
| const consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| const consoleHistorySetting = Common.Settings.Settings.instance().createLocalSetting('console-history', []); |
| |
| consoleModel.dispatchEventToListeners( |
| SDK.ConsoleModel.Events.MessageAdded, |
| createConsoleMessage(target, 'await new Promise(() => ())', SDK.ConsoleModel.FrontendMessageType.Command)); |
| |
| assert.deepEqual(consoleHistorySetting.get(), ['await new Promise(() => ())']); |
| }); |
| |
| it('keeps updating the issue counter when re-attached after detaching', async () => { |
| consoleView.markAsRoot(); |
| const spy = sinon.spy(consoleView, 'issuesCountUpdatedForTest'); |
| const issuesManager = IssuesManager.IssuesManager.IssuesManager.instance(); |
| issuesManager.dispatchEventToListeners(IssuesManager.IssuesManager.Events.ISSUES_COUNT_UPDATED); |
| sinon.assert.calledOnce(spy); |
| |
| // Pauses updating the issue counter |
| consoleView.onDetach(); |
| issuesManager.dispatchEventToListeners(IssuesManager.IssuesManager.Events.ISSUES_COUNT_UPDATED); |
| sinon.assert.calledOnce(spy); |
| |
| // Continues updating the issue counter |
| renderElementIntoDOM(consoleView); |
| issuesManager.dispatchEventToListeners(IssuesManager.IssuesManager.Events.ISSUES_COUNT_UPDATED); |
| sinon.assert.calledTwice(spy); |
| }); |
| |
| describe('ai code completion provider callbacks', () => { |
| beforeEach(async () => { |
| updateHostConfig({ |
| devToolsAiCodeCompletion: { |
| enabled: true, |
| }, |
| aidaAvailability: { |
| enabled: true, |
| blockedByAge: false, |
| blockedByGeo: false, |
| }, |
| }); |
| const aiCodeCompletionProviderStub = |
| sinon.createStubInstance(TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider); |
| aiCodeCompletionProviderStub.extension.returns([]); |
| sinon.stub(TextEditor.AiCodeCompletionProvider.AiCodeCompletionProvider, 'createInstance') |
| .returns(aiCodeCompletionProviderStub); |
| sinon.stub(Host.AidaClient.HostConfigTracker, 'instance').returns({ |
| addEventListener: () => {}, |
| removeEventListener: () => {}, |
| dispose: () => {}, |
| } as unknown as Host.AidaClient.HostConfigTracker); |
| Common.Settings.Settings.instance().createSetting('ai-code-completion-enabled', true); |
| consoleView = Console.ConsoleView.ConsoleView.instance({forceNew: true, viewportThrottlerTimeout: 0}); |
| }); |
| |
| it('initializes toolbar when the feature is enabled', async () => { |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| |
| providerConfig.onFeatureEnabled(); |
| |
| assert.exists(consoleView.element.querySelector('div.ai-code-completion-summary-toolbar-container')); |
| }); |
| |
| it('cleans up toolbar when the feature is disabled', async () => { |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| providerConfig.onFeatureEnabled(); |
| assert.exists(consoleView.element.querySelector('div.ai-code-completion-summary-toolbar-container')); |
| |
| providerConfig.onFeatureDisabled(); |
| |
| assert.notExists(consoleView.element.querySelector('div.ai-code-completion-summary-toolbar-container')); |
| }); |
| |
| it('shows a loading state when a request is triggered', async () => { |
| const setLoadingSpy = sinon.stub(AiCodeCompletionSummaryToolbar.prototype, 'setLoading'); |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| providerConfig.onFeatureEnabled(); |
| |
| providerConfig.onRequestTriggered(); |
| |
| sinon.assert.calledOnce(setLoadingSpy); |
| assert.isTrue(setLoadingSpy.firstCall.args[0]); |
| }); |
| |
| it('hides the loading indicator when a response is received', async () => { |
| const setLoadingSpy = sinon.stub(AiCodeCompletionSummaryToolbar.prototype, 'setLoading'); |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| providerConfig.onFeatureEnabled(); |
| providerConfig.onRequestTriggered(); |
| sinon.assert.calledOnce(setLoadingSpy); |
| assert.isTrue(setLoadingSpy.firstCall.args[0]); |
| |
| providerConfig.onResponseReceived([]); |
| |
| sinon.assert.calledTwice(setLoadingSpy); |
| assert.isFalse(setLoadingSpy.secondCall.args[0]); |
| }); |
| |
| it('attaches the citations toolbar when a suggestion with citations is accepted', async () => { |
| const updateCitationsSpy = sinon.spy(AiCodeCompletionSummaryToolbar.prototype, 'updateCitations'); |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| |
| providerConfig.onFeatureEnabled(); |
| providerConfig.onResponseReceived([{uri: 'https://example.com/source'}]); |
| |
| providerConfig.onSuggestionAccepted(); |
| |
| sinon.assert.calledOnce(updateCitationsSpy); |
| assert.deepEqual(updateCitationsSpy.firstCall.args, [['https://example.com/source']]); |
| }); |
| |
| it('does not attach the citations toolbar if there are no citations', async () => { |
| const updateCitationsSpy = sinon.spy(AiCodeCompletionSummaryToolbar.prototype, 'updateCitations'); |
| const providerConfig = consoleView.aiCodeCompletionConfig; |
| assert.exists(providerConfig); |
| |
| providerConfig.onFeatureEnabled(); |
| providerConfig.onResponseReceived([]); |
| |
| providerConfig.onSuggestionAccepted(); |
| |
| sinon.assert.notCalled(updateCitationsSpy); |
| }); |
| }); |
| |
| describe('group visibility', () => { |
| let target: ReturnType<typeof createTarget>; |
| let consoleModel: SDK.ConsoleModel.ConsoleModel|null; |
| |
| beforeEach(() => { |
| target = createTarget(); |
| SDK.TargetManager.TargetManager.instance().setScopeTarget(target); |
| consoleModel = target.model(SDK.ConsoleModel.ConsoleModel); |
| assert.exists(consoleModel); |
| }); |
| |
| function addMessage( |
| message: string, type: Protocol.Runtime.ConsoleAPICalledEventType, level: Protocol.Log.LogEntryLevel) { |
| consoleModel!.addMessage(createConsoleMessage(target, message, type, level)); |
| } |
| |
| for (const level |
| of [Protocol.Log.LogEntryLevel.Error, |
| Protocol.Log.LogEntryLevel.Warning, |
| Protocol.Log.LogEntryLevel.Info, |
| Protocol.Log.LogEntryLevel.Verbose, |
| ]) { |
| it(`shows collapsed group but not message when filtering for ${level}`, async () => { |
| const levels = Console.ConsoleFilter.ConsoleFilter.singleLevelMask(level); |
| // Setting might exist, .set() is crucial |
| Common.Settings.Settings.instance().createSetting('message-level-filters', levels).set(levels); |
| |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| |
| addMessage( |
| 'group', Protocol.Runtime.ConsoleAPICalledEventType.StartGroupCollapsed, Protocol.Log.LogEntryLevel.Info); |
| addMessage('message', Protocol.Runtime.ConsoleAPICalledEventType.Log, level); |
| addMessage('', Protocol.Runtime.ConsoleAPICalledEventType.EndGroup, Protocol.Log.LogEntryLevel.Info); |
| |
| const messages = await getConsoleMessages(); |
| assert.include(messages, 'group'); |
| assert.notInclude(messages, 'message'); |
| }); |
| |
| it(`shows expanded group and message when filtering for ${level}`, async () => { |
| const levels = Console.ConsoleFilter.ConsoleFilter.singleLevelMask(level); |
| // Setting might exist, .set() is crucial |
| Common.Settings.Settings.instance().createSetting('message-level-filters', levels).set(levels); |
| |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| |
| addMessage('group', Protocol.Runtime.ConsoleAPICalledEventType.StartGroup, Protocol.Log.LogEntryLevel.Info); |
| addMessage('message', Protocol.Runtime.ConsoleAPICalledEventType.Log, level); |
| addMessage('', Protocol.Runtime.ConsoleAPICalledEventType.EndGroup, Protocol.Log.LogEntryLevel.Info); |
| |
| const messages = await getConsoleMessages(); |
| assert.include(messages, 'group'); |
| assert.include(messages, 'message'); |
| }); |
| } |
| |
| it(`does not show group when filtering for level it does not contain`, async () => { |
| const levels = Console.ConsoleFilter.ConsoleFilter.singleLevelMask(Protocol.Log.LogEntryLevel.Warning); |
| // Setting might exist, .set() is crucial |
| Common.Settings.Settings.instance().createSetting('message-level-filters', levels).set(levels); |
| |
| consoleView.markAsRoot(); |
| renderElementIntoDOM(consoleView); |
| |
| addMessage('group', Protocol.Runtime.ConsoleAPICalledEventType.StartGroup, Protocol.Log.LogEntryLevel.Info); |
| addMessage('message', Protocol.Runtime.ConsoleAPICalledEventType.Log, Protocol.Log.LogEntryLevel.Error); |
| addMessage('', Protocol.Runtime.ConsoleAPICalledEventType.EndGroup, Protocol.Log.LogEntryLevel.Info); |
| |
| const messages = await getConsoleMessages(); |
| assert.notInclude(messages, 'group'); |
| assert.notInclude(messages, 'message'); |
| }); |
| }); |
| }); |