diff --git a/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js b/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js new file mode 100644 index 0000000..f1b638d --- /dev/null +++ b/devtools/client/debugger/test/mochitest/browser_dbg-project-search.js @@ -0,0 +1,306 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ + +// Testing various project search features + +"use strict"; + +requestLongerTimeout(3); + +add_task(async function testProjectSearchCloseOnNavigation() { + const dbg = await initDebugger( + "doc-script-switching.html", + "script-switching-01.js" + ); + + await selectSource(dbg, "script-switching-01.js"); + + await openProjectSearch(dbg); + + ok( + !findElement(dbg, "projectSearchRefreshButton"), + "The refresh button is only visible after having done a search" + ); + + await doProjectSearch(dbg, "function", 2); + + is(getExpandedResultsCount(dbg), 9); + + is(dbg.selectors.getActiveSearch(), "project"); + + const refreshButton = findElement(dbg, "projectSearchRefreshButton"); + ok( + refreshButton, + "Refresh button is visible right after search is completed" + ); + ok( + !refreshButton.classList.contains("highlight"), + "Refresh button is *not* highlighted by default" + ); + + await navigate(dbg, "doc-scripts.html"); + + // Project search is still visible after navigation + is(dbg.selectors.getActiveSearch(), "project"); + // With same search results + is(getExpandedResultsCount(dbg), 9); + + ok( + refreshButton.classList.contains("highlight"), + "Refresh button is highlighted after navigation" + ); + + info("Try to open a discarded source"); + await clickElement(dbg, "fileMatch"); + + info("Wait for the warning popup to be visible"); + // We are waiting for the popup to be added... + await waitFor(() => dbg.win.document.querySelector(".unavailable-source")); + // ...and verify that the popup is made visible. + await waitFor(() => dbg.win.document.querySelector(".tooltip-shown")); + info("Retry to open the discard source, this should hide the popup"); + await clickElement(dbg, "fileMatch"); + info("Wait for the popup to be hidden"); + // Note that .unavailable-source won't be removed from the DOM + await waitFor(() => !dbg.win.document.querySelector(".tooltip-visible")); + + info("Refresh results against the new page"); + refreshButton.click(); + + // Wait for the search to be updated against the new page + await waitForSearchResults(dbg, 5); + is(getExpandedResultsCount(dbg), 39); + ok( + !refreshButton.classList.contains("highlight"), + "Refresh button is no longer highlighted after refreshing the search" + ); +}); + +add_task(async function testSimpleProjectSearch() { + // Start with side panel collapsed so we can assert that the project search keyboard + // shortcut will open it. + await pushPref("devtools.debugger.start-panel-collapsed", true); + + const dbg = await initDebugger( + "doc-script-switching.html", + "script-switching-01.js" + ); + + await openProjectSearch(dbg); + + ok( + !!findElementWithSelector(dbg, ".project-text-search"), + "Project search is visible" + ); + + const searchTerm = "first"; + await doProjectSearch(dbg, searchTerm, 1); + + const queryMatch = findElement(dbg, "fileMatch").querySelector( + ".query-match" + ); + is( + queryMatch.innerText, + searchTerm, + "The highlighted text matches the search term" + ); + + info("Select a result match to open the location in the source"); + await clickElement(dbg, "fileMatch"); + await waitForSelectedSource(dbg, "script-switching-01.js"); + + info("Close start sidebar"); + const startPanelToggleButtonEl = findElementWithSelector( + dbg, + ".toggle-button.start" + ); + startPanelToggleButtonEl.click(); + await waitFor(() => startPanelToggleButtonEl.classList.contains("collapsed")); + + info("Try to open project search again"); + await openProjectSearch(dbg); + ok( + !!findElementWithSelector(dbg, ".project-text-search"), + "Project search is visible" + ); +}); + +add_task(async function testMatchesForRegexSearches() { + const dbg = await initDebugger("doc-react.html", "App.js"); + await openProjectSearch(dbg); + + type(dbg, "import .* from 'react'"); + await clickElement(dbg, "projectSearchModifiersRegexMatch"); + + await waitForSearchResults(dbg, 2); + + const queryMatch = findAllElements(dbg, "fileMatch")[1].querySelector( + ".query-match" + ); + + is( + queryMatch.innerText, + "import React, { Component } from 'react'", + "The highlighted text matches the search term" + ); + + // Turn off the regex modifier so does not break tests below + await clickElement(dbg, "projectSearchModifiersRegexMatch"); +}); + +// Test expanding search results to reveal the search matches. +add_task(async function testExpandSearchResultsToShowMatches() { + const dbg = await initDebugger("doc-react.html", "App.js"); + + await openProjectSearch(dbg); + await doProjectSearch(dbg, "we", 19); + + is(getExpandedResultsCount(dbg), 159); + + const collapsedNodes = findAllElements(dbg, "projectSearchCollapsed"); + is(collapsedNodes.length, 1); + + collapsedNodes[0].click(); + + is(getExpandedResultsCount(dbg), 367); +}); + +add_task(async function testSearchModifiers() { + const dbg = await initDebugger("doc-react.html", "App.js"); + + await openProjectSearch(dbg); + + await assertProjectSearchModifier( + dbg, + "projectSearchModifiersCaseSensitive", + "FIELDS", + "case sensitive", + { resultWithModifierOn: 0, resultWithModifierOff: 2 } + ); + await assertProjectSearchModifier( + dbg, + "projectSearchModifiersRegexMatch", + `\\*`, + "regex match", + { resultWithModifierOn: 12, resultWithModifierOff: 0 } + ); + await assertProjectSearchModifier( + dbg, + "projectSearchModifiersWholeWordMatch", + "so", + "whole word match", + { resultWithModifierOn: 6, resultWithModifierOff: 16 } + ); +}); + +add_task(async function testSearchExcludePatterns() { + const dbg = await initDebugger("doc-react.html", "App.js"); + + info("Search across all files"); + await openProjectSearch(dbg); + let fileResults = await doProjectSearch(dbg, "console", 5); + + let resultsFromNodeModules = [...fileResults].filter(result => + result.innerText.includes("node_modules") + ); + + is( + resultsFromNodeModules.length, + 3, + "3 results were found from node_modules" + ); + + info("Excludes search results based on multiple search patterns"); + + await clickElement(dbg, "excludePatternsInput"); + type(dbg, "App.js, main.js"); + pressKey(dbg, "Enter"); + + fileResults = await waitForSearchResults(dbg, 3); + + const resultsFromAppJS = [...fileResults].filter(result => + result.innerText.includes("App.js") + ); + + is(resultsFromAppJS.length, 0, "None of the results is from the App.js file"); + + const resultsFromMainJS = [...fileResults].filter(result => + result.innerText.includes("main.js") + ); + + is( + resultsFromMainJS.length, + 0, + "None of the results is from the main.js file" + ); + + info("Excludes search results from node modules files"); + + await clearElement(dbg, "excludePatternsInput"); + type(dbg, "**/node_modules/**"); + pressKey(dbg, "Enter"); + + fileResults = await waitForSearchResults(dbg, 2); + + resultsFromNodeModules = [...fileResults].filter(result => + result.innerText.includes("node_modules") + ); + + is( + resultsFromNodeModules.length, + 0, + "None of the results is from the node modules files" + ); + + info("Assert that the exclude pattern is persisted across reloads"); + await reloadSelectedTab(); + await openProjectSearch(dbg); + + const excludePatternsInputElement = await waitForElement( + dbg, + "excludePatternsInput" + ); + + is( + excludePatternsInputElement.value, + "**/node_modules/**", + "The exclude pattern for node modules is persisted accross reloads" + ); + + // Clear the fields so that it does not impact on the subsequent tests + await clearElement(dbg, "projectSearchSearchInput"); + await clearElement(dbg, "excludePatternsInput"); + pressKey(dbg, "Enter"); +}); + +async function assertProjectSearchModifier( + dbg, + searchModifierBtn, + searchTerm, + title, + expected +) { + info(`Assert ${title} search modifier`); + + type(dbg, searchTerm); + info(`Turn on the ${title} search modifier option`); + await clickElement(dbg, searchModifierBtn); + let results = await waitForSearchResults(dbg, expected.resultWithModifierOn); + is( + results.length, + expected.resultWithModifierOn, + `${results.length} results where found` + ); + + info(`Turn off the ${title} search modifier`); + await clickElement(dbg, searchModifierBtn); + + results = await waitForSearchResults(dbg, expected.resultWithModifierOff); + is( + results.length, + expected.resultWithModifierOff, + `${results.length} results where found` + ); + await clearElement(dbg, "projectSearchSearchInput"); +} diff --git a/devtools/client/shared/components/SearchInput.js b/devtools/client/shared/components/SearchInput.js new file mode 100644 index 0000000..74a8e8d --- /dev/null +++ b/devtools/client/shared/components/SearchInput.js @@ -0,0 +1,372 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at . */ + +"use strict"; + +const React = require("devtools/client/shared/vendor/react"); +const { Component } = React; + +const PropTypes = require("devtools/client/shared/vendor/react-prop-types"); +const { + button, + div, + label, + input, + span, +} = require("devtools/client/shared/vendor/react-dom-factories"); + +const DebuggerImage = require("devtools/client/shared/components/DebuggerImage"); +const CloseButton = require("devtools/client/shared/components/CloseButton"); + +const classnames = require("resource://devtools/client/shared/classnames.js"); +const SearchModifiers = require("resource://devtools/client/shared/components/SearchModifiers.js"); + +const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); +const locale = new LocalizationHelper( + "devtools/client/locales/components.properties" +); + +const arrowBtn = (onClick, type, className, tooltip) => { + const props = { + className, + key: type, + onClick, + title: tooltip, + type, + }; + return button( + props, + React.createElement(DebuggerImage, { + name: type, + }) + ); +}; + +class SearchInput extends Component { + static get propTypes() { + return { + count: PropTypes.number.isRequired, + expanded: PropTypes.bool.isRequired, + handleClose: PropTypes.func, + handleNext: PropTypes.func, + handlePrev: PropTypes.func, + hasPrefix: PropTypes.bool.isRequired, + isLoading: PropTypes.bool.isRequired, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onHistoryScroll: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyUp: PropTypes.func, + placeholder: PropTypes.string, + query: PropTypes.string, + selectedItemId: PropTypes.string, + shouldFocus: PropTypes.bool, + showClose: PropTypes.bool.isRequired, + showExcludePatterns: PropTypes.bool.isRequired, + excludePatternsLabel: PropTypes.string, + excludePatternsPlaceholder: PropTypes.string, + showErrorEmoji: PropTypes.bool.isRequired, + size: PropTypes.string, + disabled: PropTypes.bool, + summaryMsg: PropTypes.string, + searchKey: PropTypes.string.isRequired, + searchOptions: PropTypes.object.isRequired, + setSearchOptions: PropTypes.func.isRequired, + showSearchModifiers: PropTypes.bool.isRequired, + onToggleSearchModifier: PropTypes.func, + }; + } + + static defaultProps = { + expanded: false, + hasPrefix: false, + selectedItemId: "", + size: "", + showClose: true, + }; + + constructor(props) { + super(props); + this.state = { + history: [], + excludePatterns: this.props.showSearchModifiers + ? (props.searchOptions?.excludePatterns ?? "") + : null, + }; + } + + componentDidMount() { + this.setFocus(); + } + + componentDidUpdate(prevProps) { + if (this.props.shouldFocus && !prevProps.shouldFocus) { + this.setFocus(); + } + } + + setFocus() { + if (this.$input) { + const _input = this.$input; + _input.focus(); + + if (!_input.value) { + return; + } + + // omit prefix @:# from being selected + const selectStartPos = this.props.hasPrefix ? 1 : 0; + _input.setSelectionRange(selectStartPos, _input.value.length + 1); + } + } + + renderArrowButtons() { + const { handleNext, handlePrev } = this.props; + + return [ + arrowBtn( + handlePrev, + "arrow-up", + classnames("nav-btn", "prev"), + locale.getStr("editor.searchResults.prevResult"), + DebuggerImage + ), + arrowBtn( + handleNext, + "arrow-down", + classnames("nav-btn", "next"), + locale.getStr("editor.searchResults.nextResult"), + DebuggerImage + ), + ]; + } + + onFocus = e => { + const { onFocus } = this.props; + + if (onFocus) { + onFocus(e); + } + }; + + onBlur = e => { + const { onBlur } = this.props; + + if (onBlur) { + onBlur(e); + } + }; + + onKeyDown = e => { + const { onHistoryScroll, onKeyDown } = this.props; + if (!onHistoryScroll) { + onKeyDown(e); + return; + } + + const inputValue = e.target.value; + const { history } = this.state; + const currentHistoryIndex = history.indexOf(inputValue); + + if (e.key === "Enter") { + this.saveEnteredTerm(inputValue); + onKeyDown(e); + return; + } + + if (e.key === "ArrowUp") { + const previous = + currentHistoryIndex > -1 ? currentHistoryIndex - 1 : history.length - 1; + const previousInHistory = history[previous]; + if (previousInHistory) { + e.preventDefault(); + onHistoryScroll(previousInHistory); + } + return; + } + + if (e.key === "ArrowDown") { + const next = currentHistoryIndex + 1; + const nextInHistory = history[next]; + if (nextInHistory) { + onHistoryScroll(nextInHistory); + } + } + }; + + onExcludeKeyDown = e => { + if (e.key === "Enter") { + this.props.setSearchOptions(this.props.searchKey, { + excludePatterns: this.state.excludePatterns, + }); + this.props.onKeyDown(e); + } + }; + + saveEnteredTerm(query) { + const { history } = this.state; + const previousIndex = history.indexOf(query); + if (previousIndex !== -1) { + history.splice(previousIndex, 1); + } + history.push(query); + this.setState({ history }); + } + + renderSummaryMsg() { + const { summaryMsg } = this.props; + + if (!summaryMsg) { + return null; + } + return div( + { + className: "search-field-summary", + }, + summaryMsg + ); + } + + renderSpinner() { + const { isLoading } = this.props; + if (!isLoading) { + return null; + } + return React.createElement(DebuggerImage, { + name: "loader", + className: "spin", + }); + } + + renderNav() { + const { count, handleNext, handlePrev } = this.props; + if ((!handleNext && !handlePrev) || !count || count == 1) { + return null; + } + return div( + { + className: "search-nav-buttons", + }, + this.renderArrowButtons() + ); + } + + renderSearchModifiers() { + if (!this.props.showSearchModifiers) { + return null; + } + return React.createElement(SearchModifiers, { + modifiers: this.props.searchOptions, + onToggleSearchModifier: updatedOptions => { + this.props.setSearchOptions(this.props.searchKey, updatedOptions); + this.props.onToggleSearchModifier(); + }, + }); + } + + renderExcludePatterns() { + if (!this.props.showExcludePatterns) { + return null; + } + return div( + { + className: classnames("exclude-patterns-field", this.props.size), + }, + label(null, this.props.excludePatternsLabel), + input({ + placeholder: this.props.excludePatternsPlaceholder, + value: this.state.excludePatterns, + onKeyDown: this.onExcludeKeyDown, + onChange: e => + this.setState({ + excludePatterns: e.target.value, + }), + }) + ); + } + + renderClose() { + if (!this.props.showClose) { + return null; + } + return React.createElement( + React.Fragment, + null, + span({ + className: "pipe-divider", + }), + React.createElement(CloseButton, { + handleClick: this.props.handleClose, + buttonClass: this.props.size, + tooltip: locale.getStr("editor.searchResults.close"), + }) + ); + } + + render() { + const { + expanded, + onChange, + onKeyUp, + placeholder, + query, + selectedItemId, + showErrorEmoji, + size, + disabled, + } = this.props; + + const inputProps = { + className: classnames({ + empty: showErrorEmoji, + }), + disabled, + onChange, + onKeyDown: e => this.onKeyDown(e), + onKeyUp, + onFocus: e => this.onFocus(e), + onBlur: e => this.onBlur(e), + "aria-autocomplete": "list", + "aria-controls": "result-list", + "aria-activedescendant": + expanded && selectedItemId ? `${selectedItemId}-title` : "", + placeholder, + value: query, + spellCheck: false, + ref: c => (this.$input = c), + }; + return div( + { + className: "search-outline", + }, + div( + { + className: classnames("search-field", size), + role: "combobox", + "aria-haspopup": "listbox", + "aria-owns": "result-list", + "aria-expanded": expanded, + }, + React.createElement(DebuggerImage, { + name: "search", + }), + input(inputProps), + this.renderSpinner(), + this.renderSummaryMsg(), + this.renderNav(), + div( + { + className: "search-buttons-bar", + }, + this.renderSearchModifiers(), + this.renderClose() + ) + ), + this.renderExcludePatterns() + ); + } +} + +module.exports = SearchInput;