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

"use strict";

// Test the ResourceWatcher API around ERROR_MESSAGE
// Reproduces assertions from devtools/shared/webconsole/test/chrome/test_page_errors.html

const {
  ResourceWatcher,
} = require("devtools/shared/resources/resource-watcher");

// Create a simple server so we have a nice sourceName in the resources packets.
const httpServer = createTestHTTPServer();
httpServer.registerPathHandler(`/test_page_errors.html`, (req, res) => {
  res.setStatusLine(req.httpVersion, 200, "OK");
  res.write(`<meta charset=utf8>Test Error Messages`);
});

const TEST_URI = `http://localhost:${httpServer.identity.primaryPort}/test_page_errors.html`;

add_task(async function() {
  // Disable the preloaded process as it creates processes intermittently
  // which forces the emission of RDP requests we aren't correctly waiting for.
  await pushPref("dom.ipc.processPrelaunch.enabled", false);

  await testErrorMessagesResources();
  await testErrorMessagesResourcesWithIgnoreExistingResources();
});

async function testErrorMessagesResources() {
  // Open a test tab
  const tab = await addTab(TEST_URI);

  const {
    client,
    resourceWatcher,
    targetList,
  } = await initResourceWatcherAndTarget(tab);

  const receivedMessages = [];
  // The expected messages are the errors, twice (once for cached messages, once for live messages)
  const expectedMessages = Array.from(expectedPageErrors.values()).concat(
    Array.from(expectedPageErrors.values())
  );

  info(
    "Log some errors *before* calling ResourceWatcher.watchResources in order to assert" +
      " the behavior of already existing messages."
  );
  await triggerErrors(tab);

  let done;
  const onAllErrorReceived = new Promise(resolve => (done = resolve));
  const onAvailable = resources => {
    for (const resource of resources) {
      const { pageError } = resource;

      is(
        resource.targetFront,
        targetList.targetFront,
        "The targetFront property is the expected one"
      );

      if (!pageError.sourceName.includes("test_page_errors")) {
        info(`Ignore error from unknown source: "${pageError.sourceName}"`);
        continue;
      }

      const index = receivedMessages.length;
      receivedMessages.push(pageError);

      info(`checking received page error #${index}: ${pageError.errorMessage}`);
      ok(pageError, "The resource has a pageError attribute");
      checkPageErrorResource(pageError, expectedMessages[index]);

      if (receivedMessages.length == expectedMessages.length) {
        done();
      }
    }
  };

  await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], {
    onAvailable,
  });

  info(
    "Now log errors *after* the call to ResourceWatcher.watchResources and after having" +
      " received all existing messages"
  );
  await BrowserTestUtils.waitForCondition(
    () => receivedMessages.length === expectedPageErrors.size
  );
  await triggerErrors(tab);

  info("Waiting for all expected errors to be received");
  await onAllErrorReceived;
  ok(true, "All the expected errors were received");

  Services.console.reset();
  targetList.destroy();
  await client.close();
}

async function testErrorMessagesResourcesWithIgnoreExistingResources() {
  info("Test ignoreExistingResources option for ERROR_MESSAGE");
  const tab = await addTab(TEST_URI);

  const {
    client,
    resourceWatcher,
    targetList,
  } = await initResourceWatcherAndTarget(tab);

  info(
    "Check whether onAvailable will not be called with existing error messages"
  );
  await triggerErrors(tab);

  const availableResources = [];
  await resourceWatcher.watchResources([ResourceWatcher.TYPES.ERROR_MESSAGE], {
    onAvailable: resources => availableResources.push(...resources),
    ignoreExistingResources: true,
  });
  is(
    availableResources.length,
    0,
    "onAvailable wasn't called for existing error messages"
  );

  info(
    "Check whether onAvailable will be called with the future error messages"
  );
  await triggerErrors(tab);

  const expectedMessages = Array.from(expectedPageErrors.values());
  await waitUntil(() => availableResources.length === expectedMessages.length);
  for (let i = 0; i < expectedMessages.length; i++) {
    const { pageError } = availableResources[i];
    const expected = expectedMessages[i];
    checkPageErrorResource(pageError, expected);
  }

  Services.console.reset();
  await targetList.destroy();
  await client.close();
}

/**
 * Triggers all the errors in the content page.
 */
async function triggerErrors(tab) {
  for (const [expression, expected] of expectedPageErrors.entries()) {
    if (
      !expected[noUncaughtException] &&
      !Services.appinfo.browserTabsRemoteAutostart
    ) {
      expectUncaughtException();
    }

    await ContentTask.spawn(tab.linkedBrowser, expression, function frameScript(
      expr
    ) {
      const document = content.document;
      const scriptEl = document.createElement("script");
      scriptEl.textContent = expr;
      document.body.appendChild(scriptEl);
    });

    if (expected.isPromiseRejection) {
      // Wait a bit after an uncaught promise rejection error, as they are not emitted
      // right away.

      // eslint-disable-next-line mozilla/no-arbitrary-setTimeout
      await new Promise(res => setTimeout(res, 10));
    }
  }
}

