/* 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 <http://mozilla.org/MPL/2.0/>. */

/**
 * This test focuses on the SourceTree component, where we display all debuggable sources.
 *
 * The first two tests expand the tree via manual DOM events (first with clicks and second with keys).
 * `waitForSourcesInSourceTree()` is a key assertion method. Passing `{noExpand: true}`
 * is important to avoid automatically expand the source tree.
 *
 * The following tests depend on auto-expand and only assert all the sources possibly displayed
 */

"use strict";

const testServer = createVersionizedHttpTestServer(
  "examples/sourcemaps-reload-uncompressed"
);
const TEST_URL = testServer.urlFor("index.html");

/**
 * This test opens the SourceTree manually via click events on the nested source,
 * and then adds a source dynamically and asserts it is visible.
 */
add_task(async function testSimpleSourcesWithManualClickExpand() {
  const dbg = await initDebugger(
    "doc-sources.html",
    "simple1.js",
    "simple2.js",
    "nested-source.js",
    "long.js"
  );

  // Expand nodes and make sure more sources appear.
  is(
    getSourceTreeLabel(dbg, 1),
    "Main Thread",
    "Main thread is labeled properly"
  );
  info("Before interacting with the source tree, no source are displayed");
  await waitForSourcesInSourceTree(dbg, [], { noExpand: true });
  await clickElement(dbg, "sourceDirectoryLabel", 3);
  info(
    "After clicking on the directory, all sources but the nested ones are displayed"
  );
  await waitForSourcesInSourceTree(
    dbg,
    ["doc-sources.html", "simple1.js", "simple2.js", "long.js"],
    { noExpand: true }
  );

  await clickElement(dbg, "sourceDirectoryLabel", 4);
  info(
    "After clicking on the nested directory, the nested source is also displayed"
  );
  await waitForSourcesInSourceTree(
    dbg,
    [
      "doc-sources.html",
      "simple1.js",
      "simple2.js",
      "long.js",
      "nested-source.js",
    ],
    { noExpand: true }
  );

  const selected = waitForDispatch(dbg.store, "SET_SELECTED_LOCATION");
  await clickElement(dbg, "sourceNode", 5);
  await selected;
  await waitForSelectedSource(dbg, "nested-source.js");

  // Ensure the source file clicked is now focused
  await waitForElementWithSelector(dbg, ".sources-list .focused");

  const selectedSource = dbg.selectors.getSelectedSource().url;
  ok(selectedSource.includes("nested-source.js"), "nested-source is selected");
  await assertNodeIsFocused(dbg, 5);

  // Make sure new sources appear in the list.
  await SpecialPowers.spawn(gBrowser.selectedBrowser, [], function () {
    const script = content.document.createElement("script");
    script.src = "math.min.js";
    content.document.body.appendChild(script);
  });

  info("After adding math.min.js, we got a new source displayed");
  await waitForSourcesInSourceTree(
    dbg,
    [
      "doc-sources.html",
      "simple1.js",
      "simple2.js",
      "long.js",
      "nested-source.js",
      "math.min.js",
    ],
    { noExpand: true }
  );
  is(
    getSourceNodeLabel(dbg, 8),
    "math.min.js",
    "math.min.js - The dynamic script exists"
  );

  info("Assert that nested-source.js is still the selected source");
  await assertNodeIsFocused(dbg, 5);

  info("Test the copy to clipboard context menu");
  const mathMinTreeNode = findSourceNodeWithText(dbg, "math.min.js");
  await triggerSourceTreeContextMenu(
    dbg,
    mathMinTreeNode,
    "#node-menu-copy-source"
  );
  const clipboardData = SpecialPowers.getClipboardData("text/plain");
  is(
    clipboardData,
    EXAMPLE_URL + "math.min.js",
    "The clipboard content is the selected source URL"
  );

  info("Test the download file context menu");
  // Before trigerring the menu, mock the file picker
  const MockFilePicker = SpecialPowers.MockFilePicker;
  MockFilePicker.init(window.browsingContext);
  const nsiFile = new FileUtils.File(
    PathUtils.join(PathUtils.tempDir, `export_source_content_${Date.now()}.log`)
  );
  MockFilePicker.setFiles([nsiFile]);
  const path = nsiFile.path;

  await triggerSourceTreeContextMenu(
    dbg,
    mathMinTreeNode,
    "#node-menu-download-file"
  );

  info("Wait for the downloaded file to be fully saved to disk");
  await BrowserTestUtils.waitForCondition(() => IOUtils.exists(path));
  await BrowserTestUtils.waitForCondition(async () => {
    const { size } = await IOUtils.stat(path);
    return size > 0;
  });
  const buffer = await IOUtils.read(path);
  const savedFileContent = new TextDecoder().decode(buffer);

  const mathMinRequest = await fetch(EXAMPLE_URL + "math.min.js");
  const mathMinContent = await mathMinRequest.text();

  is(
    savedFileContent,
    mathMinContent,
    "The downloaded file has the expected content"
  );

  dbg.toolbox.closeToolbox();
});

