UNPKG

chrome-devtools-frontend

Version:
281 lines (249 loc) • 13.2 kB
// Copyright 2020 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 Common from '../../../core/common/common.js'; 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 Workspace from '../../../models/workspace/workspace.js'; import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js'; import { createTarget, describeWithEnvironment, } from '../../../testing/EnvironmentHelpers.js'; import {expectCall} from '../../../testing/ExpectStubCall.js'; import {TestPlugin} from '../../../testing/LanguagePluginHelpers.js'; import {describeWithMockConnection} from '../../../testing/MockConnection.js'; import {MockExecutionContext} from '../../../testing/MockExecutionContext.js'; import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js'; import * as UI from '../../legacy/legacy.js'; import * as TextEditor from './text_editor.js'; const {urlString} = Platform.DevToolsPath; function makeState(doc: string, extensions: CodeMirror.Extension = []) { return CodeMirror.EditorState.create({ doc, extensions: [ extensions, TextEditor.Config.baseConfiguration(doc), TextEditor.Config.autocompletion.instance(), ], }); } describeWithEnvironment('TextEditor', () => { afterEach(() => { // These do not get removed when the text editor is disconnected and calls // the CodeMirror destroy() method. const cmTooltips = document.body.querySelectorAll('.editor-tooltip-host'); for (const tooltip of cmTooltips) { document.body.removeChild(tooltip); } }); describe('component', () => { it('has a state property', () => { const editor = new TextEditor.TextEditor.TextEditor(makeState('one')); assert.strictEqual(editor.state.doc.toString(), 'one'); editor.state = makeState('two'); assert.strictEqual(editor.state.doc.toString(), 'two'); renderElementIntoDOM(editor); assert.strictEqual(editor.editor.state.doc.toString(), 'two'); editor.editor.dispatch({changes: {from: 3, insert: '!'}}); editor.remove(); assert.strictEqual(editor.editor.state.doc.toString(), 'two!'); }); it('sets an aria-label attribute', () => { const editor = new TextEditor.TextEditor.TextEditor(makeState('')); assert.strictEqual(editor.editor.contentDOM.getAttribute('aria-label'), 'Code editor'); }); it('can highlight whitespace', () => { const editor = new TextEditor.TextEditor.TextEditor( makeState('line1 \n line2( )\n\tline3 ', TextEditor.Config.showWhitespace.instance())); renderElementIntoDOM(editor); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-trailingWhitespace, .cm-highlightedSpaces'), 0); Common.Settings.Settings.instance().moduleSetting('show-whitespaces-in-editor').set('all'); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-highlightedSpaces'), 4); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-highlightedTab'), 1); Common.Settings.Settings.instance().moduleSetting('show-whitespaces-in-editor').set('trailing'); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-highlightedSpaces'), 0); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-trailingWhitespace'), 2); Common.Settings.Settings.instance().moduleSetting('show-whitespaces-in-editor').set('none'); assert.lengthOf(editor.editor.dom.querySelectorAll('.cm-trailingWhitespace, .cm-highlightedSpaces'), 0); editor.remove(); }); it('should restore scroll to the same position after reconnecting to DOM when it is scrollable', async () => { const editor = new TextEditor.TextEditor.TextEditor(makeState( 'line1\nline2\nline3\nline4\nline5\nline6andthisisalonglinesothatwehaveenoughspacetoscrollhorizontally', [CodeMirror.EditorView.theme( {'&.cm-editor': {height: '50px', width: '50px'}, '.cm-scroller': {overflow: 'auto'}})])); const scrollEventHandledToSaveScrollPositionForTest = sinon.stub(editor, 'scrollEventHandledToSaveScrollPositionForTest'); const waitForFirstScrollPromise = expectCall(scrollEventHandledToSaveScrollPositionForTest); renderElementIntoDOM(editor); editor.editor.dispatch({ effects: CodeMirror.EditorView.scrollIntoView(0, { x: 'start', xMargin: -20, y: 'start', yMargin: -20, }), }); await waitForFirstScrollPromise; const scrollTopBeforeRemove = editor.editor.scrollDOM.scrollTop; const scrollLeftBeforeRemove = editor.editor.scrollDOM.scrollLeft; const waitForSecondScrollPromise = expectCall(scrollEventHandledToSaveScrollPositionForTest); editor.remove(); renderElementIntoDOM(editor); await waitForSecondScrollPromise; const scrollTopAfterReconnect = editor.editor.scrollDOM.scrollTop; const scrollLeftAfterReconnect = editor.editor.scrollDOM.scrollLeft; assert.strictEqual(scrollTopBeforeRemove, scrollTopAfterReconnect); assert.strictEqual(scrollLeftBeforeRemove, scrollLeftAfterReconnect); }); }); describe('configuration', () => { it('can detect line separators', () => { assert.strictEqual(makeState('one\r\ntwo\r\nthree').lineBreak, '\r\n'); assert.strictEqual(makeState('one\ntwo\nthree').lineBreak, '\n'); assert.strictEqual(makeState('one\r\ntwo\nthree').lineBreak, '\n'); }); it('handles dynamic reconfiguration', () => { const editor = new TextEditor.TextEditor.TextEditor(makeState('')); renderElementIntoDOM(editor); assert.strictEqual(editor.state.facet(CodeMirror.indentUnit), ' '); Common.Settings.Settings.instance().moduleSetting('text-editor-indent').set('\t'); assert.strictEqual(editor.state.facet(CodeMirror.indentUnit), '\t'); Common.Settings.Settings.instance().moduleSetting('text-editor-indent').set(' '); }); it('does not treat dashes as word chars in CSS', () => { const state = makeState('.some-selector {}', CodeMirror.css.cssLanguage); const {from, to} = state.wordAt(1)!; assert.strictEqual(state.sliceDoc(from, to), 'some'); }); }); describe('autocompletion', () => { it('can complete builtins and keywords', async () => { const state = makeState('c', CodeMirror.javascript.javascriptLanguage); const result = await TextEditor.JavaScript.javascriptCompletionSource(new CodeMirror.CompletionContext(state, 1, false)); assert.isNotNull(result); const completions = result ? result.options : []; assert.isTrue(completions.some(o => o.label === 'clear')); assert.isTrue(completions.some(o => o.label === 'continue')); }); async function testQueryType( code: string, pos: number, type?: TextEditor.JavaScript.QueryType, range = '', related?: string, ): Promise<void> { const state = makeState(code, CodeMirror.javascript.javascriptLanguage); const query = TextEditor.JavaScript.getQueryType(CodeMirror.syntaxTree(state), pos, state.doc); if (type === undefined) { assert.isNull(query); } else { assert.isNotNull(query); if (query) { assert.strictEqual(query.type, type); assert.strictEqual(code.slice(query.from ?? pos, pos), range); assert.strictEqual(query.relatedNode && code.slice(query.relatedNode.from, query.relatedNode.to), related); } } } it('recognizes expression queries', async () => { await testQueryType('foo', 3, TextEditor.JavaScript.QueryType.EXPRESSION, 'foo'); await testQueryType('foo ', 4, TextEditor.JavaScript.QueryType.EXPRESSION, ''); await testQueryType('let', 3, TextEditor.JavaScript.QueryType.EXPRESSION, 'let'); }); it('recognizes propery name queries', async () => { await testQueryType('foo.bar', 7, TextEditor.JavaScript.QueryType.PROPERTY_NAME, 'bar', 'foo.bar'); await testQueryType('foo.', 4, TextEditor.JavaScript.QueryType.PROPERTY_NAME, '', 'foo.'); await testQueryType('if (foo.', 8, TextEditor.JavaScript.QueryType.PROPERTY_NAME, '', 'foo.'); await testQueryType('new foo.bar().', 14, TextEditor.JavaScript.QueryType.PROPERTY_NAME, '', 'new foo.bar().'); await testQueryType('foo?.', 5, TextEditor.JavaScript.QueryType.PROPERTY_NAME, '', 'foo?.'); await testQueryType('foo?.b', 6, TextEditor.JavaScript.QueryType.PROPERTY_NAME, 'b', 'foo?.b'); }); it('recognizes property expression queries', async () => { await testQueryType('foo[', 4, TextEditor.JavaScript.QueryType.PROPERTY_EXPRESSION, '', 'foo['); await testQueryType('foo["ba', 7, TextEditor.JavaScript.QueryType.PROPERTY_EXPRESSION, '"ba', 'foo["ba'); }); describe('potential map key retrievals', () => { it('recognizes potential maps', async () => { await testQueryType('foo.get(', 8, TextEditor.JavaScript.QueryType.POTENTIALLY_RETRIEVING_FROM_MAP, '', 'foo'); await testQueryType( 'foo\n.get(', 9, TextEditor.JavaScript.QueryType.POTENTIALLY_RETRIEVING_FROM_MAP, '', 'foo'); }); it('leaves other expressions as-is', async () => { await testQueryType('foo.method(', 11, TextEditor.JavaScript.QueryType.EXPRESSION); await testQueryType('5 + (', 5, TextEditor.JavaScript.QueryType.EXPRESSION); await testQueryType('functionCall(', 13, TextEditor.JavaScript.QueryType.EXPRESSION); }); }); it('does not complete in inappropriate places', async () => { await testQueryType('"foo bar"', 4); await testQueryType('x["foo" + "bar', 14); await testQueryType('// comment', 10); }); }); it('dispatching a transaction from a saved editor reference should not throw an error', () => { const textEditor = new TextEditor.TextEditor.TextEditor(makeState('one')); const editorViewA = textEditor.editor; renderElementIntoDOM(textEditor); // textEditor.editor references to EditorView A. textEditor.dispatch({changes: {from: 0, insert: 'a'}}); // `disconnectedCallback` removed `textEditor.#activeEditor` // so reaching to `textEditor.editor` will create a new EditorView after this. textEditor.remove(); // EditorView B is created from the previous state // and EditorView B's state is diverged from previous state after this transaction. textEditor.dispatch({changes: {from: 0, insert: 'b'}}); // directly dispatching from Editor A now calls `textEditor.editor.update` // which references to EditorView B that has a different state. assert.doesNotThrow(() => editorViewA.dispatch({changes: {from: 3, insert: '!'}})); editorViewA.destroy(); }); }); describeWithMockConnection('TextEditor autocompletion', () => { it('does not complete on language plugin frames', async () => { const executionContext = new MockExecutionContext(createTarget()); const {debuggerModel} = executionContext; UI.Context.Context.instance().setFlavor(SDK.RuntimeModel.ExecutionContext, executionContext); const workspace = Workspace.Workspace.WorkspaceImpl.instance(); const targetManager = SDK.TargetManager.TargetManager.instance(); const resourceMapping = new Bindings.ResourceMapping.ResourceMapping(targetManager, workspace); const {pluginManager} = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance( {forceNew: true, targetManager, resourceMapping}); const testScript = debuggerModel.parsedScriptSource( '1' as Protocol.Runtime.ScriptId, urlString`script://1`, 0, 0, 0, 0, executionContext.id, '', undefined, false, undefined, false, false, 0, null, null, null, null, null, null, null); const payload: Protocol.Debugger.CallFrame = { callFrameId: '0' as Protocol.Debugger.CallFrameId, functionName: 'test', functionLocation: undefined, location: { scriptId: testScript.scriptId, lineNumber: 0, columnNumber: 0, }, url: 'test-url', scopeChain: [], this: {type: 'object'} as Protocol.Runtime.RemoteObject, returnValue: undefined, canBeRestarted: false, }; const callframe = new SDK.DebuggerModel.CallFrame(debuggerModel, testScript, payload); executionContext.debuggerModel.setSelectedCallFrame(callframe); pluginManager.addPlugin(new class extends TestPlugin { constructor() { super('TextEditorTestPlugin'); } override handleScript(script: SDK.Script.Script) { return script === testScript; } }()); const state = makeState('c', CodeMirror.javascript.javascriptLanguage); const result = await TextEditor.JavaScript.javascriptCompletionSource(new CodeMirror.CompletionContext(state, 1, false)); assert.isNull(result); }); });