"use strict";

/* import-globals-from head.js */

// Test implementations and utility functions that are used against multiple
// storage areas (eg, a test which is run against browser.storage.local and
// browser.storage.sync, or a test against browser.storage.sync but needs to
// be run against both the kinto and rust implementations.)

/**
 * Utility function to ensure that all supported APIs for getting are
 * tested.
 *
 * @param {string} areaName
 *        either "local" or "sync" according to what we want to test
 * @param {string} prop
 *        "key" to look up using the storage API
 * @param {object} value
 *        "value" to compare against
 */
async function checkGetImpl(areaName, prop, value) {
  let storage = browser.storage[areaName];

  let data = await storage.get();
  browser.test.assertEq(
    value,
    data[prop],
    `unspecified getter worked for ${prop} in ${areaName}`
  );

  data = await storage.get(null);
  browser.test.assertEq(
    value,
    data[prop],
    `null getter worked for ${prop} in ${areaName}`
  );

  data = await storage.get(prop);
  browser.test.assertEq(
    value,
    data[prop],
    `string getter worked for ${prop} in ${areaName}`
  );
  browser.test.assertEq(
    Object.keys(data).length,
    1,
    `string getter should return an object with a single property`
  );

  data = await storage.get([prop]);
  browser.test.assertEq(
    value,
    data[prop],
    `array getter worked for ${prop} in ${areaName}`
  );
  browser.test.assertEq(
    Object.keys(data).length,
    1,
    `array getter with a single key should return an object with a single property`
  );

  data = await storage.get({ [prop]: undefined });
  browser.test.assertEq(
    value,
    data[prop],
    `object getter worked for ${prop} in ${areaName}`
  );
  browser.test.assertEq(
    Object.keys(data).length,
    1,
    `object getter with a single key should return an object with a single property`
  );
}

async function test_storage_after_reload(areaName, { expectPersistency }) {
  // Just some random extension ID that we can re-use
  const extensionId = "my-extension-id@1";

  function loadExtension() {
    async function background(areaName) {
      browser.test.sendMessage(
        "initialItems",
        await browser.storage[areaName].get(null)
      );
      await browser.storage[areaName].set({ a: "b" });
      browser.test.notifyPass("set-works");
    }

    return ExtensionTestUtils.loadExtension({
      manifest: {
        browser_specific_settings: { gecko: { id: extensionId } },
        permissions: ["storage"],
      },
      background: `(${background})("${areaName}")`,
    });
  }

  let extension1 = loadExtension();
  await extension1.startup();

  Assert.deepEqual(
    await extension1.awaitMessage("initialItems"),
    {},
    "No stored items at first"
  );

  await extension1.awaitFinish("set-works");
  await extension1.unload();

  let extension2 = loadExtension();
  await extension2.startup();

  Assert.deepEqual(
    await extension2.awaitMessage("initialItems"),
    expectPersistency ? { a: "b" } : {},
    `Expect ${areaName} stored items ${
      expectPersistency ? "to" : "not"
    } be available after restart`
  );

  await extension2.awaitFinish("set-works");
  await extension2.unload();
}

async function test_sync_reloading_extensions_works() {
  await test_storage_after_reload("sync", { expectPersistency: true });
}