/**
 * Test keyboard arrow behaviour on the SourceTree with a nested folder
 * that we manually expand/collapse via arrow keys.
 */
add_task(async function testSimpleSourcesWithManualKeyShortcutsExpand() {
  const dbg = await initDebugger(
    "doc-sources.html",
    "simple1.js",
    "simple2.js",
    "nested-source.js",
    "long.js"
  );

  // Before clicking on the source label, no source is displayed
  await waitForSourcesInSourceTree(dbg, [], { noExpand: true });
  await clickElement(dbg, "sourceDirectoryLabel", 3);
  // Right after, all sources, but the nested one are displayed
  await waitForSourcesInSourceTree(
    dbg,
    ["doc-sources.html", "simple1.js", "simple2.js", "long.js"],
    { noExpand: true }
  );

  // Right key on open dir
  await pressKey(dbg, "Right");
  await assertNodeIsFocused(dbg, 3);

  // Right key on closed dir
  await pressKey(dbg, "Right");
  await assertNodeIsFocused(dbg, 4);

  // Left key on a open dir
  await pressKey(dbg, "Left");
  await assertNodeIsFocused(dbg, 4);

  // Down key on a closed dir
  await pressKey(dbg, "Down");
  await assertNodeIsFocused(dbg, 4);

  // Right key on a source
  // We are focused on the nested source and up to this point we still display only the 4 initial sources
  await waitForSourcesInSourceTree(
    dbg,
    ["doc-sources.html", "simple1.js", "simple2.js", "long.js"],
    { noExpand: true }
  );
  await pressKey(dbg, "Right");
  await assertNodeIsFocused(dbg, 4);
  // Now, the nested source is also displayed
  await waitForSourcesInSourceTree(
    dbg,
    [
      "doc-sources.html",
      "simple1.js",
      "simple2.js",
      "long.js",
      "nested-source.js",
    ],
    { noExpand: true }
  );

  // Down key on a source
  await pressKey(dbg, "Down");
  await assertNodeIsFocused(dbg, 5);

  // Go to bottom of tree and press down key
  await pressKey(dbg, "Down");
  await pressKey(dbg, "Down");
  await assertNodeIsFocused(dbg, 6);

  // Up key on a source
  await pressKey(dbg, "Up");
  await assertNodeIsFocused(dbg, 5);

  // Left key on a source
  await pressKey(dbg, "Left");
  await assertNodeIsFocused(dbg, 4);

  // Left key on a closed dir
  // We are about to close the nested folder, the nested source is about to disappear
  await waitForSourcesInSourceTree(
    dbg,
    [
      "doc-sources.html",
      "simple1.js",
      "simple2.js",
      "long.js",
      "nested-source.js",
    ],
    { noExpand: true }
  );
  await pressKey(dbg, "Left");
  // And it disappeared
  await waitForSourcesInSourceTree(
    dbg,
    ["doc-sources.html", "simple1.js", "simple2.js", "long.js"],
    { noExpand: true }
  );
  await pressKey(dbg, "Left");
  await assertNodeIsFocused(dbg, 3);

  // Up Key at the top of the source tree
  await pressKey(dbg, "Up");
  await assertNodeIsFocused(dbg, 2);
  dbg.toolbox.closeToolbox();
});

