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

"use strict";

// Test the TargetCommand API when bfcache navigations happen

const TEST_COM_URL = URL_ROOT_SSL + "simple_document.html";

add_task(async function () {
  // 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);

  info("## Test with bfcache in parent DISABLED");
  await pushPref("fission.bfcacheInParent", false);
  await testTopLevelNavigations(false);
  await testIframeNavigations(false);
  await testTopLevelNavigationsOnDocumentWithIframe(false);

  // bfcacheInParent only works if sessionHistoryInParent is enable
  // so only test it if both settings are enabled.
  // (it looks like sessionHistoryInParent is enabled by default when fission is enabled)
  if (Services.appinfo.sessionHistoryInParent) {
    info("## Test with bfcache in parent ENABLED");
    await pushPref("fission.bfcacheInParent", true);
    await testTopLevelNavigations(true);
    await testIframeNavigations(true);
    await testTopLevelNavigationsOnDocumentWithIframe(true);
  }
});

async function testTopLevelNavigations(bfcacheInParent) {
  info(" # Test TOP LEVEL navigations");
  // Create a TargetCommand for a given test tab
  const tab = await addTab(TEST_COM_URL);
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;

  await targetCommand.startListening();

  // Assert that watchTargets will call the create callback for all existing frames
  const targets = [];
  const onAvailable = async ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    ok(targetFront.isTopLevel, "all targets of this test are top level");
    targets.push(targetFront);
  };
  const destroyedTargets = [];
  const onDestroyed = async ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    ok(targetFront.isTopLevel, "all targets of this test are top level");
    destroyedTargets.push(targetFront);
  };

  await targetCommand.watchTargets({
    types: [TYPES.FRAME],
    onAvailable,
    onDestroyed,
  });
  is(targets.length, 1, "retrieved only the top level target");
  is(targets[0], targetCommand.targetFront, "the target is the top level one");
  is(
    destroyedTargets.length,
    0,
    "We get no destruction when calling watchTargets"
  );
  ok(
    targets[0].targetForm.followWindowGlobalLifeCycle,
    "the first server side target follows the WindowGlobal lifecycle, when server target switching is enabled"
  );

  // Navigate to the same page with query params
  info("Load the second page");
  const secondPageUrl = TEST_COM_URL + "?second-load";
  const previousBrowsingContextID = gBrowser.selectedBrowser.browsingContext.id;
  ok(
    previousBrowsingContextID,
    "Fetch the tab's browsing context id before navigation"
  );
  const onLoaded = BrowserTestUtils.browserLoaded(
    gBrowser.selectedBrowser,
    false,
    secondPageUrl
  );
  BrowserTestUtils.startLoadingURIString(
    gBrowser.selectedBrowser,
    secondPageUrl
  );
  await onLoaded;

  // Assert BrowsingContext changes as it impact the behavior of targets
  if (bfcacheInParent) {
    isnot(
      previousBrowsingContextID,
      gBrowser.selectedBrowser.browsingContext.id,
      "When bfcacheInParent is enabled, same-origin navigations spawn new BrowsingContext"
    );
  } else {
    is(
      previousBrowsingContextID,
      gBrowser.selectedBrowser.browsingContext.id,
      "When bfcacheInParent is disabled, same-origin navigations re-use the same BrowsingContext"
    );
  }

  // Same-origin navigations also spawn a new top level target
  await waitFor(
    () => targets.length == 2,
    "wait for the next top level target"
  );
  is(
    targets[1],
    targetCommand.targetFront,
    "the second target is the top level one"
  );
  // 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[1].outerWindowID,
    gBrowser.selectedBrowser.outerWindowID,
    "the second target is for the second page"
  );
  ok(
    targets[1].targetForm.followWindowGlobalLifeCycle,
    "the new server side target follows the WindowGlobal lifecycle"
  );
  ok(targets[0].isDestroyed(), "the first target is destroyed");
  is(destroyedTargets.length, 1, "We get one target being destroyed...");
  is(destroyedTargets[0], targets[0], "...and that's the first one");

  // Go back to the first page, this should be a bfcache navigation, and,
  // we should get a new target
  info("Go back to the first page");
  const onPageShow = BrowserTestUtils.waitForContentEvent(
    gBrowser.selectedBrowser,
    "pageshow"
  );
  gBrowser.selectedBrowser.goBack();
  await onPageShow;

  await waitFor(
    () => targets.length == 3,
    "wait for the next top level target"
  );
  is(
    targets[2],
    targetCommand.targetFront,
    "the third target is the top level one"
  );
  // Here as this is revived from cache, the url should always be correct
  is(targets[2].url, TEST_COM_URL, "the third target is for the first url");
  ok(
    targets[2].targetForm.followWindowGlobalLifeCycle,
    "the third target for bfcache navigations is following the WindowGlobal lifecycle"
  );
  ok(targets[1].isDestroyed(), "the second target is destroyed");
  is(
    destroyedTargets.length,
    2,
    "We get one additional target being destroyed..."
  );
  is(destroyedTargets[1], targets[1], "...and that's the second one");

  // Wait for full attach in order to having breaking any pending requests
  // when navigating to another page and switching to new process and target.
  await waitForAllTargetsToBeAttached(targetCommand);

  // Go forward and resurect the second page, this should also be a bfcache navigation, and,
  // get a new target.
  info("Go forward to the second page");

  // When a new target will be created, we need to wait until it's fully processed
  // to avoid pending promises.
  const onNewTargetProcessed = bfcacheInParent
    ? new Promise(resolve => {
        targetCommand.on(
          "processed-available-target",
          function onProcessedAvailableTarget(targetFront) {
            if (targetFront === targets[3]) {
              resolve();
              targetCommand.off(
                "processed-available-target",
                onProcessedAvailableTarget
              );
            }
          }
        );
      })
    : null;

  gBrowser.selectedBrowser.goForward();

  await waitFor(
    () => targets.length == 4,
    "wait for the next top level target"
  );
  is(
    targets[3],
    targetCommand.targetFront,
    "the 4th target is the top level one"
  );
  // Same here, as the document is revived from the cache, the url should always be correct
  is(targets[3].url, secondPageUrl, "the 4th target is for the second url");
  ok(
    targets[3].targetForm.followWindowGlobalLifeCycle,
    "the 4th target for bfcache navigations is following the WindowGlobal lifecycle"
  );
  ok(targets[2].isDestroyed(), "the third target is destroyed");
  is(
    destroyedTargets.length,
    3,
    "We get one additional target being destroyed..."
  );
  is(destroyedTargets[2], targets[2], "...and that's the third one");

  // Wait for full attach in order to having breaking any pending requests
  // when navigating to another page and switching to new process and target.
  await waitForAllTargetsToBeAttached(targetCommand);
  await onNewTargetProcessed;

  await waitForAllTargetsToBeAttached(targetCommand);

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

  BrowserTestUtils.removeTab(tab);

  await commands.destroy();
}

