/* 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 records code loaded during a dummy background task.
 *
 * To run this test similar to try server, you need to run:
 *   ./mach package
 *   ./mach test --appname=dist <path to test>
 *
 * If you made changes that cause this test to fail, it's likely
 * because you are changing the application startup process.  In
 * general, you should prefer to defer loading code as long as you
 * can, especially if it's not going to be used in background tasks.
 */

"use strict";

const Cm = Components.manager.QueryInterface(Ci.nsIComponentRegistrar);

// Shortcuts for conditions.
const LINUX = AppConstants.platform == "linux";
const WIN = AppConstants.platform == "win";
const MAC = AppConstants.platform == "macosx";

const backgroundtaskPhases = {
  AfterRunBackgroundTaskNamed: {
    allowlist: {
      modules: [
        "resource://gre/modules/AppConstants.sys.mjs",
        "resource://gre/modules/AsyncShutdown.sys.mjs",
        "resource://gre/modules/BackgroundTasksManager.sys.mjs",
        "resource://gre/modules/Console.sys.mjs",
        "resource://gre/modules/EnterprisePolicies.sys.mjs",
        "resource://gre/modules/EnterprisePoliciesParent.sys.mjs",
        "resource://gre/modules/XPCOMUtils.sys.mjs",
        "resource://gre/modules/nsAsyncShutdown.sys.mjs",
      ],
      // Human-readable contract IDs are many-to-one mapped to CIDs, so this
      // list is a little misleading.  For example, all of
      // "@mozilla.org/xre/app-info;1", "@mozilla.org/xre/runtime;1", and
      // "@mozilla.org/toolkit/crash-reporter;1", map to the CID
      // {95d89e3e-a169-41a3-8e56-719978e15b12}, but only one is listed here.
      // We could be more precise by listing CIDs, but that's a good deal harder
      // to read and modify.
      services: [
        "@mozilla.org/async-shutdown-service;1",
        "@mozilla.org/backgroundtasks;1",
        "@mozilla.org/backgroundtasksmanager;1",
        "@mozilla.org/base/telemetry;1",
        "@mozilla.org/categorymanager;1",
        "@mozilla.org/chrome/chrome-registry;1",
        "@mozilla.org/cookieService;1",
        "@mozilla.org/docloaderservice;1",
        "@mozilla.org/embedcomp/window-watcher;1",
        "@mozilla.org/enterprisepolicies;1",
        "@mozilla.org/file/directory_service;1",
        "@mozilla.org/intl/stringbundle;1",
        "@mozilla.org/layout/content-policy;1",
        "@mozilla.org/memory-reporter-manager;1",
        "@mozilla.org/network/captive-portal-service;1",
        "@mozilla.org/network/dns-service;1",
        "@mozilla.org/network/oblivious-http-service;1",
        "@mozilla.org/network/effective-tld-service;1",
        "@mozilla.org/network/idn-service;1",
        "@mozilla.org/network/io-service;1",
        "@mozilla.org/network/network-link-service;1",
        "@mozilla.org/network/protocol;1?name=file",
        "@mozilla.org/network/protocol;1?name=jar",
        "@mozilla.org/network/protocol;1?name=resource",
        "@mozilla.org/network/socket-transport-service;1",
        "@mozilla.org/network/stream-transport-service;1",
        "@mozilla.org/network/url-parser;1?auth=maybe",
        "@mozilla.org/network/url-parser;1?auth=no",
        "@mozilla.org/network/url-parser;1?auth=yes",
        "@mozilla.org/observer-service;1",
        "@mozilla.org/power/powermanagerservice;1",
        "@mozilla.org/preferences-service;1",
        "@mozilla.org/process/environment;1",
        "@mozilla.org/storage/service;1",
        "@mozilla.org/thirdpartyutil;1",
        "@mozilla.org/toolkit/app-startup;1",
        {
          name: "@mozilla.org/widget/appshell/mac;1",
          condition: MAC,
        },
        {
          name: "@mozilla.org/widget/appshell/gtk;1",
          condition: LINUX,
        },
        {
          name: "@mozilla.org/widget/appshell/win;1",
          condition: WIN,
        },
        "@mozilla.org/xpcom/debug;1",
        "@mozilla.org/xre/app-info;1",
        "@mozilla.org/mime;1",
      ],
    },
  },
  AfterFindRunBackgroundTask: {
    allowlist: {
      modules: [
        // We have a profile marker for this, even though it failed to load!
        "resource:///modules/backgroundtasks/BackgroundTask_wait.sys.mjs",

        "resource://gre/modules/ConsoleAPIStorage.sys.mjs",
        "resource://gre/modules/Timer.sys.mjs",

        // We have a profile marker for this, even though it failed to load!
        "resource://gre/modules/backgroundtasks/BackgroundTask_wait.sys.mjs",

        "resource://testing-common/backgroundtasks/BackgroundTask_wait.sys.mjs",
      ],
      services: ["@mozilla.org/consoleAPI-storage;1"],
    },
  },
  AfterAwaitRunBackgroundTask: {
    allowlist: {
      modules: [
        "resource://devtools/shared/security/DevToolsSocketStatus.sys.mjs",
      ],
      services: [],
    },
  },
};