async function test_background_page_storage(testAreaName) {
  async function backgroundScript(checkGet) {
    let globalChanges, gResolve;
    function clearGlobalChanges() {
      globalChanges = new Promise(resolve => {
        gResolve = resolve;
      });
    }
    clearGlobalChanges();
    let expectedAreaName;

    browser.storage.onChanged.addListener((changes, areaName) => {
      browser.test.assertEq(
        expectedAreaName,
        areaName,
        "Expected area name received by listener"
      );
      gResolve(changes);
    });

    async function checkChanges(areaName, changes, message) {
      function checkSub(obj1, obj2) {
        for (let prop in obj1) {
          browser.test.assertTrue(
            obj1[prop] !== undefined,
            `checkChanges ${areaName} ${prop} is missing (${message})`
          );
          browser.test.assertTrue(
            obj2[prop] !== undefined,
            `checkChanges ${areaName} ${prop} is missing (${message})`
          );
          browser.test.assertEq(
            obj1[prop].oldValue,
            obj2[prop].oldValue,
            `checkChanges ${areaName} ${prop} old (${message})`
          );
          browser.test.assertEq(
            obj1[prop].newValue,
            obj2[prop].newValue,
            `checkChanges ${areaName} ${prop} new (${message})`
          );
        }
      }

      const recentChanges = await globalChanges;
      checkSub(changes, recentChanges);
      checkSub(recentChanges, changes);
      clearGlobalChanges();
    }

    // Regression test for https://bugzilla.mozilla.org/show_bug.cgi?id=1645598
    async function testNonExistingKeys(storage, storageAreaDesc) {
      let data = await storage.get({ test6: 6 });
      browser.test.assertEq(
        `{"test6":6}`,
        JSON.stringify(data),
        `Use default value when not stored for ${storageAreaDesc}`
      );

      data = await storage.get({ test6: null });
      browser.test.assertEq(
        `{"test6":null}`,
        JSON.stringify(data),
        `Use default value, even if null for ${storageAreaDesc}`
      );

      data = await storage.get("test6");
      browser.test.assertEq(
        `{}`,
        JSON.stringify(data),
        `Empty result if key is not found for ${storageAreaDesc}`
      );

      data = await storage.get(["test6", "test7"]);
      browser.test.assertEq(
        `{}`,
        JSON.stringify(data),
        `Empty result if list of keys is not found for ${storageAreaDesc}`
      );
    }

    // Regression test https://bugzilla.mozilla.org/show_bug.cgi?id=1989840
    async function testDeeplyNestedObject(areaName) {
      const obj = {};
      let current = obj;
      for (let i = 0; i < 127; i++) {
        const next = {};
        current.foo = next;
        current = next;
      }
      let storage = browser.storage[areaName];
      if (areaName == "sync") {
        // TODO(bug 1990313)
        // Storing a deeply nested object currently fails for the Rust sync storage, but not Kinto.
        // Let's make sure it doesn't crash in any case.
        let errorMessage = null;
        try {
          await storage.set(obj);
        } catch (e) {
          errorMessage = e.toString();
          // The caller expects one storage event. To make sure that the event is
          // fired, trigger a dummy change.
          storage.set({ foo: "dummy" });
        }
        browser.test.assertTrue(
          errorMessage === null ||
            errorMessage.includes("An unexpected error occurred"),
          `Invalid exception message when storing deeply nested object: ${errorMessage}`
        );
      } else {
        // Storing a deeply nested object should succeed for non-sync engines.
        await storage.set(obj);
      }
    }

    async function testFalseyValues(areaName) {
      let storage = browser.storage[areaName];
      const dataInitial = {
        "test-falsey-value-bool": false,
        "test-falsey-value-string": "",
        "test-falsey-value-number": 0,
      };
      const dataUpdate = {
        "test-falsey-value-bool": true,
        "test-falsey-value-string": "non-empty-string",
        "test-falsey-value-number": 10,
      };

      // Compute the expected changes.
      const onSetInitial = {
        "test-falsey-value-bool": { newValue: false },
        "test-falsey-value-string": { newValue: "" },
        "test-falsey-value-number": { newValue: 0 },
      };
      const onRemovedFalsey = {
        "test-falsey-value-bool": { oldValue: false },
        "test-falsey-value-string": { oldValue: "" },
        "test-falsey-value-number": { oldValue: 0 },
      };
      const onUpdatedFalsey = {
        "test-falsey-value-bool": { newValue: true, oldValue: false },
        "test-falsey-value-string": {
          newValue: "non-empty-string",
          oldValue: "",
        },
        "test-falsey-value-number": { newValue: 10, oldValue: 0 },
      };
      const keys = Object.keys(dataInitial);

      // Test on removing falsey values.
      await storage.set(dataInitial);
      await checkChanges(areaName, onSetInitial, "set falsey values");
      await storage.remove(keys);
      await checkChanges(areaName, onRemovedFalsey, "remove falsey value");

      // Test on updating falsey values.
      await storage.set(dataInitial);
      await checkChanges(areaName, onSetInitial, "set falsey values");
      await storage.set(dataUpdate);
      await checkChanges(areaName, onUpdatedFalsey, "set non-falsey values");

      // Clear the storage state.
      await testNonExistingKeys(storage, `${areaName} before clearing`);
      await storage.clear();
      await testNonExistingKeys(storage, `${areaName} after clearing`);
      await globalChanges;
      clearGlobalChanges();
    }

    function CustomObj() {
      this.testKey1 = "testValue1";
    }

    CustomObj.prototype.toString = function () {
      return '{"testKey2":"testValue2"}';
    };

    CustomObj.prototype.toJSON = function customObjToJSON() {
      return { testKey1: "testValue3" };
    };

    /* eslint-disable dot-notation */
    async function runTests(areaName) {
      expectedAreaName = areaName;
      let storage = browser.storage[areaName];
      // Set some data and then test getters.
      try {
        await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
        await checkChanges(
          areaName,
          {
            "test-prop1": { newValue: "value1" },
            "test-prop2": { newValue: "value2" },
          },
          "set (a)"
        );

        await checkGet(areaName, "test-prop1", "value1");
        await checkGet(areaName, "test-prop2", "value2");

        let data = await storage.get({
          "test-prop1": undefined,
          "test-prop2": undefined,
          other: "default",
        });
        browser.test.assertEq(
          "value1",
          data["test-prop1"],
          "prop1 correct (a)"
        );
        browser.test.assertEq(
          "value2",
          data["test-prop2"],
          "prop2 correct (a)"
        );
        browser.test.assertEq("default", data["other"], "other correct");

        data = await storage.get(["test-prop1", "test-prop2", "other"]);
        browser.test.assertEq(
          "value1",
          data["test-prop1"],
          "prop1 correct (b)"
        );
        browser.test.assertEq(
          "value2",
          data["test-prop2"],
          "prop2 correct (b)"
        );
        browser.test.assertFalse("other" in data, "other correct");

        // Remove data in various ways.
        await storage.remove("test-prop1");
        await checkChanges(
          areaName,
          { "test-prop1": { oldValue: "value1" } },
          "remove string"
        );

        data = await storage.get(["test-prop1", "test-prop2"]);
        browser.test.assertFalse(
          "test-prop1" in data,
          "prop1 absent (remove string)"
        );
        browser.test.assertTrue(
          "test-prop2" in data,
          "prop2 present (remove string)"
        );

        await storage.set({ "test-prop1": "value1" });
        await checkChanges(
          areaName,
          { "test-prop1": { newValue: "value1" } },
          "set (c)"
        );

        data = await storage.get(["test-prop1", "test-prop2"]);
        browser.test.assertEq(
          data["test-prop1"],
          "value1",
          "prop1 correct (c)"
        );
        browser.test.assertEq(
          data["test-prop2"],
          "value2",
          "prop2 correct (c)"
        );

        await storage.remove(["test-prop1", "test-prop2"]);
        await checkChanges(
          areaName,
          {
            "test-prop1": { oldValue: "value1" },
            "test-prop2": { oldValue: "value2" },
          },
          "remove array"
        );

        data = await storage.get(["test-prop1", "test-prop2"]);
        browser.test.assertFalse(
          "test-prop1" in data,
          "prop1 absent (remove array)"
        );
        browser.test.assertFalse(
          "test-prop2" in data,
          "prop2 absent (remove array)"
        );

        await testFalseyValues(areaName);

        // test storage.clear
        await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
        // Make sure that set() handler happened before we clear the
        // promise again.
        await globalChanges;

        clearGlobalChanges();
        await storage.clear();

        await checkChanges(
          areaName,
          {
            "test-prop1": { oldValue: "value1" },
            "test-prop2": { oldValue: "value2" },
          },
          "clear"
        );
        data = await storage.get(["test-prop1", "test-prop2"]);
        browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
        browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");

        // Make sure we can store complex JSON data.
        // known previous values
        await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });

        // Make sure the set() handler landed.
        await globalChanges;

        let date = new Date(0);

        clearGlobalChanges();
        await storage.set({
          "test-prop1": {
            str: "hello",
            bool: true,
            null: null,
            undef: undefined,
            obj: {},
            nestedObj: {
              testKey: {},
            },
            intKeyObj: {
              4: "testValue1",
              3: "testValue2",
              99: "testValue3",
            },
            floatKeyObj: {
              1.4: "testValue1",
              5.5: "testValue2",
            },
            customObj: new CustomObj(),
            arr: [1, 2],
            nestedArr: [1, [2, 3]],
            date,
            regexp: /regexp/,
          },
        });

        await browser.test.assertRejects(
          storage.set({
            window,
          }),
          /DataCloneError|cyclic object value/
        );

        await browser.test.assertRejects(
          storage.set({ "test-prop2": function func() {} }),
          /DataCloneError/
        );

        const recentChanges = await globalChanges;

        browser.test.assertEq(
          "value1",
          recentChanges["test-prop1"].oldValue,
          "oldValue correct"
        );
        browser.test.assertEq(
          "object",
          typeof recentChanges["test-prop1"].newValue,
          "newValue is obj"
        );
        clearGlobalChanges();

        data = await storage.get({
          "test-prop1": undefined,
          "test-prop2": undefined,
        });
        let obj = data["test-prop1"];

        browser.test.assertEq(
          "object",
          typeof obj.customObj,
          "custom object part correct"
        );
        browser.test.assertEq(
          1,
          Object.keys(obj.customObj).length,
          "customObj keys correct"
        );

        if (areaName === "local" || areaName === "session") {
          browser.test.assertEq(
            String(date),
            String(obj.date),
            "date part correct"
          );
          browser.test.assertEq(
            "/regexp/",
            obj.regexp.toString(),
            "regexp part correct"
          );
          // storage.local and .session don't use toJSON().
          browser.test.assertEq(
            "testValue1",
            obj.customObj.testKey1,
            "customObj keys correct"
          );
        } else {
          browser.test.assertEq(
            "1970-01-01T00:00:00.000Z",
            String(obj.date),
            "date part correct"
          );

          browser.test.assertEq(
            "object",
            typeof obj.regexp,
            "regexp part is an object"
          );
          browser.test.assertEq(
            0,
            Object.keys(obj.regexp).length,
            "regexp part is an empty object"
          );
          // storage.sync does call toJSON
          browser.test.assertEq(
            "testValue3",
            obj.customObj.testKey1,
            "customObj keys correct"
          );
        }

        browser.test.assertEq("hello", obj.str, "string part correct");
        browser.test.assertEq(true, obj.bool, "bool part correct");
        browser.test.assertEq(null, obj.null, "null part correct");
        browser.test.assertEq(undefined, obj.undef, "undefined part correct");
        browser.test.assertEq(undefined, obj.window, "window part correct");
        browser.test.assertEq("object", typeof obj.obj, "object part correct");
        browser.test.assertEq(
          "object",
          typeof obj.nestedObj,
          "nested object part correct"
        );
        browser.test.assertEq(
          "object",
          typeof obj.nestedObj.testKey,
          "nestedObj.testKey part correct"
        );
        browser.test.assertEq(
          "object",
          typeof obj.intKeyObj,
          "int key object part correct"
        );
        browser.test.assertEq(
          "testValue1",
          obj.intKeyObj[4],
          "intKeyObj[4] part correct"
        );
        browser.test.assertEq(
          "testValue2",
          obj.intKeyObj[3],
          "intKeyObj[3] part correct"
        );
        browser.test.assertEq(
          "testValue3",
          obj.intKeyObj[99],
          "intKeyObj[99] part correct"
        );
        browser.test.assertEq(
          "object",
          typeof obj.floatKeyObj,
          "float key object part correct"
        );
        browser.test.assertEq(
          "testValue1",
          obj.floatKeyObj[1.4],
          "floatKeyObj[1.4] part correct"
        );
        browser.test.assertEq(
          "testValue2",
          obj.floatKeyObj[5.5],
          "floatKeyObj[5.5] part correct"
        );

        browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
        browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
        browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
        browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
        browser.test.assertTrue(
          Array.isArray(obj.nestedArr),
          "nested array part present"
        );
        browser.test.assertEq(
          2,
          obj.nestedArr.length,
          "nestedArr.length part correct"
        );
        browser.test.assertEq(1, obj.nestedArr[0], "nestedArr[0] part correct");
        browser.test.assertTrue(
          Array.isArray(obj.nestedArr[1]),
          "nestedArr[1] part present"
        );
        browser.test.assertEq(
          2,
          obj.nestedArr[1].length,
          "nestedArr[1].length part correct"
        );
        browser.test.assertEq(
          2,
          obj.nestedArr[1][0],
          "nestedArr[1][0] part correct"
        );
        browser.test.assertEq(
          3,
          obj.nestedArr[1][1],
          "nestedArr[1][1] part correct"
        );

        // test storage.getKeys()
        clearGlobalChanges();
        await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
        await globalChanges;
        const keys = await storage.getKeys();
        browser.test.assertDeepEq(
          keys,
          ["test-prop1", "test-prop2"],
          "getKeys() returns the correct keys"
        );
        clearGlobalChanges();
        await storage.clear();
        await globalChanges;

        clearGlobalChanges();
        await testDeeplyNestedObject(areaName);
        await globalChanges;
        clearGlobalChanges();
        await storage.clear();
        await globalChanges;
      } catch (e) {
        browser.test.fail(`Error: ${e} :: ${e.stack}`);
        browser.test.notifyFail("storage");
      }
    }

    browser.test.onMessage.addListener(msg => {
      let promise;
      if (msg === "test-local") {
        promise = runTests("local");
      } else if (msg === "test-sync") {
        promise = runTests("sync");
      } else if (msg === "test-session") {
        promise = runTests("session");
      }
      promise.then(() => browser.test.sendMessage("test-finished"));
    });

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

  let extensionData = {
    background: `(${backgroundScript})(${checkGetImpl})`,
    manifest: {
      permissions: ["storage"],
    },
  };

  let extension = ExtensionTestUtils.loadExtension(extensionData);
  await extension.startup();
  await extension.awaitMessage("ready");

  extension.sendMessage(`test-${testAreaName}`);
  await extension.awaitMessage("test-finished");

  await extension.unload();
}

