/* 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 focus on asserting the source content displayed in CodeMirror
 * when we open a source from the SourceTree (or by any other means).
 *
 * The source content is being fetched from the server only on-demand.
 * The main shortcoming is about sources being GC-ed. This only happens
 * when we open the debugger on an already loaded page.
 * When we (re)load a page while the debugger is opened, sources are never GC-ed.
 * There are also specifics related to HTML page having inline scripts.
 * Also, as this data is fetched on-demand, there is a loading prompt
 * being displayed while the source is being fetched from the server.
 */

"use strict";

const httpServer = createTestHTTPServer();
const BASE_URL = `http://localhost:${httpServer.identity.primaryPort}/`;
const loadCounts = {};

/**
 * Simple tests, asserting that we correctly display source text content in CodeMirror
 */
const NAMED_EVAL_CONTENT = `function namedEval() {}; console.log('eval script'); //# sourceURL=named-eval.js`;
const NEW_FUNCTION_CONTENT =
  "console.log('new function'); //# sourceURL=new-function.js";
const INDEX_PAGE_CONTENT = `<!DOCTYPE html>
    <html>
      <head>
        <script type="text/javascript" src="/normal-script.js"></script>
        <script type="text/javascript" src="/slow-loading-script.js"></script>
        <script type="text/javascript" src="/http-error-script.js"></script>
        <script type="text/javascript" src="/same-url.js"></script>
        <script>
          console.log("inline script");
        </script>
        <script>
          console.log("second inline script");
          this.evaled1 = eval("${NAMED_EVAL_CONTENT}");

          // Load same-url.js in various different ways
          this.evaled2 = eval("function sameUrlEval() {}; //# sourceURL=same-url.js");

          const script = document.createElement("script");
          script.src = "same-url.js";
          document.documentElement.append(script);

          // Ensure loading the same-url.js file *after*
          // the iframe is done loading. So that we have a deterministic load order.
          window.onload = () => {
            this.worker = new Worker("same-url.js");
            this.worker.postMessage("foo");
          };

          this.newFunction = new Function("${NEW_FUNCTION_CONTENT}");

          // This method will be called via invokeInTab.
          let inFunctionIncrement = 1;
          function breakInNewFunction() {
            new Function("a\\n", "b" +inFunctionIncrement++, "debugger;")();
          }
        </script>
      </head>
      <body>
        <iframe src="iframe.html"></iframe>
      </body>
    </html>`;
const IFRAME_CONTENT = `<!DOCTYPE html>
    <html>
      <head>
        <script type="text/javascript" src="/same-url.js"></script>
      </head>
      <body>
        <script>
          console.log("First inline script");
        </script>
        <script>
          console.log("Second inline script");
        </script>
      </body>
    </html>`;

httpServer.registerPathHandler("/index.html", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  response.setStatusLine(request.httpVersion, 200, "OK");
  response.write(INDEX_PAGE_CONTENT);
});

