"use strict";

const BASE_URI = "http://mochi.test:8888/browser/dom/serviceworkers/test/";
const emptyDoc = BASE_URI + "empty.html";
const fakeDoc = BASE_URI + "fake.html";
const helloDoc = BASE_URI + "hello.html";

const CROSS_URI = "http://example.com/browser/dom/serviceworkers/test/";
const crossRedirect = CROSS_URI + "redirect";
const crossHelloDoc = CROSS_URI + "hello.html";

const sw = BASE_URI + "fetch.js";

const gIsParentInterceptEnabled = Cc["@mozilla.org/serviceworkers/manager;1"]
  .getService(Ci.nsIServiceWorkerManager)
  .isParentInterceptEnabled();

async function checkObserver({ aInput, isParentInterceptEnabled }) {
  let interceptedChannel = null;

  // We always get two channels which receive the "http-on-stop-request"
  // notification if the service worker hijacks the request and respondWith an
  // another fetch. One is for the "outer" window request when the other one is
  // for the "inner" service worker request. Therefore, distinguish them by the
  // order.
  let waitForSecondOnStopRequest = aInput.intercepted;

  let promiseResolve;

  function observer(aSubject) {
    let channel = aSubject.QueryInterface(Ci.nsIChannel);
    // Since we cannot make sure that the network event triggered by the fetch()
    // in this testcase is the very next event processed by ObserverService, we
    // have to wait until we catch the one we want.
    if (!channel.URI.spec.includes(aInput.expectedURL)) {
      return;
    }

    if (waitForSecondOnStopRequest) {
      waitForSecondOnStopRequest = false;
      return;
    }

    // Wait for the service worker to intercept the request if it's expected to
    // be intercepted
    if (aInput.intercepted && interceptedChannel === null) {
      return;
    } else if (interceptedChannel) {
      ok(
        aInput.intercepted,
        "Service worker intercepted the channel as expected"
      );
    } else {
      ok(!aInput.intercepted, "The channel doesn't be intercepted");
    }

    var tc = interceptedChannel
      ? interceptedChannel.QueryInterface(Ci.nsITimedChannel)
      : aSubject.QueryInterface(Ci.nsITimedChannel);

    // Check service worker related timings.
    var serviceWorkerTimings = [
      {
        start: tc.launchServiceWorkerStartTime,
        end: tc.launchServiceWorkerEndTime,
      },
      {
        start: tc.dispatchFetchEventStartTime,
        end: tc.dispatchFetchEventEndTime,
      },
      { start: tc.handleFetchEventStartTime, end: tc.handleFetchEventEndTime },
    ];
    if (aInput.swPresent) {
      // TODO: remove this condition (but keep the if statement's body) when
      // bug 1577829 is resolved.
      if (!isParentInterceptEnabled) {
        serviceWorkerTimings.reduce((aPreviousTimings, aCurrentTimings) => {
          ok(aPreviousTimings.start !== 0, "Start time check.");
          ok(
            aPreviousTimings.start <= aCurrentTimings.start,
            "Start time order check."
          );
          ok(
            aPreviousTimings.end <= aCurrentTimings.end,
            "End time order check."
          );
          ok(
            aCurrentTimings.start <= aCurrentTimings.end,
            "Start time should be smaller than end time."
          );
          return aCurrentTimings;
        });
      }
    } else {
      serviceWorkerTimings.forEach(aTimings => {
        is(aTimings.start, 0, "SW timings should be 0.");
        is(aTimings.end, 0, "SW timings should be 0.");
      });
    }

    // Check network related timings.
    var networkTimings = [
      tc.domainLookupStartTime,
      tc.domainLookupEndTime,
      tc.connectStartTime,
      tc.connectEndTime,
      tc.requestStartTime,
      tc.responseStartTime,
      tc.responseEndTime,
    ];
    if (aInput.fetch) {
      networkTimings.reduce((aPreviousTiming, aCurrentTiming) => {
        ok(aPreviousTiming <= aCurrentTiming, "Checking network timings");
        return aCurrentTiming;
      });
    } else {
      networkTimings.forEach(aTiming =>
        is(aTiming, 0, "Network timings should be 0.")
      );
    }

    interceptedChannel = null;
    Services.obs.removeObserver(observer, topic);
    promiseResolve();
  }

  function addInterceptedChannel(aSubject) {
    let channel = aSubject.QueryInterface(Ci.nsIChannel);
    if (!channel.URI.spec.includes(aInput.url)) {
      return;
    }

    // Hold the interceptedChannel until checking timing information.
    // Note: It's a interceptedChannel in the type of httpChannel
    interceptedChannel = channel;
    Services.obs.removeObserver(addInterceptedChannel, topic_SW);
  }

  const topic = "http-on-stop-request";
  const topic_SW = "service-worker-synthesized-response";

  Services.obs.addObserver(observer, topic);
  if (aInput.intercepted) {
    Services.obs.addObserver(addInterceptedChannel, topic_SW);
  }

  await new Promise(resolve => {
    promiseResolve = resolve;
  });
}