async function test_storage_sync_requires_real_id() {
  async function testFn() {
    async function background() {
      const EXCEPTION_MESSAGE =
        "The storage API will not work with a temporary addon ID. " +
        "Please add an explicit addon ID to your manifest. " +
        "For more information see https://mzl.la/3lPk1aE.";

      await browser.test.assertRejects(
        browser.storage.sync.set({ foo: "bar" }),
        EXCEPTION_MESSAGE
      );

      browser.test.notifyPass("exception correct");
    }

    let extensionData = {
      background,
      manifest: {
        permissions: ["storage"],
      },
      useAddonManager: "temporary",
    };

    let extension = ExtensionTestUtils.loadExtension(extensionData);
    await extension.startup();
    await extension.awaitFinish("exception correct");

    await extension.unload();
  }

  return await testFn();
}

// Test for storage areas which support getBytesInUse() but not QUOTA
// constants.
async function check_storage_area_getBytesInUse(area) {
  let impl = browser.storage[area];

  browser.test.assertEq(
    typeof impl.getBytesInUse,
    "function",
    "getBytesInUse API method should be available"
  );
  browser.test.sendMessage("test-complete");
}

async function test_contentscript_storage_area_getBytesInUse(area) {
  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/data/file_sample.html"
  );

  function contentScript(checkImpl) {
    browser.test.onMessage.addListener(msg => {
      if (msg === "test-local") {
        checkImpl("local");
      } else if (msg === "test-sync") {
        checkImpl("sync");
      } else if (msg === "test-session") {
        checkImpl("session");
      } else {
        browser.test.fail(`Unexpected test message received: ${msg}`);
        browser.test.sendMessage("test-complete");
      }
    });
    browser.test.sendMessage("ready");
  }

  let extensionData = {
    manifest: {
      content_scripts: [
        {
          matches: ["http://example.com/data/file_sample.html"],
          js: ["content_script.js"],
          run_at: "document_idle",
        },
      ],

      permissions: ["storage"],
    },

    files: {
      "content_script.js": `(${contentScript})(${check_storage_area_getBytesInUse})`,
    },
  };

  let extension = ExtensionTestUtils.loadExtension(extensionData);
  await extension.startup();
  await extension.awaitMessage("ready");

  extension.sendMessage(`test-${area}`);
  await extension.awaitMessage("test-complete");

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

