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

"use strict";

// Test the TargetCommand API around frames

const FISSION_TEST_URL = URL_ROOT_SSL + "fission_document.html";
const IFRAME_URL = URL_ROOT_ORG_SSL + "fission_iframe.html";
const SECOND_PAGE_URL = "https://example.org/document-builder.sjs?html=org";

const PID_REGEXP = /^\d+$/;

add_task(async function () {
  // Disable bfcache for Fission for now.
  // If Fission is disabled, the pref is no-op.
  await SpecialPowers.pushPrefEnv({
    set: [["fission.bfcacheInParent", false]],
  });

  // Enabled fission prefs
  await pushPref("devtools.browsertoolbox.scope", "everything");
  // We use a commands object for the main process
  await pushPref("devtools.chrome.enabled", true);
  // Disable the preloaded process as it gets created lazily and may interfere
  // with process count assertions
  await pushPref("dom.ipc.processPrelaunch.enabled", false);
  // This preference helps destroying the content process when we close the tab
  await pushPref("dom.ipc.keepProcessesAlive.web", 1);

  // Test fetching the frames from the main process descriptor
  await testBrowserFrames();

  // Test fetching the frames from a tab descriptor
  await testTabFrames();

  // Test what happens with documents running in the parent process
  await testOpeningOnParentProcessDocument();
  await testNavigationToParentProcessDocument();

  // Test what happens with about:blank documents
  await testOpeningOnAboutBlankDocument();
  await testNavigationToAboutBlankDocument();

  await testNestedIframes();
});

async function testOpeningOnParentProcessDocument() {
  info("Test opening against a parent process document");
  const tab = await addTab("about:robots");
  is(
    tab.linkedBrowser.browsingContext.currentWindowGlobal.osPid,
    -1,
    "The tab is loaded in the parent process"
  );

  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  await targetCommand.startListening();

  const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
  is(frames.length, 1);
  is(frames[0].url, "about:robots", "target url is correct");
  is(
    frames[0],
    targetCommand.targetFront,
    "the target is the current top level one"
  );

  await commands.destroy();
}

async function testNavigationToParentProcessDocument() {
  info("Test navigating to parent process document");
  const firstLocation = "data:text/html,foo";
  const secondLocation = "about:robots";

  const tab = await addTab(firstLocation);
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  // When the first top level target is created from the server,
  // `startListening` emits a spurious switched-target event
  // which isn't necessarily emited before it resolves.
  // So ensure waiting for it, otherwise we may resolve too eagerly
  // in our expected listener.
  const onSwitchedTarget1 = targetCommand.once("switched-target");
  await targetCommand.startListening();
  info("wait for first top level target");
  await onSwitchedTarget1;

  const firstTarget = targetCommand.targetFront;
  is(firstTarget.url, firstLocation, "first target url is correct");

  info("Navigate to a parent process page");
  const onSwitchedTarget = targetCommand.once("switched-target");
  const browser = tab.linkedBrowser;
  const onLoaded = BrowserTestUtils.browserLoaded(browser);
  BrowserTestUtils.startLoadingURIString(browser, secondLocation);
  await onLoaded;
  is(
    browser.browsingContext.currentWindowGlobal.osPid,
    -1,
    "The tab is loaded in the parent process"
  );

  await onSwitchedTarget;
  isnot(targetCommand.targetFront, firstTarget, "got a new target");

  // Check that calling getAllTargets([frame]) return the same target instances
  const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
  is(frames.length, 1);
  is(frames[0].url, secondLocation, "second target url is correct");
  is(
    frames[0],
    targetCommand.targetFront,
    "second target is the current top level one"
  );

  await commands.destroy();
}

async function testOpeningOnAboutBlankDocument() {
  info("Test opening against about:blank document");
  const tab = await addTab("about:blank");

  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  await targetCommand.startListening();

  const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
  is(frames.length, 1);
  is(frames[0].url, "about:blank", "target url is correct");
  is(
    frames[0],
    targetCommand.targetFront,
    "the target is the current top level one"
  );

  await commands.destroy();
}