/**
 * Tests that the source tree works with all the various types of sources
 * coming from the integration test page.
 *
 * Also assert a few extra things on sources with query strings:
 *  - they can be pretty printed,
 *  - quick open matches them,
 *  - you can set breakpoint on them.
 */
add_task(async function testSourceTreeOnTheIntegrationTestPage() {
  // We open against a blank page and only then navigate to the test page
  // so that sources aren't GC-ed before opening the debugger.
  // When we (re)load a page while the debugger is opened, the debugger
  // will force all sources to be held in memory.
  const dbg = await initDebuggerWithAbsoluteURL("about:blank");

  await navigateToAbsoluteURL(
    dbg,
    TEST_URL,
    "index.html",
    "script.js",
    "test-functions.js",
    "query.js?x=1",
    "query.js?x=2",
    "query2.js?y=3",
    "bundle.js",
    "original.js",
    "replaced-bundle.js",
    "removed-original.js",
    "named-eval.js"
  );

  info("Verify source tree content");
  await waitForSourcesInSourceTree(dbg, INTEGRATION_TEST_PAGE_SOURCES);

  info("Verify Thread Source Items");
  const mainThreadItem = findSourceTreeThreadByName(dbg, "Main Thread");
  ok(mainThreadItem, "Found the thread item for the main thread");
  ok(
    mainThreadItem.querySelector("span.img.window"),
    "The thread has the window icon"
  );

  info(
    "Assert the number of sources and source actors for the same-url.sjs sources"
  );
  const sameUrlSource = findSource(dbg, "same-url.sjs");
  ok(sameUrlSource, "Found same-url.js in the main thread");

  const sourceActors = dbg.selectors.getSourceActorsForSource(sameUrlSource.id);

  const mainThread = dbg.selectors
    .getAllThreads()
    .find(thread => thread.name == "Main Thread");

  // When EFT is disabled the iframe's source is meld into the main target.
  const expectedSameUrlSources = isEveryFrameTargetEnabled() ? 3 : 4;
  is(
    sourceActors.filter(actor => actor.thread == mainThread.actor).length,
    expectedSameUrlSources,
    `same-url.js is loaded ${expectedSameUrlSources} times in the main thread`
  );

  if (isEveryFrameTargetEnabled()) {
    const iframeThread = dbg.selectors
      .getAllThreads()
      .find(thread => thread.name == testServer.urlFor("iframe.html"));

    is(
      sourceActors.filter(actor => actor.thread == iframeThread.actor).length,
      1,
      "same-url.js is loaded one time in the iframe thread"
    );
  }

  const workerThread = dbg.selectors
    .getAllThreads()
    .find(thread => thread.url == testServer.urlFor("same-url.sjs"));

  is(
    sourceActors.filter(actor => actor.thread == workerThread.actor).length,
    1,
    "same-url.js is loaded one time in the worker thread"
  );

  const workerThreadItem = findSourceTreeThreadByName(dbg, "same-url.sjs");
  ok(workerThreadItem, "Found the thread item for the worker");
  ok(
    workerThreadItem.querySelector("span.img.worker"),
    "The thread has the worker icon"
  );

  info("Verify source icons");
  assertSourceIcon(dbg, "index.html", "file");
  assertSourceIcon(dbg, "script.js", "javascript");
  assertSourceIcon(dbg, "query.js?x=1", "javascript");
  assertSourceIcon(dbg, "original.js", "javascript");
  // Framework icons are only displayed when we parse the source,
  // which happens when we select the source
  assertSourceIcon(dbg, "react-component-module.js", "javascript");
  const onResumed = SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [],
    function () {
      content.eval("pauseInReact()");
    }
  );
  await waitForPaused(dbg);
  assertSourceIcon(dbg, "react-component-module.js", "javascript");
  await resume(dbg);
  await onResumed;

  info("Verify blackbox source icon");
  await selectSourceFromSourceTree(dbg, "script.js");
  await clickElement(dbg, "blackbox");
  await waitForDispatch(dbg.store, "BLACKBOX_WHOLE_SOURCES");
  assertSourceIcon(dbg, "script.js", "blackBox");
  await clickElement(dbg, "blackbox");
  await waitForDispatch(dbg.store, "UNBLACKBOX_WHOLE_SOURCES");
  assertSourceIcon(dbg, "script.js", "javascript");

  info("Assert the content of the named eval");
  await selectSourceFromSourceTree(dbg, "named-eval.js");
  assertTextContentOnLine(dbg, 3, `console.log("named-eval");`);

  info("Assert that nameless eval don't show up in the source tree");
  invokeInTab("breakInEval");
  await waitForPaused(dbg);
  await waitForSourcesInSourceTree(dbg, INTEGRATION_TEST_PAGE_SOURCES);
  await resume(dbg);

  info("Assert the content of sources with query string");
  await selectSourceFromSourceTree(dbg, "query.js?x=1");
  const tab = findElement(dbg, "activeTab");
  is(tab.innerText, "query.js?x=1", "Tab label is query.js?x=1");
  assertTextContentOnLine(
    dbg,
    1,
    `function query() {console.log("query x=1");}`
  );
  await addBreakpoint(dbg, "query.js?x=1", 1);
  assertBreakpointHeading(dbg, "query.js?x=1", 0);

  // pretty print the source and check the tab text
  clickElement(dbg, "prettyPrintButton");
  await waitForSource(dbg, "query.js?x=1:formatted");
  await waitForSelectedSource(dbg, "query.js?x=1:formatted");
  assertSourceIcon(dbg, "query.js?x=1", "prettyPrint");

  const prettyTab = findElement(dbg, "activeTab");
  is(prettyTab.innerText, "query.js?x=1", "Tab label is query.js?x=1");
  ok(prettyTab.querySelector(".img.prettyPrint"));
  assertBreakpointHeading(dbg, "query.js?x=1", 0);
  assertTextContentOnLine(dbg, 1, `function query() {`);
  // Note the replacements of " by ' here:
  assertTextContentOnLine(dbg, 2, `console.log('query x=1');`);

  // assert quick open works with queries
  pressKey(dbg, "quickOpen");
  type(dbg, "query.js?x");

  // There can be intermediate updates in the results,
  // so wait for the final expected value
  await waitFor(async () => {
    const resultItem = findElement(dbg, "resultItems");
    if (!resultItem) {
      return false;
    }
    return resultItem.innerText.includes("query.js?x=1");
  }, "Results include the source with the query string");
  dbg.toolbox.closeToolbox();
});