httpServer.registerPathHandler("/normal-script.js", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  response.setHeader("Content-Type", "application/javascript");
  response.write(`console.log("normal script")`);
});
httpServer.registerPathHandler(
  "/slow-loading-script.js",
  (request, response) => {
    loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
    response.processAsync();
    // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
    setTimeout(function () {
      response.setHeader("Content-Type", "application/javascript");
      response.write(`console.log("slow loading script")`);
      response.finish();
    }, 1000);
  }
);
httpServer.registerPathHandler("/http-error-script.js", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  response.setStatusLine(request.httpVersion, 404, "Not found");
  response.write(`console.log("http error")`);
});
httpServer.registerPathHandler("/same-url.js", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  const sameUrlLoadCount = loadCounts[request.path];
  // Prevents gecko from cache this request in order to force fetching
  // a new, distinct content for each usage of this URL
  response.setHeader("Cache-Control", "no-store");
  response.setHeader("Content-Type", "application/javascript");
  response.write(`console.log("same url #${sameUrlLoadCount}")`);
});
httpServer.registerPathHandler("/iframe.html", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  response.setHeader("Content-Type", "text/html");
  response.write(IFRAME_CONTENT);
});
add_task(async function testSourceTextContent() {
  const dbg = await initDebuggerWithAbsoluteURL("about:blank");

  const waitForSources = [
    "index.html",
    "normal-script.js",
    "slow-loading-script.js",
    "same-url.js",
    "new-function.js",
  ];

  // With fission and EFT disabled, the structure of the source tree changes
  // as there is no iframe thread and all the iframe sources are loaded under the
  // Main thread, so nodes will be in different positions in the tree.
  const noFissionNoEFT = !isFissionEnabled() && !isEveryFrameTargetEnabled();

  if (noFissionNoEFT) {
    waitForSources.push("iframe.html", "named-eval.js");
  }

  // Load the document *once* the debugger is opened
  // in order to avoid having any source being GC-ed.
  await navigateToAbsoluteURL(dbg, BASE_URL + "index.html", ...waitForSources);

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "new-function.js",
    noFissionNoEFT ? 6 : 5,
    "Select `new-function.js`"
  );
  is(
    getEditorContent(dbg),
    `function anonymous(\n) {\n${NEW_FUNCTION_CONTENT}\n}`
  );

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "normal-script.js",
    noFissionNoEFT ? 7 : 6,
    "Select `normal-script.js`"
  );
  is(getEditorContent(dbg), `console.log("normal script")`);

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "slow-loading-script.js",
    noFissionNoEFT ? 9 : 8,
    "Select `slow-loading-script.js`"
  );
  is(getEditorContent(dbg), `console.log("slow loading script")`);

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "index.html",
    noFissionNoEFT ? 4 : 3,
    "Select `index.html`"
  );
  is(getEditorContent(dbg), INDEX_PAGE_CONTENT);

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "named-eval.js",
    noFissionNoEFT ? 5 : 4,
    "Select `named-eval.js`"
  );
  is(getEditorContent(dbg), NAMED_EVAL_CONTENT);

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "same-url.js",
    noFissionNoEFT ? 8 : 7,
    "Select `same-url.js` in the Main Thread"
  );

  is(
    getEditorContent(dbg),
    `console.log("same url #1")`,
    "We get an arbitrary content for same-url, the first loaded one"
  );

  const sameUrlSource = findSource(dbg, "same-url.js");
  const sourceActors = dbg.selectors.getSourceActorsForSource(sameUrlSource.id);

  if (isFissionEnabled() || isEveryFrameTargetEnabled()) {
    const mainThread = dbg.selectors
      .getAllThreads()
      .find(thread => thread.name == "Main Thread");

    is(
      sourceActors.filter(actor => actor.thread == mainThread.actor).length,
      3,
      "same-url.js is loaded 3 times in the main thread"
    );

    info(`Close the same-url.js from Main Thread`);
    await closeTab(dbg, "same-url.js");

    info("Click on the iframe tree node to show sources in the iframe");
    await clickElement(dbg, "sourceDirectoryLabel", 9);
    await waitForSourcesInSourceTree(
      dbg,
      [
        "index.html",
        "named-eval.js",
        "normal-script.js",
        "slow-loading-script.js",
        "same-url.js",
        "iframe.html",
        "same-url.js",
        "new-function.js",
      ],
      {
        noExpand: true,
      }
    );

    await selectSourceFromSourceTreeWithIndex(
      dbg,
      "same-url.js",
      12,
      "Select `same-url.js` in the iframe"
    );

    is(
      getEditorContent(dbg),
      `console.log("same url #3")`,
      "We get the expected content for same-url.js in the iframe"
    );

    const iframeThread = dbg.selectors
      .getAllThreads()
      .find(thread => thread.name == `${BASE_URL}iframe.html`);
    is(
      sourceActors.filter(actor => actor.thread == iframeThread.actor).length,
      1,
      "same-url.js is loaded one time in the iframe thread"
    );
  } else {
    // There is no iframe thread when fission is off
    const mainThread = dbg.selectors
      .getAllThreads()
      .find(thread => thread.name == "Main Thread");

    is(
      sourceActors.filter(actor => actor.thread == mainThread.actor).length,
      4,
      "same-url.js is loaded 4 times in the main thread without fission"
    );
  }

  info(`Close the same-url.js from the iframe`);
  await closeTab(dbg, "same-url.js");

  info("Click on the worker tree node to show sources in the worker");

  await clickElement(dbg, "sourceDirectoryLabel", noFissionNoEFT ? 10 : 13);

  const workerSources = [
    "index.html",
    "named-eval.js",
    "normal-script.js",
    "slow-loading-script.js",
    "same-url.js",
    "iframe.html",
    "same-url.js",
    "new-function.js",
  ];

  if (!noFissionNoEFT) {
    workerSources.push("same-url.js");
  }

  await waitForSourcesInSourceTree(dbg, workerSources, {
    noExpand: true,
  });

  await selectSourceFromSourceTreeWithIndex(
    dbg,
    "same-url.js",
    noFissionNoEFT ? 12 : 15,
    "Select `same-url.js` in the worker"
  );

  is(
    getEditorContent(dbg),
    `console.log("same url #4")`,
    "We get the expected content for same-url.js worker"
  );

  const workerThread = dbg.selectors
    .getAllThreads()
    .find(thread => thread.url == `${BASE_URL}same-url.js`);

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

  await selectSource(dbg, "iframe.html");
  is(getEditorContent(dbg), IFRAME_CONTENT);

  ok(
    !sourceExists(dbg, "http-error-script.js"),
    "scripts with HTTP error code do not appear in the source list"
  );

  info(
    "Verify that breaking in a source without url displays the right content"
  );
  let onNewSource = waitForDispatch(dbg.store, "ADD_SOURCES");
  invokeInTab("breakInNewFunction");
  await waitForPaused(dbg);
  let { sources } = await onNewSource;
  is(sources.length, 1, "Got a unique source related to new Function source");
  let newFunctionSource = sources[0];
  // We acknowledge the function header as well as the new line in the first argument
  await assertPausedAtSourceAndLine(dbg, newFunctionSource.id, 4, 0);
  is(getEditorContent(dbg), "function anonymous(a\n,b1\n) {\ndebugger;\n}");
  await resume(dbg);

  info(
    "Break a second time in a source without url to verify we display the right content"
  );
  onNewSource = waitForDispatch(dbg.store, "ADD_SOURCES");
  invokeInTab("breakInNewFunction");
  await waitForPaused(dbg);
  ({ sources } = await onNewSource);
  is(sources.length, 1, "Got a unique source related to new Function source");
  newFunctionSource = sources[0];
  // We acknowledge the function header as well as the new line in the first argument
  await assertPausedAtSourceAndLine(dbg, newFunctionSource.id, 4, 0);
  is(getEditorContent(dbg), "function anonymous(a\n,b2\n) {\ndebugger;\n}");
  await resume(dbg);

  // As we are loading the page while the debugger is already opened,
  // none of the resources are loaded twice.
  is(loadCounts["/index.html"], 1, "We loaded index.html only once");
  is(
    loadCounts["/normal-script.js"],
    1,
    "We loaded normal-script.js only once"
  );
  is(
    loadCounts["/slow-loading-script.js"],
    1,
    "We loaded slow-loading-script.js only once"
  );
  is(
    loadCounts["/same-url.js"],
    4,
    "We loaded same-url.js in 4 distinct ways (the named eval doesn't count)"
  );
  // For some reason external to the debugger, we issue two requests to scripts having http error codes.
  // These two requests are done before opening the debugger.
  is(
    loadCounts["/http-error-script.js"],
    2,
    "We loaded http-error-script.js twice, only before the debugger is opened"
  );
});