async function testNavigationToAboutBlankDocument() {
  info("Test navigating to about:blank");
  const firstLocation = "data:text/html,foo";
  const secondLocation = "about:blank";

  const tab = await addTab(firstLocation);
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  // When the first top level target is created from the server,
  // `startListening` emits a spurious switched-target event
  // which isn't necessarily emited before it resolves.
  // So ensure waiting for it, otherwise we may resolve too eagerly
  // in our expected listener.
  const onSwitchedTarget1 = targetCommand.once("switched-target");
  await targetCommand.startListening();
  info("wait for first top level target");
  await onSwitchedTarget1;

  const firstTarget = targetCommand.targetFront;
  is(firstTarget.url, firstLocation, "first target url is correct");

  info("Navigate to about:blank page");
  const onSwitchedTarget = targetCommand.once("switched-target");
  const browser = tab.linkedBrowser;
  const onLoaded = BrowserTestUtils.browserLoaded(browser, {
    wantLoad: secondLocation,
  });
  BrowserTestUtils.startLoadingURIString(browser, secondLocation);
  await onLoaded;

  await onSwitchedTarget;
  isnot(targetCommand.targetFront, firstTarget, "got a new target");

  // Check that calling getAllTargets([frame]) return the same target instances
  const frames = await targetCommand.getAllTargets([targetCommand.TYPES.FRAME]);
  is(frames.length, 1);
  is(frames[0].url, secondLocation, "second target url is correct");
  is(
    frames[0],
    targetCommand.targetFront,
    "second target is the current top level one"
  );

  await commands.destroy();
}

async function testBrowserFrames() {
  info("Test TargetCommand against frames via the parent process target");

  const aboutBlankTab = await addTab("about:blank");

  const commands = await CommandsFactory.forMainProcess();
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;
  await targetCommand.startListening();

  async function getAllFrameTargets() {
    const targets = await targetCommand.getAllTargets([TYPES.FRAME]);

    // Some extensions may be running and lead to the creation
    // of some unexpected addon targets.
    return targets.filter(t => !t.addonId);
  }

  // Very naive sanity check against getAllTargets([frame])
  const frames = await getAllFrameTargets();

  const hasBrowserDocument = frames.find(
    frameTarget => frameTarget.url == window.location.href
  );
  ok(hasBrowserDocument, "retrieve the target for the browser document");

  const hasAboutBlankDocument = frames.find(
    frameTarget =>
      frameTarget.browsingContextID ==
      aboutBlankTab.linkedBrowser.browsingContext.id
  );
  ok(hasAboutBlankDocument, "retrieve the target for the about:blank tab");

  // Check that calling getAllTargets([frame]) return the same target instances
  const frames2 = await getAllFrameTargets();

  is(frames2.length, frames.length, "retrieved the same number of frames");

  function sortFronts(f1, f2) {
    return f1.actorID < f2.actorID;
  }
  frames.sort(sortFronts);
  frames2.sort(sortFronts);
  for (let i = 0; i < frames.length; i++) {
    is(frames[i], frames2[i], `frame ${i} targets are the same`);
  }

  // Assert that watchTargets will call the create callback for all existing frames
  const targets = [];
  const topLevelTarget = targetCommand.targetFront;

  const noParentTarget = await topLevelTarget.getParentTarget();
  is(noParentTarget, null, "The top level target has no parent target");

  const onAvailable = ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    ok(
      targetFront == topLevelTarget
        ? targetFront.isTopLevel
        : !targetFront.isTopLevel,
      "isTopLevel property is correct"
    );
    ok(
      PID_REGEXP.test(targetFront.processID),
      `Target has processID of expected shape (${targetFront.processID})`
    );
    if (!targetFront.addonId) {
      targets.push(targetFront);
    }
  };
  await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable });

  is(
    targets.length,
    frames.length,
    "retrieved the same number of frames via watchTargets"
  );

  frames.sort(sortFronts);
  targets.sort(sortFronts);
  for (let i = 0; i < frames.length; i++) {
    is(
      frames[i],
      targets[i],
      `frame ${i} targets are the same via watchTargets`
    );
  }

  async function addTabAndAssertNewTarget(url) {
    const previousTargetCount = targets.length;
    const tab = await addTab(url);
    await waitFor(
      () => targets.length == previousTargetCount + 1,
      "Wait for all expected targets after tab opening"
    );
    is(
      targets.length,
      previousTargetCount + 1,
      "Opening a tab reported a new frame"
    );
    const newTabTarget = targets.at(-1);
    is(newTabTarget.url, url, "This frame target is about the new tab");
    // Internaly, the tab, which uses a <browser type='content'> element is considered detached from their owner document
    // and so the target is having a null parentInnerWindowId. But the framework will attach all non-top-level targets
    // as children of the top level.
    const tabParentTarget = await newTabTarget.getParentTarget();
    is(
      tabParentTarget,
      targetCommand.targetFront,
      "tab's WindowGlobal/BrowsingContext is detached and has no parent, but we report them as children of the top level target"
    );

    const frames3 = await getAllFrameTargets();
    const hasTabDocument = frames3.find(target => target.url == url);
    ok(hasTabDocument, "retrieve the target for tab via getAllTargets");

    return tab;
  }

  info("Open a tab loaded in content process");
  await addTabAndAssertNewTarget("data:text/html,content-process-page");

  info("Open a tab loaded in the parent process");
  const parentProcessTab = await addTabAndAssertNewTarget("about:robots");
  is(
    parentProcessTab.linkedBrowser.browsingContext.currentWindowGlobal.osPid,
    -1,
    "The tab is loaded in the parent process"
  );

  info("Open a new content window via window.open");
  info("First open a tab on .org domain");
  const tabUrl = "https://example.org/document-builder.sjs?html=org";
  await addTabAndAssertNewTarget(tabUrl);
  const previousTargetCount = targets.length;

  info("Then open a popup on .com domain");
  const popupUrl = "https://example.com/document-builder.sjs?html=com";
  const onPopupOpened = BrowserTestUtils.waitForNewTab(gBrowser, popupUrl);
  await SpecialPowers.spawn(gBrowser.selectedBrowser, [popupUrl], async url => {
    content.window.open(url, "_blank");
  });
  await onPopupOpened;

  await waitFor(
    () => targets.length == previousTargetCount + 1,
    "Wait for all expected targets after window.open()"
  );
  is(
    targets.length,
    previousTargetCount + 1,
    "Opening a new content window reported a new frame"
  );
  is(
    targets.at(-1).url,
    popupUrl,
    "This frame target is about the new content window"
  );

  // About:blank are a bit special because we ignore a transcient about:blank
  // document when navigating to another process. But we should not ignore
  // tabs, loading a real, final about:blank document.
  info("Open a tab with about:blank");
  await addTabAndAssertNewTarget("about:blank");

  // Until we start spawning target for all WindowGlobals,
  // including the one running in the same process as their parent,
  // we won't create dedicated target for new top level windows.
  // Instead, these document will be debugged via the ParentProcessTargetActor.
  info("Open a top level chrome window");
  const expectedTargets = targets.length;
  const chromeWindow = Services.ww.openWindow(
    null,
    "about:robots",
    "_blank",
    "chrome",
    null
  );
  await wait(250);
  is(
    targets.length,
    expectedTargets,
    "New top level window shouldn't spawn new target"
  );
  chromeWindow.close();

  targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });

  targetCommand.destroy();
  await waitForAllTargetsToBeAttached(targetCommand);

  await commands.destroy();
}

