"use strict";

const PROCESS_COUNT_PREF = "dom.ipc.processCount";

const {
  createAppInfo,
} = AddonTestUtils;

AddonTestUtils.init(this);

createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");

const server = createHttpServer();
server.registerDirectory("/data/", do_get_file("data"));

const BASE_URL = `http://localhost:${server.identity.primaryPort}/data`;

add_task(async function setup_test_environment() {
  if (ExtensionTestUtils.remoteContentScripts) {
    // Start with one content process so that we can increase the number
    // later and test the behavior of a fresh content process.
    Services.prefs.setIntPref(PROCESS_COUNT_PREF, 1);
  }

  // Grant the optional permissions requested.
  function permissionObserver(subject, topic, data) {
    if (topic == "webextension-optional-permission-prompt") {
      let {resolve} = subject.wrappedJSObject;
      resolve(true);
    }
  }
  Services.obs.addObserver(permissionObserver, "webextension-optional-permission-prompt");
  registerCleanupFunction(() => {
    Services.obs.removeObserver(permissionObserver, "webextension-optional-permission-prompt");
  });
});

// Test that there is no userScripts API namespace when the manifest doesn't include a user_scripts
// property.
add_task(async function test_userScripts_manifest_property_required() {
  function background() {
    browser.test.assertEq(undefined, browser.userScripts,
                          "userScripts API namespace should be undefined in the extension page");
    browser.test.sendMessage("background-page:done");
  }

  async function contentScript() {
    browser.test.assertEq(undefined, browser.userScripts,
                          "userScripts API namespace should be undefined in the content script");
    browser.test.sendMessage("content-script:done");
  }

  let extension = ExtensionTestUtils.loadExtension({
    background,
    manifest: {
      permissions: ["http://*/*/file_sample.html"],
      content_scripts: [
        {
          matches:  ["http://*/*/file_sample.html"],
          js: ["content_script.js"],
          run_at: "document_start",
        },
      ],
    },
    files: {
      "content_script.js": contentScript,
    },
  });

  await extension.startup();
  await extension.awaitMessage("background-page:done");

  let url = `${BASE_URL}/file_sample.html`;
  let contentPage = await ExtensionTestUtils.loadContentPage(url);

  await extension.awaitMessage("content-script:done");

  await extension.unload();
  await contentPage.close();
});

// Test that userScripts can only matches origins that are subsumed by the extension permissions,
// and that more origins can be allowed by requesting an optional permission.
add_task(async function test_userScripts_matches_denied() {
  async function background() {
    async function registerUserScriptWithMatches(matches) {
      const scripts = await browser.userScripts.register({
        js: [{code: ""}],
        matches,
      });
      await scripts.unregister();
    }

    // These matches are supposed to be denied until the extension has been granted the
    // <all_urls> origin permission.
    const testMatches = [
      "<all_urls>",
      "file://*/*",
      "https://localhost/*",
      "http://example.com/*",
    ];

    browser.test.onMessage.addListener(async msg => {
      if (msg === "test-denied-matches") {
        for (let testMatch of testMatches) {
          await browser.test.assertRejects(
            registerUserScriptWithMatches([testMatch]),
            /Permission denied to register a user script for/,
            "Got the expected rejection when the extension permission does not subsume the userScript matches");
        }
      } else if (msg === "grant-all-urls") {
        await browser.permissions.request({origins: ["<all_urls>"]});
      } else if (msg === "test-allowed-matches") {
        for (let testMatch of testMatches) {
          try {
            await registerUserScriptWithMatches([testMatch]);
          } catch (err) {
            browser.test.fail(`Unexpected rejection ${err} on matching ${JSON.stringify(testMatch)}`);
          }
        }
      } else {
        browser.test.fail(`Received an unexpected ${msg} test message`);
      }

      browser.test.sendMessage(`${msg}:done`);
    });

    browser.test.sendMessage("background-ready");
  }

  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: ["http://localhost/*"],
      optional_permissions: ["<all_urls>"],
      user_scripts: {},
    },
    background,
  });

  await extension.startup();

  await extension.awaitMessage("background-ready");

  // Test that the matches not subsumed by the extension permissions are being denied.
  extension.sendMessage("test-denied-matches");
  await extension.awaitMessage("test-denied-matches:done");

  // Grant the optional <all_urls> permission.
  await withHandlingUserInput(extension, async () => {
    extension.sendMessage("grant-all-urls");
    await extension.awaitMessage("grant-all-urls:done");
  });

  // Test that all the matches are now subsumed by the extension permissions.
  extension.sendMessage("test-allowed-matches");
  await extension.awaitMessage("test-allowed-matches:done");

  await extension.unload();
});