async function contentFetch(aURL) {
  if (aURL.includes("redirect")) {
    await content.window.fetch(aURL, { mode: "no-cors" });
    return;
  }
  await content.window.fetch(aURL);
}

// The observer topics are fired in the parent process in parent-intercept
// and the content process in child-intercept. This function will handle running
// the check in the correct process. Note that it will block until the observers
// are notified.
async function fetchAndCheckObservers(
  aFetchBrowser,
  aObserverBrowser,
  aTestCase
) {
  let promise = null;

  let checkArgs = {
    aInput: aTestCase,
    isParentInterceptEnabled: gIsParentInterceptEnabled,
  };
  if (gIsParentInterceptEnabled) {
    promise = checkObserver(checkArgs);
  } else {
    promise = SpecialPowers.spawn(aObserverBrowser, [checkArgs], checkObserver);
  }

  await SpecialPowers.spawn(aFetchBrowser, [aTestCase.url], contentFetch);
  await promise;
}

async function registerSWAndWaitForActive(aServiceWorker) {
  let swr = await content.navigator.serviceWorker.register(aServiceWorker, {
    scope: "empty.html",
  });
  await new Promise(resolve => {
    let worker = swr.installing || swr.waiting || swr.active;
    if (worker.state === "activated") {
      return resolve();
    }

    worker.addEventListener("statechange", () => {
      if (worker.state === "activated") {
        return resolve();
      }
    });
  });

  await new Promise(resolve => {
    if (content.navigator.serviceWorker.controller) {
      return resolve();
    }

    content.navigator.serviceWorker.addEventListener(
      "controllerchange",
      resolve,
      { once: true }
    );
  });
}

async function unregisterSW() {
  let swr = await content.navigator.serviceWorker.getRegistration();
  swr.unregister();
}

add_task(async function test_serivce_worker_interception() {
  info("Setting the prefs to having e10s enabled");
  await SpecialPowers.pushPrefEnv({
    set: [
      // Make sure observer and testing function run in the same process
      ["dom.ipc.processCount", 1],
      ["dom.serviceWorkers.enabled", true],
      ["dom.serviceWorkers.testing.enabled", true],
    ],
  });

  waitForExplicitFinish();

  info("Open the tab");
  let tab = BrowserTestUtils.addTab(gBrowser, emptyDoc);
  let tabBrowser = gBrowser.getBrowserForTab(tab);
  await BrowserTestUtils.browserLoaded(tabBrowser);

  info("Open the tab for observing");
  let tab_observer = BrowserTestUtils.addTab(gBrowser, emptyDoc);
  let tabBrowser_observer = gBrowser.getBrowserForTab(tab_observer);
  await BrowserTestUtils.browserLoaded(tabBrowser_observer);

  let testcases = [
    {
      url: helloDoc,
      expectedURL: helloDoc,
      swPresent: false,
      intercepted: false,
      fetch: true,
    },
    {
      url: fakeDoc,
      expectedURL: helloDoc,
      swPresent: true,
      intercepted: true,
      fetch: false, // should use HTTP cache
    },
    {
      // Bypass http cache
      url: helloDoc + "?ForBypassingHttpCache=" + Date.now(),
      expectedURL: helloDoc,
      swPresent: true,
      intercepted: false,
      fetch: true,
    },
    {
      // no-cors mode redirect to no-cors mode (trigger internal redirect)
      url: crossRedirect + "?url=" + crossHelloDoc + "&mode=no-cors",
      expectedURL: crossHelloDoc,
      swPresent: true,
      redirect: "hello.html",
      intercepted: true,
      fetch: true,
    },
  ];

  info("Test 1: Verify simple fetch");
  await fetchAndCheckObservers(tabBrowser, tabBrowser_observer, testcases[0]);

  info("Register a service worker");
  await SpecialPowers.spawn(tabBrowser, [sw], registerSWAndWaitForActive);

  info("Test 2: Verify simple hijack");
  await fetchAndCheckObservers(tabBrowser, tabBrowser_observer, testcases[1]);

  info("Test 3: Verify fetch without using http cache");
  await fetchAndCheckObservers(tabBrowser, tabBrowser_observer, testcases[2]);

  info("Test 4: make a internal redirect");
  await fetchAndCheckObservers(tabBrowser, tabBrowser_observer, testcases[3]);

  info("Clean up");
  await SpecialPowers.spawn(tabBrowser, [undefined], unregisterSW);

  gBrowser.removeTab(tab);
  gBrowser.removeTab(tab_observer);
});