async function testTabFrames() {
  info("Test TargetCommand against frames via a tab target");

  // Create a TargetCommand for a given test tab
  const tab = await addTab(FISSION_TEST_URL);
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;

  await targetCommand.startListening();

  // Check that calling getAllTargets([frame]) return the same target instances
  const frames = await targetCommand.getAllTargets([TYPES.FRAME]);
  // When fission is enabled, we also get the remote example.org iframe.
  const expectedFramesCount = 2;
  is(
    frames.length,
    expectedFramesCount,
    "retrieved the expected number of targets"
  );

  // Assert that watchTargets will call the create callback for all existing frames
  const targets = [];
  const destroyedTargets = [];
  const topLevelTarget = targetCommand.targetFront;
  const onAvailable = ({ targetFront, isTargetSwitching }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    ok(
      PID_REGEXP.test(targetFront.processID),
      `Target has processID of expected shape (${targetFront.processID})`
    );
    targets.push({ targetFront, isTargetSwitching });
  };
  const onDestroyed = ({ targetFront, isTargetSwitching }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    ok(
      targetFront == topLevelTarget
        ? targetFront.isTopLevel
        : !targetFront.isTopLevel,
      "isTopLevel property is correct"
    );
    destroyedTargets.push({ targetFront, isTargetSwitching });
  };
  await targetCommand.watchTargets({
    types: [TYPES.FRAME],
    onAvailable,
    onDestroyed,
  });
  is(
    targets.length,
    frames.length,
    "retrieved the same number of frames via watchTargets"
  );
  is(destroyedTargets.length, 0, "Should be no destroyed target initialy");

  for (const frame of frames) {
    ok(
      targets.find(({ targetFront }) => targetFront === frame),
      "frame " + frame.actorID + " target is the same via watchTargets"
    );
  }
  is(
    targets[0].targetFront.url,
    FISSION_TEST_URL,
    "First target should be the top document one"
  );
  is(
    targets[0].targetFront.isTopLevel,
    true,
    "First target is a top level one"
  );
  is(
    !targets[0].isTargetSwitching,
    true,
    "First target is not considered as a target switching"
  );
  const noParentTarget = await targets[0].targetFront.getParentTarget();
  is(noParentTarget, null, "The top level target has no parent target");

  is(
    targets[1].targetFront.url,
    IFRAME_URL,
    "Second target should be the iframe one"
  );
  is(!targets[1].targetFront.isTopLevel, true, "Iframe target isn't top level");
  is(!targets[1].isTargetSwitching, true, "Iframe target isn't a target swich");
  const parentTarget = await targets[1].targetFront.getParentTarget();
  is(
    parentTarget,
    targets[0].targetFront,
    "The parent target for the iframe is the top level target"
  );

  // Before navigating to another process, ensure cleaning up everything from the first page
  await waitForAllTargetsToBeAttached(targetCommand);
  await SpecialPowers.spawn(tab.linkedBrowser, [], async () => {
    // registrationPromise is set by the test page.
    const registration = await content.wrappedJSObject.registrationPromise;
    registration.unregister();
  });

  info("Navigate to another domain and process (if fission is enabled)");
  // When a new target will be created, we need to wait until it's fully processed
  // to avoid pending promises.
  const onNewTargetProcessed = targetCommand.once("processed-available-target");

  const browser = tab.linkedBrowser;
  const onLoaded = BrowserTestUtils.browserLoaded(browser);
  BrowserTestUtils.startLoadingURIString(browser, SECOND_PAGE_URL);
  await onLoaded;

  const afterNavigationFramesCount = 3;
  await waitFor(
    () => targets.length == afterNavigationFramesCount,
    "Wait for all expected targets after navigation"
  );
  is(
    targets.length,
    afterNavigationFramesCount,
    "retrieved all targets after navigation"
  );
  // As targetFront.url isn't reliable and might be about:blank,
  // try to assert that we got the right target via other means.
  // outerWindowID should change when navigating to another process,
  // while it would stay equal for in-process navigations.
  is(
    targets[2].targetFront.outerWindowID,
    browser.outerWindowID,
    "The new target should be the newly loaded document"
  );
  is(
    targets[2].isTargetSwitching,
    true,
    "and should be flagged as a target switching"
  );

  is(
    destroyedTargets.length,
    2,
    "The two existing targets should be destroyed"
  );
  const iframeDestroyedTarget = destroyedTargets.find(
    target => target.targetFront === targets[1].targetFront
  );
  ok(
    iframeDestroyedTarget,
    "Received the destroyed target notification for the iframe"
  );
  is(
    iframeDestroyedTarget.isTargetSwitching,
    false,
    "the target destruction is not flagged as target switching for iframes"
  );
  const topLevelDestroyedTarget = destroyedTargets.find(
    target => target.targetFront === targets[0].targetFront
  );
  ok(
    topLevelDestroyedTarget,
    "Received the destroyed target notification for the top level frame"
  );
  is(
    topLevelDestroyedTarget.isTargetSwitching,
    true,
    "the target destruction is flagged as target switching"
  );

  await onNewTargetProcessed;

  targetCommand.unwatchTargets({ types: [TYPES.FRAME], onAvailable });

  targetCommand.destroy();

  BrowserTestUtils.removeTab(tab);

  await commands.destroy();
}