// Test that userScripts sandboxes:
// - can be registered/unregistered from an extension page (and they are registered on both new and
//   existing processes).
// - have no WebExtensions APIs available
// - are able to access the target window and document
add_task(async function test_userScripts_no_webext_apis() {
  async function background() {
    const matches = ["http://localhost/*/file_sample.html*"];

    const sharedCode = {code: "console.log(\"js code shared by multiple userScripts\");"};

    const userScriptOptions = {
      js: [sharedCode, {
        code: `
          window.addEventListener("load", () => {
            const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
            document.body.innerHTML = "userScript loaded - " + JSON.stringify(webextAPINamespaces);
          }, {once: true});
        `,
      }],
      runAt: "document_start",
      matches,
      scriptMetadata: {
        name: "test-user-script",
        arrayProperty: ["el1"],
        objectProperty: {nestedProp: "nestedValue"},
        nullProperty: null,
      },
    };

    let script = await browser.userScripts.register(userScriptOptions);

    // Unregister and then register the same js code again, to verify that the last registered
    // userScript doesn't get assigned a revoked blob url (otherwise Extensioncontent.jsm
    // ScriptCache raises an error because it fails to compile the revoked blob url and the user
    // script will never be loaded).
    script.unregister();
    script = await browser.userScripts.register(userScriptOptions);

    browser.test.onMessage.addListener(async msg => {
      if (msg !== "register-new-script") {
        return;
      }

      await script.unregister();
      await browser.userScripts.register({
        ...userScriptOptions,
        scriptMetadata: {name: "test-new-script"},
        js: [sharedCode, {
          code: `
          window.addEventListener("load", () => {
            const webextAPINamespaces = this.browser ? Object.keys(this.browser) : undefined;
            document.body.innerHTML = "new userScript loaded - " + JSON.stringify(webextAPINamespaces);
          }, {once: true});
        `,
        }],
      });

      browser.test.sendMessage("script-registered");
    });

    const scriptToRemove = await browser.userScripts.register({
      js: [sharedCode, {
        code: `
          window.addEventListener("load", () => {
            document.body.innerHTML = "unexpected unregistered userScript loaded";
          }, {once: true});
        `,
      }],
      runAt: "document_start",
      matches,
      scriptMetadata: {
        name: "user-script-to-remove",
      },
    });

    browser.test.assertTrue("unregister" in script,
                            "Got an unregister method on the userScript API object");

    // Remove the last registered user script.
    await scriptToRemove.unregister();

    browser.test.sendMessage("background-ready");
  }

  let extensionData = {
    manifest: {
      permissions: [
        "http://localhost/*/file_sample.html",
      ],
      user_scripts: {},
    },
    background,
  };

  let extension = ExtensionTestUtils.loadExtension(extensionData);

  await extension.startup();

  await extension.awaitMessage("background-ready");

  let url = `${BASE_URL}/file_sample.html?testpage=1`;
  let contentPage = await ExtensionTestUtils.loadContentPage(
    url, ExtensionTestUtils.remoteContentScripts ? {remote: true} : undefined);
  let result = await contentPage.spawn(undefined, async () => {
    return {
      textContent: this.content.document.body.textContent,
      url: this.content.location.href,
      readyState: this.content.document.readyState,
    };
  });
  Assert.deepEqual(result, {
    textContent: "userScript loaded - undefined",
    url,
    readyState: "complete",
  }, "The userScript executed on the expected url and no access to the WebExtensions APIs");

  // If the tests is running with "remote content process" mode, test that the userScript
  // are being correctly registered in newly created processes (received as part of the sharedData).
  if (ExtensionTestUtils.remoteContentScripts) {
    info("Test content script are correctly created on a newly created process");

    await extension.sendMessage("register-new-script");
    await extension.awaitMessage("script-registered");

    // Update the process count preference, so that we can test that the newly registered user script
    // is propagated as expected into the newly created process.
    Services.prefs.setIntPref(PROCESS_COUNT_PREF, 2);

    const url2 = `${BASE_URL}/file_sample.html?testpage=2`;
    let contentPage2 = await ExtensionTestUtils.loadContentPage(url2, {remote: true});
    let result2 = await contentPage2.spawn(undefined, async () => {
      return {
        textContent: this.content.document.body.textContent,
        url: this.content.location.href,
        readyState: this.content.document.readyState,
      };
    });
    Assert.deepEqual(result2, {
      textContent: "new userScript loaded - undefined",
      url: url2,
      readyState: "complete",
    }, "The userScript executed on the expected url and no access to the WebExtensions APIs");

    await contentPage2.close();
  }

  await contentPage.close();

  await extension.unload();
});