function getStackFromProfile(profile, stack, libs) {
  const stackPrefixCol = profile.stackTable.schema.prefix;
  const stackFrameCol = profile.stackTable.schema.frame;
  const frameLocationCol = profile.frameTable.schema.location;

  let index = 1;
  let result = [];
  while (stack) {
    let sp = profile.stackTable.data[stack];
    let frame = profile.frameTable.data[sp[stackFrameCol]];
    stack = sp[stackPrefixCol];
    frame = profile.stringTable[frame[frameLocationCol]];

    if (frame.startsWith("0x")) {
      try {
        let addr = frame.slice("0x".length);
        addr = Number.parseInt(addr, 16);
        for (let lib of libs) {
          if (lib.start <= addr && addr <= lib.end) {
            // Only handle two digits for now.
            let indexString = index.toString(10);
            if (indexString.length == 1) {
              indexString = "0" + indexString;
            }
            let offset = addr - lib.start;
            frame = `#${indexString}: ???[${lib.debugPath} ${
              "+0x" + offset.toString(16)
            }]`;
            break;
          }
        }
      } catch (e) {
        // Fall through.
      }
    }

    if (frame != "js::RunScript" && !frame.startsWith("next (self-hosted:")) {
      result.push(frame);
      index += 1;
    }
  }
  return result;
}