// Test for storage areas which do support getBytesInUse() (but which may or may
// not support enforcement of the quota). Currently only works with `sync`
// storage.
async function check_storage_sync_getBytesInUse(area, expectQuota) {
  let impl = browser.storage[area];

  // QUOTA_* constants aren't currently exposed - see bug 1396810.
  // However, the quotas are still enforced, so test them here.
  // (Note that an implication of this is that we can't test area other than
  // 'sync', because its limits are different - so for completeness...)
  browser.test.assertEq(
    area,
    "sync",
    "Running test on storage.sync API as expected"
  );
  const QUOTA_BYTES_PER_ITEM = 8192;
  const MAX_ITEMS = 512;

  // bytes is counted as "length of key as a string, length of value as
  // JSON" - ie, quotes not counted in the key, but are in the value.
  let value = "x".repeat(QUOTA_BYTES_PER_ITEM - 3);

  await impl.set({ x: value }); // Shouldn't reject on either kinto or rust-based storage.sync.
  browser.test.assertEq(await impl.getBytesInUse(null), QUOTA_BYTES_PER_ITEM);
  // kinto does implement getBytesInUse() but doesn't enforce a quota.
  if (expectQuota) {
    await browser.test.assertRejects(
      impl.set({ x: value + "x" }),
      "QuotaExceededError: storage.sync API call exceeded its quota limitations.",
      "Got a rejection with the expected error message"
    );
    // MAX_ITEMS
    await impl.clear();
    let ob = {};
    for (let i = 0; i < MAX_ITEMS; i++) {
      ob[`key-${i}`] = "x";
    }
    await impl.set(ob); // should work.
    await browser.test.assertRejects(
      impl.set({ straw: "camel's back" }), // exceeds MAX_ITEMS
      "QuotaExceededError: storage.sync API call exceeded its quota limitations.",
      "Got a rejection with the expected error message"
    );
    // QUOTA_BYTES is being already tested for the underlying StorageSyncService
    // so we don't duplicate those tests here.
  } else {
    // Exceeding quota should work on the previous kinto-based storage.sync implementation
    await impl.set({ x: value + "x" }); // exceeds quota but should work.
    browser.test.assertEq(
      await impl.getBytesInUse(null),
      QUOTA_BYTES_PER_ITEM + 1,
      "Got the expected result from getBytesInUse"
    );
  }
  browser.test.sendMessage("test-complete");
}