/**
 * In this test, we force a GC before loading DevTools.
 * So that Spidermonkey will no longer have access to the sources
 * and another request should be issues to load the source text content.
 */
const GARBAGED_PAGE_CONTENT = `<!DOCTYPE html>
    <html>
      <head>
        <script type="text/javascript" src="/garbaged-script.js"></script>
        <script>
          console.log("garbaged inline script");
        </script>
      </head>
    </html>`;

httpServer.registerPathHandler(
  "/garbaged-collected.html",
  (request, response) => {
    loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
    response.setStatusLine(request.httpVersion, 200, "OK");
    response.write(GARBAGED_PAGE_CONTENT);
  }
);

httpServer.registerPathHandler("/garbaged-script.js", (request, response) => {
  loadCounts[request.path] = (loadCounts[request.path] || 0) + 1;
  response.setHeader("Content-Type", "application/javascript");
  response.write(`console.log("garbaged script ${loadCounts[request.path]}")`);
});
add_task(async function testGarbageCollectedSourceTextContent() {
  const tab = await addTab(BASE_URL + "garbaged-collected.html");
  is(
    loadCounts["/garbaged-collected.html"],
    1,
    "The HTML page is loaded once before opening the DevTools"
  );
  is(
    loadCounts["/garbaged-script.js"],
    1,
    "The script is loaded once before opening the DevTools"
  );

  // Force freeing both the HTML page and script in memory
  // so that the debugger has to fetch source content from http cache.
  await SpecialPowers.spawn(tab.linkedBrowser, [], () => {
    Cu.forceGC();
  });

  const toolbox = await openToolboxForTab(tab, "jsdebugger");
  const dbg = createDebuggerContext(toolbox);
  await waitForSources(dbg, "garbaged-collected.html", "garbaged-script.js");

  await selectSource(dbg, "garbaged-script.js");
  // XXX Bug 1758454 - Source content of GC-ed script can be wrong!
  // Even if we have to issue a new HTTP request for this source,
  // we should be using HTTP cache and retrieve the first served version which
  // is the one that actually runs in the page!
  // We should be displaying `console.log("garbaged script 1")`,
  // but instead, a new HTTP request is dispatched and we get a new content.
  is(getEditorContent(dbg), `console.log("garbaged script 2")`);

  await selectSource(dbg, "garbaged-collected.html");
  is(getEditorContent(dbg), GARBAGED_PAGE_CONTENT);

  is(
    loadCounts["/garbaged-collected.html"],
    2,
    "We loaded the html page once as we haven't tried to display it in the debugger (2)"
  );
  is(
    loadCounts["/garbaged-script.js"],
    2,
    "We loaded the garbaged script twice as we lost its content"
  );
});