// This test verify that a cached script is still able to catch the document
// while it is still loading (when we do not block the document parsing as
// we do for a non cached script).
add_task(async function test_cached_userScript_on_document_start() {
  function apiScript() {
    browser.userScripts.onBeforeScript.addListener(script => {
      script.defineGlobals({
        sendTestMessage(name, params) {
          return browser.test.sendMessage(name, params);
        },
      });
    });
  }

  async function background() {
    function userScript() {
      this.sendTestMessage("user-script-loaded", {
        url: window.location.href,
        documentReadyState: document.readyState,
      });
    }

    await browser.userScripts.register({
      js: [{
        code: `(${userScript})();`,
      }],
      runAt: "document_start",
      matches: [
        "http://localhost/*/file_sample.html",
      ],
    });

    browser.test.sendMessage("user-script-registered");
  }

  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      permissions: [
        "http://localhost/*/file_sample.html",
      ],
      user_scripts: {
        api_script: "api-script.js",
        // The following is an unexpected manifest property, that we expect to be ignored and
        // to not prevent the test extension from being installed and run as expected.
        unexpected_manifest_key: "test-unexpected-key",
      },
    },
    background,
    files: {
      "api-script.js": apiScript,
    },
  });

  await extension.startup();
  await extension.awaitMessage("user-script-registered");

  let url = `${BASE_URL}/file_sample.html`;
  let contentPage = await ExtensionTestUtils.loadContentPage(url);

  let msg = await extension.awaitMessage("user-script-loaded");
  Assert.deepEqual(msg, {
    url,
    documentReadyState: "loading",
  }, "Got the expected url and document.readyState from a non cached user script");

  // Reload the page and check that the cached content script is still able to
  // run on document_start.
  await contentPage.loadURL(url);

  let msgFromCached = await extension.awaitMessage("user-script-loaded");
  Assert.deepEqual(msgFromCached, {
    url,
    documentReadyState: "loading",
  }, "Got the expected url and document.readyState from a cached user script");

  await contentPage.close();
  await extension.unload();
});

add_task(async function test_userScripts_pref_disabled() {
  async function run_userScript_on_pref_disabled_test() {
    async function background() {
      let promise = (async () => {
        await browser.userScripts.register({
          js: [
            {code: "throw new Error('This userScripts should not be registered')"},
          ],
          runAt: "document_start",
          matches: ["<all_urls>"],
        });
      })();

      await browser.test.assertRejects(
        promise,
        /userScripts APIs are currently experimental/,
        "Got the expected error from userScripts.register when the userScripts API is disabled");

      browser.test.sendMessage("background-page:done");
    }

    async function contentScript() {
      let promise = (async () => {
        browser.userScripts.onBeforeScript.addListener(() => {});
      })();
      await browser.test.assertRejects(
        promise,
        /userScripts APIs are currently experimental/,
        "Got the expected error from userScripts.onBeforeScript when the userScripts API is disabled");

      browser.test.sendMessage("content-script:done");
    }

    let extension = ExtensionTestUtils.loadExtension({
      background,
      manifest: {
        permissions: ["http://*/*/file_sample.html"],
        user_scripts: {api_script: ""},
        content_scripts: [
          {
            matches:  ["http://*/*/file_sample.html"],
            js: ["content_script.js"],
            run_at: "document_start",
          },
        ],
      },
      files: {
        "content_script.js": contentScript,
      },
    });

    await extension.startup();

    await extension.awaitMessage("background-page:done");

    let url = `${BASE_URL}/file_sample.html`;
    let contentPage = await ExtensionTestUtils.loadContentPage(url);

    await extension.awaitMessage("content-script:done");

    await extension.unload();
    await contentPage.close();
  }

  await runWithPrefs([["extensions.webextensions.userScripts.enabled", false]],
                     run_userScript_on_pref_disabled_test);
});