async function test_background_storage_area_with_bytes_in_use(
  area,
  expectQuota
) {
  const EXT_ID = "test-gbiu@mozilla.org";

  const extensionDef = {
    manifest: {
      permissions: ["storage"],
      browser_specific_settings: { gecko: { id: EXT_ID } },
    },
    background: `(${check_storage_sync_getBytesInUse})("${area}", ${expectQuota})`,
  };

  const extension = ExtensionTestUtils.loadExtension(extensionDef);

  await extension.startup();
  await extension.awaitMessage("test-complete");
  await extension.unload();
}

async function test_contentscript_storage_sync_getBytesInUse(
  area,
  expectQuota
) {
  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/data/file_sample.html"
  );

  function contentScript(checkImpl) {
    browser.test.onMessage.addListener(([area, expectQuota]) => {
      if (
        !["local", "sync"].includes(area) ||
        typeof expectQuota !== "boolean"
      ) {
        browser.test.fail(`Unexpected test message: [${area}, ${expectQuota}]`);
        // Let the test to fail immediately instead of wait for a timeout failure.
        browser.test.sendMessage("test-complete");
        return;
      }
      checkImpl(area, expectQuota);
    });
    browser.test.sendMessage("ready");
  }

  let extensionData = {
    manifest: {
      content_scripts: [
        {
          matches: ["http://example.com/data/file_sample.html"],
          js: ["content_script.js"],
          run_at: "document_idle",
        },
      ],

      permissions: ["storage"],
    },

    files: {
      "content_script.js": `(${contentScript})(${check_storage_sync_getBytesInUse})`,
    },
  };

  let extension = ExtensionTestUtils.loadExtension(extensionData);
  await extension.startup();
  await extension.awaitMessage("ready");

  extension.sendMessage([area, expectQuota]);
  await extension.awaitMessage("test-complete");

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

