[elements] Use lossless encoding for pseudo-element hide class names

Encode characters outside [a-zA-Z0-9-] (including underscores) as hex
escape sequences when building the helper class name in
toggleClassAndInjectStyleRule. This prevents distinct pseudo-element
names (such as non-ASCII identifiers of the same length or identifiers
differing in escaped characters vs. underscores) from colliding on the
same class name.

Fixed: 570543683
Change-Id: If1273876ff63bea5c5289acf1bfc41f86f3eaa97
TAG=agy
CONV=aa392134-a395-41af-85d6-48bab269fd1e
Reviewed-on: https://chromium-review.googlesource.com/c/devtools/devtools-frontend/+/8548894
Commit-Queue: Yang Guo <yangguo@chromium.org>
Reviewed-by: Mathias Bynens <mathias@chromium.org>
Auto-Submit: Yang Guo <yangguo@chromium.org>
diff --git a/front_end/core/sdk/DOMModel.test.ts b/front_end/core/sdk/DOMModel.test.ts
index be30d07..c762e88 100644
--- a/front_end/core/sdk/DOMModel.test.ts
+++ b/front_end/core/sdk/DOMModel.test.ts
@@ -936,6 +936,128 @@
         assert.deepEqual(callFunction.firstCall.args[1],
                          [{value: '::view-transition-group(\\31 23\\.foo)'}, {value: true}]);
       });
+
+      it('generates distinct classes and style rules when hiding multiple pseudo elements', async function() {
+        const target = universe.createTarget();
+        const model = target.model(SDK.DOMModel.DOMModel) as SDK.DOMModel.DOMModel;
+        const parentNode = SDK.DOMModel.DOMNode.create(model, null, false, {
+          nodeId: 1 as Protocol.DOM.NodeId,
+          backendNodeId: 1 as Protocol.DOM.BackendNodeId,
+          nodeType: NodeType.ELEMENT_NODE,
+          nodeName: 'DIV',
+          localName: 'div',
+          nodeValue: '',
+          pseudoElements: [
+            {
+              nodeId: 2 as Protocol.DOM.NodeId,
+              backendNodeId: 2 as Protocol.DOM.BackendNodeId,
+              nodeType: NodeType.ELEMENT_NODE,
+              nodeName: '::view-transition-group',
+              localName: '::view-transition-group',
+              nodeValue: '',
+              pseudoType: ProtocolModule.DOM.PseudoType.ViewTransitionGroup,
+              pseudoIdentifier: 'ヘッダー',
+            },
+            {
+              nodeId: 3 as Protocol.DOM.NodeId,
+              backendNodeId: 3 as Protocol.DOM.BackendNodeId,
+              nodeType: NodeType.ELEMENT_NODE,
+              nodeName: '::view-transition-group',
+              localName: '::view-transition-group',
+              nodeValue: '',
+              pseudoType: ProtocolModule.DOM.PseudoType.ViewTransitionGroup,
+              pseudoIdentifier: 'フッター',
+            },
+            {
+              nodeId: 4 as Protocol.DOM.NodeId,
+              backendNodeId: 4 as Protocol.DOM.BackendNodeId,
+              nodeType: NodeType.ELEMENT_NODE,
+              nodeName: '::view-transition-group',
+              localName: '::view-transition-group',
+              nodeValue: '',
+              pseudoType: ProtocolModule.DOM.PseudoType.ViewTransitionGroup,
+              pseudoIdentifier: 'a.b',
+            },
+            {
+              nodeId: 5 as Protocol.DOM.NodeId,
+              backendNodeId: 5 as Protocol.DOM.BackendNodeId,
+              nodeType: NodeType.ELEMENT_NODE,
+              nodeName: '::view-transition-group',
+              localName: '::view-transition-group',
+              nodeValue: '',
+              pseudoType: ProtocolModule.DOM.PseudoType.ViewTransitionGroup,
+              pseudoIdentifier: 'a b',
+            },
+            {
+              nodeId: 6 as Protocol.DOM.NodeId,
+              backendNodeId: 6 as Protocol.DOM.BackendNodeId,
+              nodeType: NodeType.ELEMENT_NODE,
+              nodeName: '::view-transition-group',
+              localName: '::view-transition-group',
+              nodeValue: '',
+              pseudoType: ProtocolModule.DOM.PseudoType.ViewTransitionGroup,
+              pseudoIdentifier: 'a__b',
+            },
+          ],
+        });
+        const vtNodes = parentNode.pseudoElements().get(ProtocolModule.DOM.PseudoType.ViewTransitionGroup);
+        assert.exists(vtNodes);
+        assert.lengthOf(vtNodes, 5);
+
+        const classes = new Set<string>();
+        const styleClasses = new Set<string>();
+        const style = {
+          textContent: '',
+          classList: {
+            contains: function(cls: string) {
+              return styleClasses.has(cls);
+            },
+            add: function(cls: string) {
+              styleClasses.add(cls);
+            },
+          },
+        };
+        const element = {
+          nodeType: 1,
+          parentNode: null,
+          classList: {
+            toggle: function(cls: string, force: boolean) {
+              if (force) {
+                classes.add(cls);
+              } else {
+                classes.delete(cls);
+              }
+            },
+          },
+          querySelector: function() {
+            return style;
+          },
+        };
+        const callFunction = sinon.stub().callsFake(function(fn: (this: unknown, ...args: unknown[]) => void,
+                                                             args: Array<{value: unknown}>) {
+          fn.apply(element, args.map(function(a) {
+            return a.value;
+          }));
+          return Promise.resolve({});
+        });
+        const release = sinon.stub();
+        sinon.stub(parentNode, 'resolveToObject').resolves({callFunction,
+                                                            release} as unknown as SDK.RemoteObject.RemoteObject);
+
+        for (const node of vtNodes) {
+          await node.toggleHideElement();
+        }
+
+        assert.strictEqual(classes.size, 5);
+        assert.include(style.textContent, '::view-transition-group(ヘッダー)');
+        assert.include(style.textContent, '::view-transition-group(フッター)');
+        assert.include(style.textContent, '::view-transition-group(a\\.b)');
+        assert.include(style.textContent, '::view-transition-group(a\\ b)');
+        assert.include(style.textContent, '::view-transition-group(a__b)');
+
+        await vtNodes[0].toggleHideElement();
+        assert.strictEqual(classes.size, 4);
+      });
     });
   });
 