// This test verify that userScripts.onBeforeScript API Event is not available without
// a "user_scripts.api_script" property in the manifest.
add_task(async function test_user_script_api_script_required() {
  let extension = ExtensionTestUtils.loadExtension({
    manifest: {
      content_scripts: [
        {
          matches: ["http://localhost/*/file_sample.html"],
          js: ["content_script.js"],
          run_at: "document_start",
        },
      ],
      user_scripts: {},
    },
    files: {
      "content_script.js": function() {
        browser.test.assertEq(undefined, browser.userScripts && browser.userScripts.onBeforeScript,
                              "Got an undefined onBeforeScript property as expected");
        browser.test.sendMessage("no-onBeforeScript:done");
      },
    },
  });

  await extension.startup();

  let url = `${BASE_URL}/file_sample.html`;
  let contentPage = await ExtensionTestUtils.loadContentPage(url);

  await extension.awaitMessage("no-onBeforeScript:done");

  await extension.unload();
  await contentPage.close();
});

add_task(async function test_scriptMetaData() {
  function getTestCases(isUserScriptsRegister) {
    return [
      // When scriptMetadata is not set (or undefined), it is treated as if it were null.
      // In the API script, the metadata is then expected to be null.
      isUserScriptsRegister ? undefined : null,

      // Falsey
      null,
      "",
      false,
      0,

      // Truthy
      true,
      1,
      "non-empty string",

      // Objects
      ["some array with value"],
      {"some object": "with value"},
    ];
  }

  async function background(pageUrl) {
    for (let scriptMetadata of getTestCases(true)) {
      await browser.userScripts.register({
        js: [{file: "userscript.js"}],
        runAt: "document_end",
        allFrames: true,
        matches: ["http://localhost/*/file_sample.html"],
        scriptMetadata,
      });
    }

    let f = document.createElement("iframe");
    f.src = pageUrl;
    document.body.append(f);
    browser.test.sendMessage("background-page:done");
  }

  function apiScript() {
    let testCases = getTestCases(false);
    let i = 0;

    browser.userScripts.onBeforeScript.addListener(script => {
      script.defineGlobals({
        checkMetadata() {
          let expectation = testCases[i];
          let metadata = script.metadata;
          if (typeof expectation === "object" && expectation !== null) {
            // Non-primitive values cannot be compared with assertEq,
            // so serialize both and just verify that they are equal.
            expectation = JSON.stringify(expectation);
            metadata = JSON.stringify(script.metadata);
          }

          browser.test.assertEq(expectation, metadata,
                                `Expected metadata at call ${i}`);
          if (++i === testCases.length) {
            browser.test.sendMessage("apiscript:done");
          }
        },
      });
    });
  }

  let extension = ExtensionTestUtils.loadExtension({
    background: `${getTestCases};(${background})("${BASE_URL}/file_sample.html")`,
    manifest: {
      permissions: ["http://*/*/file_sample.html"],
      user_scripts: {
        api_script: "apiscript.js",
      },
    },
    files: {
      "apiscript.js": `${getTestCases};(${apiScript})()`,
      "userscript.js": "checkMetadata();",
    },
  });

  await extension.startup();

  await extension.awaitMessage("background-page:done");
  await extension.awaitMessage("apiscript:done");

  await extension.unload();
});