// A couple of common tests for checking content scripts.
async function testStorageContentScript(checkGet) {
  let globalChanges, gResolve;
  function clearGlobalChanges() {
    globalChanges = new Promise(resolve => {
      gResolve = resolve;
    });
  }
  clearGlobalChanges();
  let expectedAreaName;

  browser.storage.onChanged.addListener((changes, areaName) => {
    browser.test.assertEq(
      expectedAreaName,
      areaName,
      "Expected area name received by listener"
    );
    gResolve(changes);
  });

  async function checkChanges(areaName, changes, message) {
    function checkSub(obj1, obj2) {
      for (let prop in obj1) {
        browser.test.assertTrue(
          obj1[prop] !== undefined,
          `checkChanges ${areaName} ${prop} is missing (${message})`
        );
        browser.test.assertTrue(
          obj2[prop] !== undefined,
          `checkChanges ${areaName} ${prop} is missing (${message})`
        );
        browser.test.assertEq(
          obj1[prop].oldValue,
          obj2[prop].oldValue,
          `checkChanges ${areaName} ${prop} old (${message})`
        );
        browser.test.assertEq(
          obj1[prop].newValue,
          obj2[prop].newValue,
          `checkChanges ${areaName} ${prop} new (${message})`
        );
      }
    }

    const recentChanges = await globalChanges;
    checkSub(changes, recentChanges);
    checkSub(recentChanges, changes);
    clearGlobalChanges();
  }

  /* eslint-disable dot-notation */
  async function runTests(areaName) {
    expectedAreaName = areaName;
    let storage = browser.storage[areaName];
    // Set some data and then test getters.
    try {
      await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
      await checkChanges(
        areaName,
        {
          "test-prop1": { newValue: "value1" },
          "test-prop2": { newValue: "value2" },
        },
        "set (a)"
      );

      await checkGet(areaName, "test-prop1", "value1");
      await checkGet(areaName, "test-prop2", "value2");

      let data = await storage.get({
        "test-prop1": undefined,
        "test-prop2": undefined,
        other: "default",
      });
      browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (a)");
      browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (a)");
      browser.test.assertEq("default", data["other"], "other correct");

      data = await storage.get(["test-prop1", "test-prop2", "other"]);
      browser.test.assertEq("value1", data["test-prop1"], "prop1 correct (b)");
      browser.test.assertEq("value2", data["test-prop2"], "prop2 correct (b)");
      browser.test.assertFalse("other" in data, "other correct");

      // Remove data in various ways.
      await storage.remove("test-prop1");
      await checkChanges(
        areaName,
        { "test-prop1": { oldValue: "value1" } },
        "remove string"
      );

      data = await storage.get(["test-prop1", "test-prop2"]);
      browser.test.assertFalse(
        "test-prop1" in data,
        "prop1 absent (remove string)"
      );
      browser.test.assertTrue(
        "test-prop2" in data,
        "prop2 present (remove string)"
      );

      await storage.set({ "test-prop1": "value1" });
      await checkChanges(
        areaName,
        { "test-prop1": { newValue: "value1" } },
        "set (c)"
      );

      data = await storage.get(["test-prop1", "test-prop2"]);
      browser.test.assertEq(data["test-prop1"], "value1", "prop1 correct (c)");
      browser.test.assertEq(data["test-prop2"], "value2", "prop2 correct (c)");

      await storage.remove(["test-prop1", "test-prop2"]);
      await checkChanges(
        areaName,
        {
          "test-prop1": { oldValue: "value1" },
          "test-prop2": { oldValue: "value2" },
        },
        "remove array"
      );

      data = await storage.get(["test-prop1", "test-prop2"]);
      browser.test.assertFalse(
        "test-prop1" in data,
        "prop1 absent (remove array)"
      );
      browser.test.assertFalse(
        "test-prop2" in data,
        "prop2 absent (remove array)"
      );

      // test storage.clear
      await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });
      // Make sure that set() handler happened before we clear the
      // promise again.
      await globalChanges;

      clearGlobalChanges();
      await storage.clear();

      await checkChanges(
        areaName,
        {
          "test-prop1": { oldValue: "value1" },
          "test-prop2": { oldValue: "value2" },
        },
        "clear"
      );
      data = await storage.get(["test-prop1", "test-prop2"]);
      browser.test.assertFalse("test-prop1" in data, "prop1 absent (clear)");
      browser.test.assertFalse("test-prop2" in data, "prop2 absent (clear)");

      // Make sure we can store complex JSON data.
      // known previous values
      await storage.set({ "test-prop1": "value1", "test-prop2": "value2" });

      // Make sure the set() handler landed.
      await globalChanges;

      let date = new Date(0);

      clearGlobalChanges();
      await storage.set({
        "test-prop1": {
          str: "hello",
          bool: true,
          null: null,
          undef: undefined,
          obj: {},
          arr: [1, 2],
          date: new Date(0),
          regexp: /regexp/,
        },
      });

      await browser.test.assertRejects(
        storage.set({
          window,
        }),
        /DataCloneError|cyclic object value/
      );

      await browser.test.assertRejects(
        storage.set({ "test-prop2": function func() {} }),
        /DataCloneError/
      );

      const recentChanges = await globalChanges;

      browser.test.assertEq(
        "value1",
        recentChanges["test-prop1"].oldValue,
        "oldValue correct"
      );
      browser.test.assertEq(
        "object",
        typeof recentChanges["test-prop1"].newValue,
        "newValue is obj"
      );
      clearGlobalChanges();

      data = await storage.get({
        "test-prop1": undefined,
        "test-prop2": undefined,
      });
      let obj = data["test-prop1"];

      if (areaName === "local") {
        browser.test.assertEq(
          String(date),
          String(obj.date),
          "date part correct"
        );
        browser.test.assertEq(
          "/regexp/",
          obj.regexp.toString(),
          "regexp part correct"
        );
      } else {
        browser.test.assertEq(
          "1970-01-01T00:00:00.000Z",
          String(obj.date),
          "date part correct"
        );

        browser.test.assertEq(
          "object",
          typeof obj.regexp,
          "regexp part is an object"
        );
        browser.test.assertEq(
          0,
          Object.keys(obj.regexp).length,
          "regexp part is an empty object"
        );
      }

      browser.test.assertEq("hello", obj.str, "string part correct");
      browser.test.assertEq(true, obj.bool, "bool part correct");
      browser.test.assertEq(null, obj.null, "null part correct");
      browser.test.assertEq(undefined, obj.undef, "undefined part correct");
      browser.test.assertEq(undefined, obj.window, "window part correct");
      browser.test.assertEq("object", typeof obj.obj, "object part correct");
      browser.test.assertTrue(Array.isArray(obj.arr), "array part present");
      browser.test.assertEq(1, obj.arr[0], "arr[0] part correct");
      browser.test.assertEq(2, obj.arr[1], "arr[1] part correct");
      browser.test.assertEq(2, obj.arr.length, "arr.length part correct");
    } catch (e) {
      browser.test.fail(`Error: ${e} :: ${e.stack}`);
      browser.test.notifyFail("storage");
    }
  }

  browser.test.onMessage.addListener(msg => {
    let promise;
    if (msg === "test-local") {
      promise = runTests("local");
    } else if (msg === "test-sync") {
      promise = runTests("sync");
    } else if (msg === "test-session") {
      promise = runTests("session");
    }
    promise.then(() => browser.test.sendMessage("test-finished"));
  });

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