add_task(async function test_xpcom_graph_wait() {
  TestUtils.assertPackagedBuild();

  let profilePath = Services.env.get("MOZ_UPLOAD_DIR");
  profilePath =
    profilePath ||
    (await IOUtils.createUniqueDirectory(
      PathUtils.profileDir,
      "testBackgroundTask",
      0o700
    ));

  profilePath = PathUtils.join(profilePath, "profile_backgroundtask_wait.json");
  await IOUtils.remove(profilePath, { ignoreAbsent: true });

  let extraEnv = {
    MOZ_PROFILER_STARTUP: "1",
    MOZ_PROFILER_SHUTDOWN: profilePath,
  };

  let exitCode = await do_backgroundtask("wait", { extraEnv });
  Assert.equal(0, exitCode);

  let rootProfile = await IOUtils.readJSON(profilePath);
  let profile = rootProfile.threads[0];

  const nameCol = profile.markers.schema.name;
  const dataCol = profile.markers.schema.data;

  function newMarkers() {
    return {
      // The equivalent of `Cu.loadedESModules`.
      modules: [],
      services: [],
    };
  }

  let phases = {};
  let markersForCurrentPhase = newMarkers();

  // If a subsequent phase loads an already loaded resource, that's
  // fine.  Track all loaded resources to ignore such repeated loads.
  let markersForAllPhases = newMarkers();

  for (let m of profile.markers.data) {
    let markerName = profile.stringTable[m[nameCol]];
    if (markerName.startsWith("BackgroundTasksManager:")) {
      phases[markerName.split("BackgroundTasksManager:")[1]] =
        markersForCurrentPhase;
      markersForCurrentPhase = newMarkers();
      continue;
    }

    if (
      ![
        "ChromeUtils.import", // JSMs.
        "ChromeUtils.importESModule", // System ESMs.
        "ChromeUtils.importESModule static import",
        "GetService", // XPCOM services.
      ].includes(markerName)
    ) {
      continue;
    }

    let markerData = m[dataCol];
    if (
      markerName == "ChromeUtils.import" ||
      markerName == "ChromeUtils.importESModule" ||
      markerName == "ChromeUtils.importESModule static import"
    ) {
      let module = markerData.name;
      if (!markersForAllPhases.modules.includes(module)) {
        markersForAllPhases.modules.push(module);
        markersForCurrentPhase.modules.push(module);
      }
    }

    if (markerName == "GetService") {
      // We get a CID from the marker itself, but not a human-readable contract
      // ID.  Now, most of the time, the stack will contain a label like
      // `GetServiceByContractID @...;1`, and we could extract the contract ID
      // from that.  But there are multiple ways to instantiate services, and
      // not all of them are annotated in this manner.  Therefore we "go the
      // other way" and use the component manager's mapping from contract IDs to
      // CIDs.  This opens up the possibility for that mapping to be different
      // between `--backgroundtask` and `xpcshell`, but that's not an issue
      // right at this moment.  It's worth noting that one CID can (and
      // sometimes does) correspond to more than one contract ID.
      let cid = markerData.name;

      if (!markersForAllPhases.services.includes(cid)) {
        markersForAllPhases.services.push(cid);
        markersForCurrentPhase.services.push(cid);
      }
    }
  }

  // Turn `["1", {name: "2", condition: false}, {name: "3", condition: true}]`
  // into `new Set(["1", "3"])`.
  function filterConditions(l) {
    let set = new Set([]);
    for (let entry of l) {
      if (typeof entry == "object") {
        if ("condition" in entry && !entry.condition) {
          continue;
        }
        entry = entry.name;
      }
      set.add(entry);
    }
    return set;
  }

  for (let phaseName in backgroundtaskPhases) {
    for (let listName in backgroundtaskPhases[phaseName]) {
      for (let scriptType in backgroundtaskPhases[phaseName][listName]) {
        backgroundtaskPhases[phaseName][listName][scriptType] =
          filterConditions(
            backgroundtaskPhases[phaseName][listName][scriptType]
          );
      }

      // Turn human-readable contract IDs into CIDs.  It's worth noting that one
      // CID can (and sometimes does) correspond to more than one contract ID.
      let services = Array.from(
        backgroundtaskPhases[phaseName][listName].services || new Set([])
      );
      services = services
        .map(contractID => {
          try {
            return Cm.contractIDToCID(contractID).toString();
          } catch (e) {
            return null;
          }
        })
        .filter(cid => cid);
      services.sort();
      backgroundtaskPhases[phaseName][listName].services = new Set(services);
      info(
        `backgroundtaskPhases[${phaseName}][${listName}].services = ${JSON.stringify(
          services.map(c => c.toString())
        )}`
      );
    }
  }

  // Turn `{CID}` into `{CID} (@contractID)` or `{CID} (one of
  // @contractID1, ..., @contractIDn)` as appropriate.
  function renderResource(resource) {
    const UUID_PATTERN =
      /^\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}$/i;
    if (UUID_PATTERN.test(resource)) {
      let foundContractIDs = [];
      for (let contractID of Cm.getContractIDs()) {
        try {
          if (resource == Cm.contractIDToCID(contractID).toString()) {
            foundContractIDs.push(contractID);
          }
        } catch (e) {
          // This can throw for contract IDs that are filtered.  The common
          // reason is that they're limited to a particular process.
        }
      }
      if (!foundContractIDs.length) {
        return `${resource} (CID with no human-readable contract IDs)`;
      } else if (foundContractIDs.length == 1) {
        return `${resource} (CID with human-readable contract ID ${foundContractIDs[0]})`;
      }
      foundContractIDs.sort();
      return `${resource} (CID with human-readable contract IDs ${JSON.stringify(
        foundContractIDs
      )})`;
    }

    return resource;
  }

  for (let phase in backgroundtaskPhases) {
    let loadedList = phases[phase];
    let allowlist = backgroundtaskPhases[phase].allowlist || null;
    if (allowlist) {
      for (let scriptType in allowlist) {
        loadedList[scriptType] = loadedList[scriptType].filter(c => {
          if (!allowlist[scriptType].has(c)) {
            return true;
          }
          allowlist[scriptType].delete(c);
          return false;
        });
        Assert.deepEqual(
          loadedList[scriptType],
          [],
          `${phase}: should have no unexpected ${scriptType} loaded`
        );

        // Present errors in deterministic order.
        let unexpected = Array.from(loadedList[scriptType]);
        unexpected.sort();
        for (let script of unexpected) {
          // It would be nice to show stacks here, but that can be follow-up.
          ok(
            false,
            `${phase}: unexpected ${scriptType}: ${renderResource(script)}`
          );
        }
        Assert.deepEqual(
          allowlist[scriptType].size,
          0,
          `${phase}: all ${scriptType} allowlist entries should have been used`
        );
        let unused = Array.from(allowlist[scriptType]);
        unused.sort();
        for (let script of unused) {
          ok(
            false,
            `${phase}: unused ${scriptType} allowlist entry: ${renderResource(
              script
            )}`
          );
        }
      }
    }
    let denylist = backgroundtaskPhases[phase].denylist || null;
    if (denylist) {
      for (let scriptType in denylist) {
        let resources = denylist[scriptType];
        resources.sort();
        for (let resource of resources) {
          let loaded = loadedList[scriptType].includes(resource);
          let message = `${phase}: ${renderResource(resource)} is not allowed`;
          // It would be nice to show stacks here, but that can be follow-up.
          ok(!loaded, message);
        }
      }
    }
  }
});