function checkPageErrorResource(pageErrorResource, expected) {
  // Let's remove test harness related frames in stacktrace
  const clonedPageErrorResource = { ...pageErrorResource };
  if (clonedPageErrorResource.stacktrace) {
    const index = clonedPageErrorResource.stacktrace.findIndex(frame =>
      frame.filename.startsWith("resource://testing-common/content-task.js")
    );
    if (index > -1) {
      clonedPageErrorResource.stacktrace = clonedPageErrorResource.stacktrace.slice(
        0,
        index
      );
    }
  }
  checkObject(clonedPageErrorResource, expected);
}

const noUncaughtException = Symbol();
const NUMBER_REGEX = /^\d+$/;

const mdnUrl = path =>
  `https://developer.mozilla.org/${path}?utm_source=mozilla&utm_medium=firefox-console-errors&utm_campaign=default`;

const expectedPageErrors = new Map([
  [
    "document.doTheImpossible();",
    {
      errorMessage: /doTheImpossible/,
      errorMessageName: undefined,
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Not_a_function"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 10,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "(42).toString(0);",
    {
      errorMessage: /radix/,
      errorMessageName: "JSMSG_BAD_RADIX",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Bad_radix"),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 6,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "'use strict'; (Object.freeze({name: 'Elsa', score: 157})).score = 0;",
    {
      errorMessage: /read.only/,
      errorMessageName: "JSMSG_READ_ONLY",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 23,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "([]).length = -1",
    {
      errorMessage: /array length/,
      errorMessageName: "JSMSG_BAD_ARRAY_LENGTH",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Invalid_array_length"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 2,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "'abc'.repeat(-1);",
    {
      errorMessage: /repeat count.*non-negative/,
      errorMessageName: "JSMSG_NEGATIVE_REPETITION_COUNT",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Negative_repetition_count"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: "self-hosted",
          sourceId: null,
          lineNumber: NUMBER_REGEX,
          columnNumber: 1,
          functionName: "repeat",
        },
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 7,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "'a'.repeat(2e28);",
    {
      errorMessage: /repeat count.*less than infinity/,
      errorMessageName: "JSMSG_RESULTING_STRING_TOO_LARGE",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Resulting_string_too_large"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: "self-hosted",
          sourceId: null,
          lineNumber: NUMBER_REGEX,
          columnNumber: 1,
          functionName: "repeat",
        },
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 5,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "77.1234.toExponential(-1);",
    {
      errorMessage: /out of range/,
      errorMessageName: "JSMSG_PRECISION_RANGE",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Precision_range"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 9,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "function a() { return; 1 + 1; }",
    {
      errorMessage: /unreachable code/,
      errorMessageName: "JSMSG_STMT_AFTER_RETURN",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: false,
      warning: true,
      info: false,
      sourceId: null,
      lineText: "function a() { return; 1 + 1; }",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Stmt_after_return"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: null,
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "{let a, a;}",
    {
      errorMessage: /redeclaration of/,
      errorMessageName: "JSMSG_REDECLARED_VAR",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      sourceId: null,
      lineText: "{let a, a;}",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl(
        "docs/Web/JavaScript/Reference/Errors/Redeclared_parameter"
      ),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [],
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
      notes: [
        {
          messageBody: /Previously declared at line/,
          frame: {
            source: /test_page_errors/,
          },
        },
      ],
    },
  ],
  [
    `var error = new TypeError("abc");
      error.name = "MyError";
      error.message = "here";
      throw error`,
    {
      errorMessage: /MyError: here/,
      errorMessageName: "",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 13,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    "DOMTokenList.prototype.contains.call([])",
    {
      errorMessage: /does not implement interface/,
      errorMessageName: "MSG_METHOD_THIS_DOES_NOT_IMPLEMENT_INTERFACE",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          lineNumber: 1,
          columnNumber: 33,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: false,
      isForwardedFromContentProcess: false,
    },
  ],
  [
    `
      function promiseThrow() {
        var error2 = new TypeError("abc");
        error2.name = "MyPromiseError";
        error2.message = "here2";
        return Promise.reject(error2);
      }
      promiseThrow()`,
    {
      errorMessage: /MyPromiseError: here2/,
      errorMessageName: "",
      sourceName: /test_page_errors/,
      category: "content javascript",
      timeStamp: NUMBER_REGEX,
      error: true,
      warning: false,
      info: false,
      lineText: "",
      lineNumber: NUMBER_REGEX,
      columnNumber: NUMBER_REGEX,
      exceptionDocURL: mdnUrl("docs/Web/JavaScript/Reference/Errors/Read-only"),
      innerWindowID: NUMBER_REGEX,
      private: false,
      stacktrace: [
        {
          filename: /test_page_errors\.html/,
          sourceId: null,
          lineNumber: 6,
          columnNumber: 24,
          functionName: "promiseThrow",
        },
        {
          filename: /test_page_errors\.html/,
          sourceId: null,
          lineNumber: 8,
          columnNumber: 7,
          functionName: null,
        },
      ],
      notes: null,
      chromeContext: false,
      isPromiseRejection: true,
      isForwardedFromContentProcess: false,
      [noUncaughtException]: true,
    },
  ],
]);