async function testTopLevelNavigationsOnDocumentWithIframe() {
  info(" # Test TOP LEVEL navigations on document with iframe");
  // Create a TargetCommand for a given test tab
  const tab =
    await addTab(`https://example.com/document-builder.sjs?id=top&html=
    <h1>Top level</h1>
    <iframe src="${encodeURIComponent(
      "https://example.com/document-builder.sjs?id=iframe&html=<h2>In iframe</h2>"
    )}">
    </iframe>`);
  const getLocationIdParam = url =>
    new URLSearchParams(new URL(url).search).get("id");

  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;

  await targetCommand.startListening();

  // Assert that watchTargets will call the create callback for all existing frames
  const targets = [];
  const onAvailable = async ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    targets.push(targetFront);
  };
  const destroyedTargets = [];
  const onDestroyed = async ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    destroyedTargets.push(targetFront);
  };

  await targetCommand.watchTargets({
    types: [TYPES.FRAME],
    onAvailable,
    onDestroyed,
  });

  if (isEveryFrameTargetEnabled()) {
    is(
      targets.length,
      2,
      "retrieved targets for top level and iframe documents"
    );
    is(
      targets[0],
      targetCommand.targetFront,
      "the target is the top level one"
    );
    is(
      getLocationIdParam(targets[1].url),
      "iframe",
      "the second target is the iframe one"
    );
  } else {
    is(targets.length, 1, "retrieved only the top level target");
    is(
      targets[0],
      targetCommand.targetFront,
      "the target is the top level one"
    );
  }

  is(
    destroyedTargets.length,
    0,
    "We get no destruction when calling watchTargets"
  );

  info("Navigate to a new page");
  let targetCountBeforeNavigation = targets.length;
  const secondPageUrl = `https://example.com/document-builder.sjs?html=second`;
  const onLoaded = BrowserTestUtils.browserLoaded(
    gBrowser.selectedBrowser,
    false,
    secondPageUrl
  );
  BrowserTestUtils.startLoadingURIString(
    gBrowser.selectedBrowser,
    secondPageUrl
  );
  await onLoaded;

  // Same-origin navigations also spawn a new top level target
  await waitFor(
    () => targets.length == targetCountBeforeNavigation + 1,
    "wait for the next top level target"
  );
  is(
    targets.at(-1),
    targetCommand.targetFront,
    "the new target is the top level one"
  );

  ok(targets[0].isDestroyed(), "the first target is destroyed");
  if (isEveryFrameTargetEnabled()) {
    ok(targets[1].isDestroyed(), "the second target is destroyed");
    is(destroyedTargets.length, 2, "The two targets were destroyed");
  } else {
    is(destroyedTargets.length, 1, "Only one target was destroyed");
  }

  // Go back to the first page, this should be a bfcache navigation, and,
  // we should get a new target (or 2 if EFT is enabled)
  targetCountBeforeNavigation = targets.length;
  info("Go back to the first page");
  gBrowser.selectedBrowser.goBack();

  await waitFor(
    () =>
      targets.length ===
      targetCountBeforeNavigation + (isEveryFrameTargetEnabled() ? 2 : 1),
    "wait for the next top level target"
  );

  if (isEveryFrameTargetEnabled()) {
    await waitFor(() => targets.at(-2).url && targets.at(-1).url);
    is(
      getLocationIdParam(targets.at(-2).url),
      "top",
      "the first new target is for the top document…"
    );
    is(
      getLocationIdParam(targets.at(-1).url),
      "iframe",
      "…and the second one is for the iframe"
    );
  } else {
    is(
      getLocationIdParam(targets.at(-1).url),
      "top",
      "the new target is for the first url"
    );
  }

  ok(
    targets[targetCountBeforeNavigation - 1].isDestroyed(),
    "the target for the second page is destroyed"
  );
  is(
    destroyedTargets.length,
    targetCountBeforeNavigation,
    "We get one additional target being destroyed…"
  );
  is(
    destroyedTargets.at(-1),
    targets[targetCountBeforeNavigation - 1],
    "…and that's the second page one"
  );

  await waitForAllTargetsToBeAttached(targetCommand);

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

  BrowserTestUtils.removeTab(tab);

  await commands.destroy();
}