diff --git a/front_end/core/sdk/PageFunctions.ts b/front_end/core/sdk/PageFunctions.ts
index 0c7d1d9..bfb203c 100644
--- a/front_end/core/sdk/PageFunctions.ts
+++ b/front_end/core/sdk/PageFunctions.ts
@@ -109,7 +109,12 @@
   const classNamePrefix = '__web-inspector-hide';
   const classNameSuffix = '-shortcut__';
   const styleTagId = '__web-inspector-hide-shortcut-style__';
-  const pseudoElementNameEscaped = pseudoElementName ? pseudoElementName.replace(/[^a-zA-Z0-9_-]/g, '_') : '';
+  let pseudoElementNameEscaped = '';
+  if (pseudoElementName) {
+    pseudoElementNameEscaped = pseudoElementName.replace(/[^a-zA-Z0-9-]/g, function(c) {
+      return '_' + c.charCodeAt(0).toString(16) + '_';
+    });
+  }
   const className = classNamePrefix + pseudoElementNameEscaped + classNameSuffix;
   this.classList.toggle(className, hidden);
 
@@ -117,7 +122,7 @@
   while (localRoot.parentNode) {
     localRoot = (localRoot.parentNode as Element);
   }
-  if (localRoot.nodeType === Node.DOCUMENT_NODE) {
+  if (localRoot.nodeType === 9 /* Node.DOCUMENT_NODE */) {
     localRoot = document.head;
   }