/* Any copyright is dedicated to the Public Domain.
   http://creativecommons.org/publicdomain/zero/1.0/ */

"use strict";

Services.scriptloader.loadSubScript(
  "chrome://mochitests/content/browser/devtools/server/tests/browser/inspector-helpers.js",
  this
);

// Test for Bug 835896
// WalkerSearch specific tests.  This is to make sure search results are
// coming back as expected.
// See also test_inspector-search-front.html.

add_task(async function () {
  const { walker } = await initInspectorFront(
    MAIN_DOMAIN + "inspector-search-data.html"
  );

  await SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [[walker.actorID]],
    async function (actorID) {
      const { require } = ChromeUtils.importESModule(
        "resource://devtools/shared/loader/Loader.sys.mjs"
      );
      const {
        DevToolsServer,
      } = require("resource://devtools/server/devtools-server.js");
      const {
        DocumentWalker: _documentWalker,
      } = require("resource://devtools/server/actors/inspector/document-walker.js");

      // Convert actorID to current compartment string otherwise
      // searchAllConnectionsForActor is confused and won't find the actor.
      actorID = String(actorID);
      const walkerActor = DevToolsServer.searchAllConnectionsForActor(actorID);
      const walkerSearch = walkerActor.walkerSearch;
      const {
        WalkerSearch,
        WalkerIndex,
      } = require("resource://devtools/server/actors/utils/walker-search.js");

      info("Testing basic index APIs exist.");
      const index = new WalkerIndex(walkerActor);
      Assert.greater(
        index.data.size,
        0,
        "public index is filled after getting"
      );

      index.clearIndex();
      ok(!index._data, "private index is empty after clearing");
      Assert.greater(
        index.data.size,
        0,
        "public index is filled after getting"
      );

      index.destroy();

      info("Testing basic search APIs exist.");

      ok(walkerSearch, "walker search exists on the WalkerActor");
      ok(walkerSearch.search, "walker search has `search` method");
      ok(walkerSearch.index, "walker search has `index` property");
      is(
        walkerSearch.walker,
        walkerActor,
        "referencing the correct WalkerActor"
      );

      const walkerSearch2 = new WalkerSearch(walkerActor);
      ok(walkerSearch2, "a new search instance can be created");
      ok(walkerSearch2.search, "new search instance has `search` method");
      ok(walkerSearch2.index, "new search instance has `index` property");
      isnot(
        walkerSearch2,
        walkerSearch,
        "new search instance differs from the WalkerActor's"
      );

      walkerSearch2.destroy();

      info("Testing search with an empty query.");
      let results = walkerSearch.search("");
      is(results.length, 0, "No results when searching for ''");

      results = walkerSearch.search(null);
      is(results.length, 0, "No results when searching for null");

      results = walkerSearch.search(undefined);
      is(results.length, 0, "No results when searching for undefined");

      results = walkerSearch.search(10);
      is(results.length, 0, "No results when searching for 10");

      const inspectee = content.document;
      const testData = [
        {
          desc: "Search for tag with one result.",
          search: "body",
          expected: [
            { node: inspectee.body, type: "xpath" },
            { node: inspectee.body, type: "tag" },
          ],
        },
        {
          desc: "Search for tag with multiple results",
          search: "h2",
          expected: [
            {
              node: inspectee.querySelector("h2:nth-of-type(1)"),
              type: "selector",
            },
            { node: inspectee.querySelector("h2:nth-of-type(1)"), type: "tag" },
            {
              node: inspectee.querySelector("h2:nth-of-type(2)"),
              type: "selector",
            },
            { node: inspectee.querySelector("h2:nth-of-type(2)"), type: "tag" },
            {
              node: inspectee.querySelector("h2:nth-of-type(3)"),
              type: "selector",
            },
            { node: inspectee.querySelector("h2:nth-of-type(3)"), type: "tag" },
          ],
        },
        {
          desc: "Search for selector with multiple results",
          search: "body > h2",
          expected: [
            { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
          ],
        },
        {
          desc: "Search for selector with multiple results",
          search: ":root h2",
          expected: [
            { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
          ],
        },
        {
          desc: "Search for selector with multiple results",
          search: "* h2",
          expected: [
            { node: inspectee.querySelectorAll("h2")[0], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[1], type: "selector" },
            { node: inspectee.querySelectorAll("h2")[2], type: "selector" },
          ],
        },
        {
          desc: "Search for selector with :has()",
          search: "article:has(p)",
          expected: [
            {
              node: inspectee.querySelectorAll("aside article")[0],
              type: "selector",
            },
            {
              node: inspectee.querySelectorAll("aside article")[2],
              type: "selector",
            },
          ],
        },
        {
          desc: "Search with multiple matches in a single tag expecting a single result",
          search: "💩",
          expected: [
            { node: inspectee.getElementById("💩"), type: "attributeName" },
            { node: inspectee.getElementById("💩"), type: "attributeValue" },
          ],
        },
        {
          desc: "Search for attributeName=attributeValue pairs without quotation marks",
          search: "id=arrows",
          expected: [
            { node: inspectee.getElementById("arrows"), type: "attributeName" },
          ],
        },
        {
          desc: "Search for attributeName=attributeValue pairs with quotation marks",
          search: 'id="arrows"',
          expected: [
            { node: inspectee.getElementById("arrows"), type: "attributeName" },
          ],
        },
        {
          desc: "Search for attributeName=attributeValue pairs with partial quotation marks",
          search: 'id="arr',
          expected: [
            { node: inspectee.getElementById("arrows"), type: "attributeName" },
          ],
        },
        {
          desc: `Search for unmatched attributeName="attr"`,
          search: 'id="arr"',
          expected: [],
        },
        {
          desc: "Search for attributeName=",
          search: "id=",
          expected: [
            { node: inspectee.getElementById("pseudo"), type: "attributeName" },
            { node: inspectee.getElementById("arrows"), type: "attributeName" },
            { node: inspectee.getElementById("💩"), type: "attributeName" },
            {
              node: inspectee.getElementById("with-hyphen"),
              type: "attributeName",
            },
          ],
        },
        {
          desc: "Search for =attributeValue",
          search: "=arr",
          expected: [
            {
              node: inspectee.getElementById("arrows"),
              type: "attributeValue",
            },
          ],
        },
        {
          desc: `Search for ="attributeValue`,
          search: `="arr`,
          expected: [
            {
              node: inspectee.getElementById("arrows"),
              type: "attributeValue",
            },
          ],
        },
        {
          desc: `Search for ="attributeValue"`,
          search: `="arrows"`,
          expected: [
            {
              node: inspectee.getElementById("arrows"),
              type: "attributeValue",
            },
          ],
        },
        {
          desc: `Search for unmatched ="attributeValue"`,
          search: `="arr"`,
          expected: [],
        },
        {
          desc: "Search that has tag and text results",
          search: "h1",
          expected: [
            { node: inspectee.querySelector("h1"), type: "selector" },
            { node: inspectee.querySelector("h1"), type: "tag" },
            {
              node: inspectee.querySelector("h1 + p").childNodes[0],
              type: "text",
            },
            {
              node: inspectee.querySelector("h1 + p > strong").childNodes[0],
              type: "text",
            },
          ],
        },
        {
          desc: "Search for XPath with one result",
          search: "//strong",
          expected: [
            { node: inspectee.querySelector("strong"), type: "xpath" },
          ],
        },
        {
          desc: "Search for XPath with multiple results",
          search: "//h2",
          expected: [
            { node: inspectee.querySelectorAll("h2")[0], type: "xpath" },
            { node: inspectee.querySelectorAll("h2")[1], type: "xpath" },
            { node: inspectee.querySelectorAll("h2")[2], type: "xpath" },
          ],
        },
        {
          desc: "Search for XPath via containing text",
          search: "//*[contains(text(), 'p tag')]",
          expected: [{ node: inspectee.querySelector("p"), type: "xpath" }],
        },
        {
          desc: "Search for XPath via strict equal text",
          search: "//*[text()='Heading 1']",
          expected: [
            { node: inspectee.querySelector("h1#pseudo"), type: "xpath" },
          ],
        },
        {
          desc: "Search for XPath matching text node",
          search: "//strong/text()",
          expected: [
            {
              node: inspectee.querySelector("strong").firstChild,
              type: "xpath",
            },
          ],
        },
        {
          desc: "Search using XPath grouping expression",
          search: "(//*)[2]",
          expected: [{ node: inspectee.querySelector("head"), type: "xpath" }],
        },
        {
          desc: "Search using XPath function",
          search: "id('arrows')",
          expected: [
            { node: inspectee.querySelector("#arrows"), type: "xpath" },
          ],
        },
        {
          desc: "Search using div + id with hyphen",
          search: "div#with-hyphen",
          expected: [
            {
              node: inspectee.querySelector("div#with-hyphen"),
              type: "selector",
            },
          ],
        },
        {
          desc: "Search using div + class with hyphen",
          search: "div.with-hyphen",
          expected: [
            {
              node: inspectee.querySelector("div.with-hyphen"),
              type: "selector",
            },
          ],
        },
      ];

      const assertSearchResults = (searchResults, expectedResults, msg) => {
        is(
          searchResults.length,
          expectedResults.length,
          `${msg} - got expected number of results`
        );
        if (searchResults.length === expectedResults.length) {
          searchResults.forEach((result, i) => {
            const { type, node } = expectedResults[i];
            is(result.type, type, `${msg} - result #${i} type`);
            if (result.node != node) {
              const displayNode = el => {
                return `<${el.nodeName.toLowerCase()}${el.id ? "#" + el.id : ""}>`;
              };
              ok(
                false,
                `${msg} - result #${i} unexpected node: Got ${displayNode(result.node)}, expected ${displayNode(node)}`
              );
            }
          });
        }
      };

      for (const { desc, search, expected } of testData) {
        info("Running test: " + desc);
        results = walkerSearch.search(search);
        assertSearchResults(
          results,
          expected,
          "Search returns correct results with '" + search + "'"
        );
      }

      info("Testing ::before and ::after element matching");

      const beforeElt = new _documentWalker(
        inspectee.querySelector("#pseudo"),
        inspectee.defaultView
      ).firstChild();
      const afterElt = new _documentWalker(
        inspectee.querySelector("#pseudo"),
        inspectee.defaultView
      ).lastChild();
      const styleText = inspectee.querySelector("style").childNodes[0];

      // ::before
      results = walkerSearch.search("::before");
      assertSearchResults(
        results,
        [{ node: beforeElt, type: "tag" }],
        "Tag search works for pseudo element"
      );

      results = walkerSearch.search("_moz_generated_content_before");
      is(results.length, 0, "No results for anon tag name");

      results = walkerSearch.search("before element");
      assertSearchResults(
        results,
        [
          { node: styleText, type: "text" },
          { node: beforeElt, type: "text" },
        ],
        "Text search works for pseudo element"
      );

      // ::after
      results = walkerSearch.search("::after");
      assertSearchResults(
        results,
        [{ node: afterElt, type: "tag" }],
        "Tag search works for pseudo element"
      );

      results = walkerSearch.search("_moz_generated_content_after");
      is(results.length, 0, "No results for anon tag name");

      results = walkerSearch.search("after element");
      assertSearchResults(
        results,
        [
          { node: styleText, type: "text" },
          { node: afterElt, type: "text" },
        ],
        "Text search works for pseudo element"
      );

      info("Testing search before and after a mutation.");
      const expected = [
        {
          node: inspectee.querySelector("h3:nth-of-type(1)"),
          type: "selector",
        },
        { node: inspectee.querySelector("h3:nth-of-type(1)"), type: "tag" },
        {
          node: inspectee.querySelector("h3:nth-of-type(2)"),
          type: "selector",
        },
        { node: inspectee.querySelector("h3:nth-of-type(2)"), type: "tag" },
        {
          node: inspectee.querySelector("h3:nth-of-type(3)"),
          type: "selector",
        },
        { node: inspectee.querySelector("h3:nth-of-type(3)"), type: "tag" },
      ];

      results = walkerSearch.search("h3");
      assertSearchResults(results, expected, "Search works with tag results");

      function mutateDocumentAndWaitForMutation(mutationFn) {
        // eslint-disable-next-line new-cap
        return new Promise(resolve => {
          info("Listening to markup mutation on the inspectee");
          const observer = new inspectee.defaultView.MutationObserver(resolve);
          observer.observe(inspectee, { childList: true, subtree: true });
          mutationFn();
        });
      }
      await mutateDocumentAndWaitForMutation(() => {
        expected[0].node.remove();
      });

      results = walkerSearch.search("h3");
      assertSearchResults(
        results,
        [expected[2], expected[3], expected[4], expected[5]],
        "Results are updated after removal"
      );

      // eslint-disable-next-line new-cap
      await new Promise(resolve => {
        info("Waiting for a mutation to happen");
        const observer = new inspectee.defaultView.MutationObserver(() => {
          resolve();
        });
        observer.observe(inspectee, { attributes: true, subtree: true });
        inspectee.body.setAttribute("h3", "true");
      });

      results = walkerSearch.search("h3");
      assertSearchResults(
        results,
        [
          { node: inspectee.body, type: "attributeName" },
          expected[2],
          expected[3],
          expected[4],
          expected[5],
        ],
        "Results are updated after addition"
      );

      // eslint-disable-next-line new-cap
      await new Promise(resolve => {
        info("Waiting for a mutation to happen");
        const observer = new inspectee.defaultView.MutationObserver(() => {
          resolve();
        });
        observer.observe(inspectee, {
          attributes: true,
          childList: true,
          subtree: true,
        });
        inspectee.body.removeAttribute("h3");
        expected[2].node.remove();
        expected[4].node.remove();
      });

      results = walkerSearch.search("h3");
      is(results.length, 0, "Results are updated after removal");
    }
  );
});