async function testIframeNavigations() {
  info(" # Test IFRAME navigations");
  // Create a TargetCommand for a given test tab
  const tab = await addTab(
    `http://example.org/document-builder.sjs?html=<iframe src="${TEST_COM_URL}"></iframe>`
  );
  const commands = await CommandsFactory.forTab(tab);
  const targetCommand = commands.targetCommand;
  const { TYPES } = targetCommand;

  await targetCommand.startListening();

  // Assert that watchTargets will call the create callback for all existing frames
  const targets = [];
  const onAvailable = async ({ targetFront }) => {
    is(
      targetFront.targetType,
      TYPES.FRAME,
      "We are only notified about frame targets"
    );
    targets.push(targetFront);
  };
  await targetCommand.watchTargets({ types: [TYPES.FRAME], onAvailable });

  // When fission/EFT is off, there isn't much to test for iframes as they are debugged
  // when the unique top level target
  if (!isFissionEnabled() && !isEveryFrameTargetEnabled()) {
    is(
      targets.length,
      1,
      "Without fission/EFT, there is only the top level target"
    );
    await commands.destroy();
    return;
  }
  is(targets.length, 2, "retrieved the top level and the iframe targets");
  is(
    targets[0],
    targetCommand.targetFront,
    "the first target is the top level one"
  );
  is(targets[1].url, TEST_COM_URL, "the second target is the iframe one");

  // Navigate to the same page with query params
  info("Load the second page");
  const secondPageUrl = TEST_COM_URL + "?second-load";
  await SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [secondPageUrl],
    function (url) {
      const iframe = content.document.querySelector("iframe");
      iframe.src = url;
    }
  );

  await waitFor(() => targets.length == 3, "wait for the next target");
  is(targets[2].url, secondPageUrl, "the second target is for the second url");
  ok(targets[1].isDestroyed(), "the first target is destroyed");

  // Go back to the first page, this should be a bfcache navigation, and,
  // we should get a new target
  info("Go back to the first page");
  const iframeBrowsingContext = await SpecialPowers.spawn(
    gBrowser.selectedBrowser,
    [],
    function () {
      const iframe = content.document.querySelector("iframe");
      return iframe.browsingContext;
    }
  );
  await SpecialPowers.spawn(iframeBrowsingContext, [], function () {
    content.history.back();
  });

  await waitFor(() => targets.length == 4, "wait for the next target");
  is(targets[3].url, TEST_COM_URL, "the third target is for the first url");
  ok(targets[2].isDestroyed(), "the second target is destroyed");

  // Go forward and resurect the second page, this should also be a bfcache navigation, and,
  // get a new target.
  info("Go forward to the second page");
  await SpecialPowers.spawn(iframeBrowsingContext, [], function () {
    content.history.forward();
  });

  await waitFor(() => targets.length == 5, "wait for the next target");
  is(targets[4].url, secondPageUrl, "the 4th target is for the second url");
  ok(targets[3].isDestroyed(), "the third target is destroyed");

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

  await waitForAllTargetsToBeAttached(targetCommand);

  BrowserTestUtils.removeTab(tab);

  await commands.destroy();
}