/**
 * Verify that Web Extension content scripts appear only when
 * devtools.chrome.enabled is set to true and that they get
 * automatically re-selected on page reload.
 */
add_task(async function testSourceTreeWithWebExtensionContentScript() {
  const extension = await installAndStartContentScriptExtension();

  // Ensure that the setting to show content script is off before running the test
  await pushPref("devtools.debugger.show-content-scripts", false);
  let dbg = await initDebugger("doc-content-script-sources.html");
  // Let some time for unexpected source to appear
  await wait(1000);
  // There is no content script, but still html pages inline sources
  await waitForSourcesInSourceTree(dbg, [
    "doc-content-script-sources.html",
    "doc-strict.html",
  ]);
  await dbg.toolbox.closeToolbox();

  const toolbox = await openToolboxForTab(gBrowser.selectedTab, "jsdebugger");
  dbg = createDebuggerContext(toolbox);

  info("Enable the content script setting");
  await toggleSourcesTreeSettingsMenuItem(dbg, {
    className: ".debugger-settings-menu-item-show-content-scripts",
    isChecked: false,
  });

  await waitForSourcesInSourceTree(dbg, [
    "doc-content-script-sources.html",
    "doc-strict.html",
    "content_script.js",
  ]);
  await selectSourceFromSourceTree(dbg, "content_script.js");
  ok(
    findElementWithSelector(dbg, ".sources-list .focused"),
    "Source is focused"
  );

  // Note that the thread item also contains the extension name
  const contentScriptGroupItem = findSourceTreeGroupByName(
    dbg,
    "Test content script extension"
  );
  ok(contentScriptGroupItem, "Found the group item for the content script");
  ok(
    contentScriptGroupItem.querySelector("span.img.extension"),
    "The group has the extension icon"
  );
  assertSourceIcon(dbg, "content_script.js", "javascript");

  for (let i = 1; i < 3; i++) {
    info(
      `Reloading tab (${i} time), the content script should always be reselected`
    );
    gBrowser.reloadTab(gBrowser.selectedTab);
    await waitForSelectedSource(dbg, "content_script.js");
    ok(
      findElementWithSelector(dbg, ".sources-list .focused"),
      "Source is focused"
    );
  }
  await dbg.toolbox.closeToolbox();

  await extension.unload();
});

