Bug 1542277 - [devtools] Fix Inspector search autocomplete when using selector search with pseudo selectors. r=devtools-reviewers,jdescottes.

This patch adds support for the "pseudo" selector state in the inspector search,
which is already handled in the server (in WalkerActor#getSuggestionsForQuery).

Differential Revision: https://phabricator.services.mozilla.com/D250896
This commit is contained in:
Nicolas Chevobbe
2025-05-24 08:28:36 +00:00
committed by nchevobbe@mozilla.com
parent 2ce6dfa245
commit 76957c0da0
3 changed files with 239 additions and 16 deletions

View File

@@ -202,6 +202,11 @@ class SelectorAutocompleter extends EventEmitter {
ID: "id",
TAG: "tag",
ATTRIBUTE: "attribute",
// This is for pseudo classes (e.g. `:active`, `:not()`). We keep it as "pseudo" as
// the server handles both pseudo elements and pseudo classes under the same type
PSEUDO_CLASS: "pseudo",
// This is for pseudo element (e.g. `::selection`)
PSEUDO_ELEMENT: "pseudo-element",
};
// The current state of the query.
@@ -263,6 +268,10 @@ class SelectorAutocompleter extends EventEmitter {
this.#state = this.States.ID;
} else if (lastChar === "[") {
this.#state = this.States.ATTRIBUTE;
} else if (lastChar === ":") {
this.#state = this.States.PSEUDO_CLASS;
} else if (lastChar === ")") {
this.#state = null;
} else {
this.#state = this.States.TAG;
}
@@ -278,6 +287,10 @@ class SelectorAutocompleter extends EventEmitter {
this.#state = this.States.ID;
} else if (lastChar === "[") {
this.#state = this.States.ATTRIBUTE;
} else if (lastChar === ":") {
this.#state = this.States.PSEUDO_CLASS;
} else if (lastChar === ")") {
this.#state = null;
} else {
this.#state = this.States.CLASS;
}
@@ -294,6 +307,10 @@ class SelectorAutocompleter extends EventEmitter {
this.#state = this.States.CLASS;
} else if (lastChar === "[") {
this.#state = this.States.ATTRIBUTE;
} else if (lastChar === ":") {
this.#state = this.States.PSEUDO_CLASS;
} else if (lastChar === ")") {
this.#state = null;
} else {
this.#state = this.States.ID;
}
@@ -309,11 +326,34 @@ class SelectorAutocompleter extends EventEmitter {
this.#state = this.States.CLASS;
} else if (lastChar === "#") {
this.#state = this.States.ID;
} else if (lastChar === ":") {
this.#state = this.States.PSEUDO_CLASS;
} else if (lastChar === ")") {
this.#state = null;
} else {
this.#state = this.States.ATTRIBUTE;
}
}
break;
case this.States.PSEUDO_CLASS:
if (lastChar === ":" && secondLastChar === ":") {
// We don't support searching for pseudo elements, so bail out when we
// see `::`
this.#state = this.States.PSEUDO_ELEMENT;
return this.#state;
}
if (lastChar === "(") {
this.#state = null;
} else if (lastChar === ".") {
this.#state = this.States.CLASS;
} else if (lastChar === "#") {
this.#state = this.States.ID;
} else {
this.#state = this.States.PSEUDO_CLASS;
}
break;
}
}
return this.#state;
@@ -417,7 +457,9 @@ class SelectorAutocompleter extends EventEmitter {
const items = [];
for (let [value, state] of suggestions) {
if (query.match(/[\s>+~]$/)) {
if (popupState === this.States.PSEUDO_CLASS) {
value = query.substring(0, query.lastIndexOf(":")) + value;
} else if (query.match(/[\s>+~]$/)) {
// for cases like 'div ', 'div >', 'div+' or 'div~'
value = query + value;
} else if (query.match(/[\s>+~][\.#a-zA-Z][^\s>+~\.#\[]*$/)) {
@@ -487,12 +529,17 @@ class SelectorAutocompleter extends EventEmitter {
const originalQuery = this.searchBox.value;
const state = this.state;
let firstPart = "";
let completing = "";
if (query.endsWith("*") || state === this.States.ATTRIBUTE) {
// Hide the popup if the query ends with * (because we don't want to
// suggest all nodes) or if it is an attribute selector (because
// it would give a lot of useless results).
if (
// Hide the popup if:
// - the query ends with * (because we don't want to suggest all nodes)
query.endsWith("*") ||
// - if it is an attribute selector (because it would give a lot of useless results).
state === this.States.ATTRIBUTE ||
// - if it is a pseudo element selector (we don't support it, see Bug 1097991)
state === this.States.PSEUDO_ELEMENT
) {
this.hidePopup();
this.emitForTests("processing-done", { query: originalQuery });
return;
@@ -507,16 +554,20 @@ class SelectorAutocompleter extends EventEmitter {
// - 'div.foo ~ s' returns 's'
// - 'div.foo x-el_1' returns 'x-el_1'
const matches = query.match(/[\s>+~]?(?<tag>[a-zA-Z0-9_-]*)$/);
firstPart = matches.groups.tag;
query = query.slice(0, query.length - firstPart.length);
completing = matches.groups.tag;
query = query.slice(0, query.length - completing.length);
} else if (state === this.States.CLASS) {
// gets the class that is being completed. For ex. '.foo.b' returns 'b'
firstPart = query.match(/\.([^\.]*)$/)[1];
query = query.slice(0, query.length - firstPart.length - 1);
completing = query.match(/\.([^\.]*)$/)[1];
query = query.slice(0, query.length - completing.length - 1);
} else if (state === this.States.ID) {
// gets the id that is being completed. For ex. '.foo#b' returns 'b'
firstPart = query.match(/#([^#]*)$/)[1];
query = query.slice(0, query.length - firstPart.length - 1);
completing = query.match(/#([^#]*)$/)[1];
query = query.slice(0, query.length - completing.length - 1);
} else if (state === this.States.PSEUDO_CLASS) {
// The getSuggestionsForQuery expects a pseudo element without the : prefix
completing = query.substring(query.lastIndexOf(":") + 1);
query = "";
}
// TODO: implement some caching so that over the wire request is not made
// everytime.
@@ -527,19 +578,25 @@ class SelectorAutocompleter extends EventEmitter {
let suggestions =
await this.inspector.commands.inspectorCommand.getSuggestionsForQuery(
query,
firstPart,
completing,
state
);
if (state === this.States.CLASS) {
firstPart = "." + firstPart;
completing = "." + completing;
} else if (state === this.States.ID) {
firstPart = "#" + firstPart;
completing = "#" + completing;
} else if (state === this.States.PSEUDO_CLASS) {
completing = ":" + completing;
// Remove pseudo-element suggestions, since the search does not work with them (Bug 1097991)
suggestions = suggestions.filter(
suggestion => !suggestion[0].startsWith("::")
);
}
// If there is a single tag match and it's what the user typed, then
// don't need to show a popup.
if (suggestions.length === 1 && suggestions[0][0] === firstPart) {
if (suggestions.length === 1 && suggestions[0][0] === completing) {
suggestions = [];
}

View File

@@ -405,6 +405,8 @@ fail-if = ["a11y_checks"] # Bug 1849028 clicked element may not be focusable and
["browser_inspector_search-suggests-ids-and-classes.js"]
["browser_inspector_search-suggests-pseudo.js"]
["browser_inspector_search_keyboard_shortcut_conflict.js"]
["browser_inspector_search_keyboard_trap.js"]

View File

@@ -0,0 +1,164 @@
/* Any copyright is dedicated to the Public Domain.
http://creativecommons.org/publicdomain/zero/1.0/ */
"use strict";
// Test that the selector-search input proposes pseudo elements
const TEST_URL = `<h1>Hello</h1>`;
add_task(async function () {
const { inspector } = await openInspectorForURL(
"data:text/html;charset=utf-8," + encodeURI(TEST_URL)
);
const TESTS = [
{
input: ":",
// we don't want to test the exact items that are suggested, as the test would fail
// when new pseudo are added in the platform.
// Only includes some items that should be suggested (`included`),
// and some that should not be (`notIncluded`)
suggestions: {
included: [":active", ":empty", ":focus"],
notIncluded: ["::selection", "::marker"],
},
inputAfterAcceptingSuggestion: ":active",
},
{
// For now we don't support searching for pseudo element (Bug 1097991),
// so the list should be empty
input: "::",
suggestions: {
included: [],
},
},
{
input: "h1:",
suggestions: {
included: ["h1:active", "h1:empty", "h1:focus"],
notIncluded: ["h1::selection", "h1::marker"],
},
inputAfterAcceptingSuggestion: "h1:active",
},
{
input: "h1:not",
suggestions: {
included: ["h1:not("],
notIncluded: ["h1:nth-child("],
},
inputAfterAcceptingSuggestion: "h1:not(",
},
{
input: "h1:empty:",
suggestions: {
included: ["h1:empty:active", "h1:empty:empty", "h1:empty:focus"],
notIncluded: ["h1::selection", "h1::marker"],
},
inputAfterAcceptingSuggestion: "h1:empty:active",
},
{
input: "h1:empty:no",
suggestions: {
included: ["h1:empty:not("],
notIncluded: ["h1:empty:nth-child("],
},
inputAfterAcceptingSuggestion: "h1:empty:not(",
},
{
input: "body > h1:",
suggestions: {
included: ["body > h1:active", "body > h1:empty", "body > h1:focus"],
notIncluded: ["body > h1::selection", "body > h1::marker"],
},
inputAfterAcceptingSuggestion: "body > h1:active",
},
{
input: "body > h1:no",
suggestions: {
included: ["body > h1:not("],
notIncluded: ["body > h1:nth-child("],
},
inputAfterAcceptingSuggestion: "body > h1:not(",
},
];
info("Focus the search box");
await focusSearchBoxUsingShortcut(inspector.panelWin);
const searchInputEl = inspector.panelWin.document.getElementById(
"inspector-searchbox"
);
const { searchPopup } = inspector.searchSuggestions;
for (const { input, suggestions, inputAfterAcceptingSuggestion } of TESTS) {
info(`Checking suggestions for "${input}"`);
const onPopupOpened = searchPopup.once("popup-opened");
// the query for getting suggestions is not throttled and is fired for every char
// being typed, so we avoid using EventUtils.sendString for the whole input to avoid
// dealing with multiple events. Instead, put the value directly in the input, and only
// type the last char.
const onProcessingDone =
inspector.searchSuggestions.once("processing-done");
searchInputEl.value = input.substring(0, input.length - 1);
EventUtils.sendChar(input.at(-1), inspector.panelWin);
info("Wait for search query to complete");
await onProcessingDone;
const actualSuggestions = Array.from(
searchPopup.list.querySelectorAll("li")
).map(li => li.textContent);
if (!suggestions.included.length) {
const res = await Promise.race([
onPopupOpened,
wait(1000).then(() => "TIMEOUT"),
]);
is(res, "TIMEOUT", "popup did not open");
} else {
await onPopupOpened;
ok(true, "suggestions popup opened");
}
for (const expectedLabel of suggestions.included) {
ok(
actualSuggestions.some(s => s === expectedLabel),
`"${expectedLabel}" is in the list of suggestions for "${input}" (full list: ${JSON.stringify(actualSuggestions)})`
);
}
for (const unexpectedLabel of suggestions.notIncluded || []) {
ok(
!actualSuggestions.some(s => s === unexpectedLabel),
`"${unexpectedLabel}" is not in the list of suggestions for "${input}"`
);
}
if (inputAfterAcceptingSuggestion) {
info("Press tab to fill the search input with the first suggestion");
const onSuggestionAccepted =
inspector.searchSuggestions.once("processing-done");
const onPopupClosed = searchPopup.once("popup-closed");
EventUtils.synthesizeKey("VK_TAB", {}, inspector.panelWin);
await onSuggestionAccepted;
await onPopupClosed;
is(
searchInputEl.value,
inputAfterAcceptingSuggestion,
"input has expected value after accepting suggestion"
);
}
info("Clear the input");
const onSearchCleared = inspector.search.once("search-cleared");
const onEmptySearchSuggestionProcessingDone =
inspector.searchSuggestions.once("processing-done");
// select the whole input and hit backspace to clear it
searchInputEl.setSelectionRange(0, searchInputEl.value.length);
EventUtils.synthesizeKey("VK_BACK_SPACE", {}, inspector.panelWin);
await onSearchCleared;
await onEmptySearchSuggestionProcessingDone;
}
});