/**
 * Test failures when trying to open the source text content.
 *
 * In this test we load an html page
 * - with inline source (so that it shows up in the debugger)
 * - it first loads fine so that it shows up
 * - initDebuggerWithAbsoluteURL will first load the document before the debugger
 * - so the debugger will have to fetch the html page content via a network request
 * - the test page will return a connection reset error on the second load attempt
 */
let loadCount = 0;
httpServer.registerPathHandler(
  "/200-then-connection-reset.html",
  (request, response) => {
    loadCount++;
    if (loadCount > 1) {
      response.seizePower();
      response.bodyOutPutStream.close();
      response.finish();
      return;
    }
    response.setStatusLine(request.httpVersion, 200, "OK");
    response.write(`<!DOCTYPE html><script>console.log("200 page");</script>`);
  }
);
add_task(async function testFailingHtmlSource() {
  info("Test failure in retrieving html page sources");

  // initDebuggerWithAbsoluteURL will first load the document once before the debugger,
  // then the debugger will have to fetch the html page content via a network request
  // therefore the test page will encounter a connection reset error on the second load attempt
  const dbg = await initDebuggerWithAbsoluteURL(
    BASE_URL + "200-then-connection-reset.html",
    "200-then-connection-reset.html"
  );

  // We can't select the HTML page as its source content isn't fetched
  // (waitForSelectedSource doesn't resolve)
  // Note that it is important to load the page *before* opening the page
  // so that the thread actor has to request the page content and will fail
  const source = findSource(dbg, "200-then-connection-reset.html");
  await dbg.actions.selectLocation(createLocation({ source }), {
    keepContext: false,
  });

  ok(
    getEditorContent(dbg).includes("Could not load the source"),
    "Display failure error"
  );
});

/**
 * In this test we try to reproduce the "Loading..." message.
 * This may happen when opening an HTML source that was loaded *before*
 * opening DevTools. The thread actor will have to issue a new HTTP request
 * to load the source content.
 */
let loadCount2 = 0;
let slowLoadingPageResolution = null;
httpServer.registerPathHandler(
  "/slow-loading-page.html",
  (request, response) => {
    loadCount2++;
    if (loadCount2 > 1) {
      response.processAsync();
      slowLoadingPageResolution = function () {
        response.write(
          `<!DOCTYPE html><script>console.log("slow-loading-page:second-load");</script>`
        );
        response.finish();
      };
      return;
    }
    response.write(
      `<!DOCTYPE html><script>console.log("slow-loading-page:first-load");</script>`
    );
  }
);
add_task(async function testLoadingHtmlSource() {
  info("Test loading progress of html page sources");
  const dbg = await initDebuggerWithAbsoluteURL(
    BASE_URL + "slow-loading-page.html",
    "slow-loading-page.html"
  );

  const onSelected = selectSource(dbg, "slow-loading-page.html");
  await waitFor(
    () => getEditorContent(dbg) == DEBUGGER_L10N.getStr("loadingText"),
    "Wait for the source to be displayed as loading"
  );

  info("Wait for a second HTTP request to be made for the html page");
  await waitFor(
    () => slowLoadingPageResolution,
    "Wait for the html page to be queried a second time"
  );
  is(
    getEditorContent(dbg),
    DEBUGGER_L10N.getStr("loadingText"),
    "The source is still loading until we release the network request"
  );

  slowLoadingPageResolution();
  info("Wait for the source to be fully selected and loaded");
  await onSelected;

  // Note that, even if the thread actor triggers a new HTTP request,
  // it will use the HTTP cache and retrieve the first request content.
  // This is actually relevant as that's the source that actually runs in the page!
  //
  // XXX Bug 1758458 - the source content is wrong.
  // We should be seeing the whole HTML page content,
  // whereas we only see the inline source text content.
  is(getEditorContent(dbg), `console.log("slow-loading-page:first-load");`);
});