async function test_contentscript_storage(storageType) {
  let contentPage = await ExtensionTestUtils.loadContentPage(
    "http://example.com/data/file_sample.html"
  );

  let extensionData = {
    manifest: {
      content_scripts: [
        {
          matches: ["http://example.com/data/file_sample.html"],
          js: ["content_script.js"],
          run_at: "document_idle",
        },
      ],

      permissions: ["storage"],
    },

    files: {
      "content_script.js": `(${testStorageContentScript})(${checkGetImpl})`,
    },
  };

  let extension = ExtensionTestUtils.loadExtension(extensionData);
  await extension.startup();
  await extension.awaitMessage("ready");

  extension.sendMessage(`test-${storageType}`);
  await extension.awaitMessage("test-finished");

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

async function test_storage_empty_events(areaName) {
  async function background(areaName) {
    let eventCount = 0;

    browser.storage[areaName].onChanged.addListener(changes => {
      browser.test.sendMessage("onChanged", [++eventCount, changes]);
    });

    browser.test.onMessage.addListener(async (method, arg) => {
      let result = await browser.storage[areaName][method](arg);
      browser.test.sendMessage("result", result);
    });

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

  let extension = ExtensionTestUtils.loadExtension({
    manifest: { permissions: ["storage"] },
    background: `(${background})("${areaName}")`,
  });

  await extension.startup();
  await extension.awaitMessage("ready");

  async function callStorageMethod(method, arg) {
    info(`call storage.${areaName}.${method}(${JSON.stringify(arg) ?? ""})`);
    extension.sendMessage(method, arg);
    await extension.awaitMessage("result");
  }

  async function expectEvent(expectCount, expectChanges) {
    equal(
      JSON.stringify([expectCount, expectChanges]),
      JSON.stringify(await extension.awaitMessage("onChanged")),
      "Correct onChanged events count and data in the last changes notified."
    );
  }

  await callStorageMethod("set", { alpha: 1 });
  await expectEvent(1, { alpha: { newValue: 1 } });

  await callStorageMethod("set", {});
  // Setting nothing doesn't trigger onChanged event.

  await callStorageMethod("set", { beta: 12 });
  await expectEvent(2, { beta: { newValue: 12 } });

  await callStorageMethod("remove", "alpha");
  await expectEvent(3, { alpha: { oldValue: 1 } });

  await callStorageMethod("remove", "alpha");
  // Trying to remove alpha again doesn't trigger onChanged.

  await callStorageMethod("clear");
  await expectEvent(4, { beta: { oldValue: 12 } });

  await callStorageMethod("clear");
  // Clear again wothout onChanged. Test will fail on unexpected event/message.

  await extension.unload();
}

async function test_storage_change_event_page(areaName) {
  async function testOnChanged(targetIsStorageArea) {
    function backgroundTestStorageTopNamespace(areaName) {
      browser.storage.onChanged.addListener((changes, area) => {
        browser.test.assertEq(area, areaName, "Expected areaName");
        browser.test.assertEq(
          JSON.stringify(changes),
          `{"storageKey":{"newValue":"newStorageValue"}}`,
          "Expected changes"
        );
        browser.test.sendMessage("onChanged_was_fired");
      });
    }
    function backgroundTestStorageAreaNamespace(areaName) {
      browser.storage[areaName].onChanged.addListener((changes, ...args) => {
        browser.test.assertEq(args.length, 0, "no more args after changes");
        browser.test.assertEq(
          JSON.stringify(changes),
          `{"storageKey":{"newValue":"newStorageValue"}}`,
          `Expected changes via ${areaName}.onChanged event`
        );
        browser.test.sendMessage("onChanged_was_fired");
      });
    }
    let background, onChangedName;
    if (targetIsStorageArea) {
      // Test storage.local.onChanged / storage.sync.onChanged.
      background = backgroundTestStorageAreaNamespace;
      onChangedName = `${areaName}.onChanged`;
    } else {
      background = backgroundTestStorageTopNamespace;
      onChangedName = "onChanged";
    }
    let extension = ExtensionTestUtils.loadExtension({
      manifest: {
        permissions: ["storage"],
        background: { persistent: false },
      },
      background: `(${background})("${areaName}")`,
      files: {
        "trigger-change.html": `
          <!DOCTYPE html><meta charset="utf-8">
          <script src="trigger-change.js"></script>
        `,
        "trigger-change.js": async () => {
          let areaName = location.search.slice(1);
          await browser.storage[areaName].set({
            storageKey: "newStorageValue",
          });
          browser.test.sendMessage("tried_to_trigger_change");
        },
      },
    });
    await extension.startup();
    assertPersistentListeners(extension, "storage", onChangedName, {
      primed: false,
    });

    await extension.terminateBackground();
    assertPersistentListeners(extension, "storage", onChangedName, {
      primed: true,
    });

    // Now trigger the event
    let contentPage = await ExtensionTestUtils.loadContentPage(
      `moz-extension://${extension.uuid}/trigger-change.html?${areaName}`
    );
    await extension.awaitMessage("tried_to_trigger_change");
    await contentPage.close();
    await extension.awaitMessage("onChanged_was_fired");

    assertPersistentListeners(extension, "storage", onChangedName, {
      primed: false,
    });
    await extension.unload();
  }

  async function testFn() {
    // Test browser.storage.onChanged.addListener
    await testOnChanged(/* targetIsStorageArea */ false);
    // Test browser.storage.local.onChanged.addListener
    // and browser.storage.sync.onChanged.addListener, depending on areaName.
    await testOnChanged(/* targetIsStorageArea */ true);
  }

  return runWithPrefs([["extensions.eventPages.enabled", true]], testFn);
}

async function test_storage_sync_telemetry_quota(backend, enforced = false) {
  Services.fog.testResetFOG();
  let id = "my-extension-id@23";

  // Repeat twice to get the glean events "before" and "after".
  for (let i = 0; i < 2; i++) {
    let ext = ExtensionTestUtils.loadExtension({
      manifest: {
        browser_specific_settings: { gecko: { id: id } },
        permissions: ["storage"],
      },
      background() {
        browser.test.onMessage.addListener(async enforced => {
          await browser.storage.sync.set({
            a: "1",
            b: "x".repeat(enforced ? 1_000 : 10_000),
          });
          browser.test.notifyPass("done");
        });
      },
    });

    await ext.startup();
    ext.sendMessage(enforced);
    await ext.awaitFinish("done");
    await ext.unload();
  }

  let events = Glean.extensionsData.syncUsageQuotas.testGetValue();
  events = events.filter(e => e.extra?.addon_id === id);

  Assert.deepEqual(
    events[0].extra,
    {
      addon_id: id,
      total_size_bytes: 0,
      items_count: 0,
      items_over_quota: 0,
      backend: backend,
    },
    "Expected event values before setting sync storage"
  );
  Assert.deepEqual(
    events[1].extra,
    {
      addon_id: id,
      total_size_bytes: enforced ? 1_007 : 10_007,
      items_count: 2,
      items_over_quota: enforced ? 0 : 1,
      backend: backend,
    },
    "Expected event values after setting sync storage"
  );
}

// This test doesn't support the `managed` storage area or the Kinto
// implementation of `sync` storage area.
async function check_get_bytes_in_use(areaName, idbBackend) {
  const storage = browser.storage[areaName];
  let expectedKey = areaName;
  if (areaName === "local") {
    expectedKey += idbBackend ? "-idb" : "-json";
  }
  const tests = [
    {
      key: null,
      expected: {
        "local-json": 118,
        "local-idb": 228,
        session: 228,
        sync: 118,
      },
      message: "getBytesInUse(null) returns correct value for storage",
    },
    {
      key: "key2",
      expected: { "local-json": 23, "local-idb": 60, session: 60, sync: 23 },
      message: "getBytesInUse() returns correct value for a single key",
    },
    {
      key: "foo",
      expected: { "local-json": 0, "local-idb": 0, session: 0, sync: 0 },
      message: "getBytesInUse() returns 0 for a key not in storage",
    },
    {
      key: [],
      expected: { "local-json": 0, "local-idb": 0, session: 0, sync: 0 },
      message: "getBytesInUse() returns 0 for an empty array of keys",
    },
    {
      key: ["key1", "key3"],
      expected: { "local-json": 21, "local-idb": 48, session: 48, sync: 21 },
      message: "getBytesInUse() returns correct value for an array of keys",
    },
    {
      key: "",
      expected: { "local-json": 39, "local-idb": 56, session: 56, sync: 39 },
      message:
        'getBytesInUse() returns correct value for an empty string key ("")',
    },
  ];

  try {
    browser.test.assertEq(
      0,
      await storage.getBytesInUse(),
      "getBytesInUse() returns 0 when storage is empty"
    );

    await storage.set({
      key1: "value1",
      key2: { nested: "value2" },
      key3: 12345,
      key4: "Привет, мир!",
      null: null,
      "": "Value for key that's an empty string.",
    });

    for (let test of tests) {
      browser.test.assertEq(
        test.expected[expectedKey],
        await storage.getBytesInUse(test.key),
        test.message
      );
    }
  } catch (e) {
    browser.test.fail(`Error: ${e} :: ${e.stack}`);
    browser.test.notifyFail("storage");
  }

  browser.test.sendMessage("test-finished");
}

/**
 *
 * @param {string} areaName Currently only `local` storage is supported.
 * @param {boolean} isIdbBackend Applicable for `local` storage only. If true, the
 *                             test expects the IndexedDB backend to be used.
 */
async function test_get_bytes_in_use(areaName, isIdbBackend) {
  const extensionData = {
    background: `(${check_get_bytes_in_use})("${areaName}", ${isIdbBackend})`,
    manifest: {
      permissions: ["storage"],
      browser_specific_settings: {
        gecko: { id: "test-getBytesInUse@mozilla.org" },
      },
    },
  };

  const extension = ExtensionTestUtils.loadExtension(extensionData);
  await extension.startup();
  await extension.awaitMessage("test-finished");
  await extension.unload();
}