add_task(async function testSourceTreeWithEncodedPaths() {
  const httpServer = createTestHTTPServer();
  httpServer.registerContentType("html", "text/html");
  httpServer.registerContentType("js", "application/javascript");

  httpServer.registerPathHandler("/index.html", function (request, response) {
    response.setStatusLine(request.httpVersion, 200, "OK");
    response.write(`<!DOCTYPE html>
    <html>
      <head>
      <script src="/my folder/my file.js"></script>
      <script src="/malformedUri.js?%"></script>
      </head>
      <body>
      <h1>Encoded scripts paths</h1>
      </body>
    `);
  });
  httpServer.registerPathHandler(
    encodeURI("/my folder/my file.js"),
    function (request, response) {
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.setHeader("Content-Type", "application/javascript", false);
      response.write(`const x = 42`);
    }
  );
  httpServer.registerPathHandler(
    "/malformedUri.js",
    function (request, response) {
      response.setStatusLine(request.httpVersion, 200, "OK");
      response.setHeader("Content-Type", "application/javascript", false);
      response.write(`const y = "malformed"`);
    }
  );
  const port = httpServer.identity.primaryPort;

  const dbg = await initDebuggerWithAbsoluteURL(
    `http://localhost:${port}/index.html`,
    "my file.js"
  );

  await waitForSourcesInSourceTree(dbg, ["my file.js", "malformedUri.js?%"]);
  ok(
    true,
    "source name are decoded in the tree, and malformed uri source are displayed"
  );
  is(
    // We don't have any specific class on the folder item, so let's target the folder
    // icon next sibling, which is the directory label.
    findElementWithSelector(dbg, ".sources-panel .node .folder + .label")
      .innerText,
    "my folder",
    "folder name is decoded in the tree"
  );
});

/**
 * Assert the location displayed in the breakpoint list, in the right sidebar.
 *
 * @param {Object} dbg
 * @param {String} label
 *        The expected displayed location
 * @param {Number} index
 *        The position of the breakpoint in the list to verify
 */
function assertBreakpointHeading(dbg, label, index) {
  const breakpointHeading = findAllElements(dbg, "breakpointHeadings")[index]
    .innerText;
  is(breakpointHeading, label, `Breakpoint heading is ${label}`);
}