async function testNestedIframes() {
  info("Test TargetCommand against nested frames");

  const nestedIframeUrl = `https://example.com/document-builder.sjs?html=${encodeURIComponent(
    "<title>second</title><h3>second level iframe</h3>"
  )}&delay=500`;

  // addTab will wait for this specific url so need to use URL to serialize correctly
  const testUrl = new URL(`data:text/html;charset=utf-8,
    <h1>Top-level</h1>
    <iframe id=first-level
      src='data:text/html;charset=utf-8,${encodeURIComponent(
        `<title>first</title><h2>first level iframe</h2><iframe id=second-level src="${nestedIframeUrl}"></iframe>`
      )}'
    ></iframe>`).href;

  // Create a TargetCommand for a given test tab
  const tab = await addTab(testUrl);
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;

  await targetCommand.startListening();

  // Check that calling getAllTargets([frame]) return the same target instances
  const frames = await targetCommand.getAllTargets([TYPES.FRAME]);

  is(frames[0], targetCommand.targetFront, "First target is the top level one");
  const topParent = await frames[0].getParentTarget();
  is(topParent, null, "Top level target has no parent");

  const firstIframeTarget = frames.find(target => target.title == "first");
  ok(firstIframeTarget, "Got the target for the first level iframe");
  const firstParent = await firstIframeTarget.getParentTarget();
  is(
    firstParent,
    targetCommand.targetFront,
    "First level has top level target as parent"
  );

  const secondIframeTarget = frames.find(target => target.title == "second");
  ok(secondIframeTarget, "Got the target for the second level iframe");
  const secondParent = await secondIframeTarget.getParentTarget();
  is(
    secondParent,
    firstIframeTarget,
    "Second level has the first level target as parent"
  );

  await commands.destroy();
}
