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

"use strict";

/**
 * This test ensures that the about:home startup cache worker
 * script can correctly convert a state object from the Activity
 * Stream Redux store into an HTML document and script.
 */

const { AddonTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/AddonTestUtils.sys.mjs"
);
const { SearchTestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/SearchTestUtils.sys.mjs"
);
const { TestUtils } = ChromeUtils.importESModule(
  "resource://testing-common/TestUtils.sys.mjs"
);
const { sinon } = ChromeUtils.importESModule(
  "resource://testing-common/Sinon.sys.mjs"
);
const { DiscoveryStreamFeed } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/DiscoveryStreamFeed.sys.mjs"
);

SearchTestUtils.init(this);
AddonTestUtils.init(this);
AddonTestUtils.createAppInfo(
  "xpcshell@tests.mozilla.org",
  "XPCShell",
  "42",
  "42"
);

const { AboutNewTab } = ChromeUtils.importESModule(
  "resource:///modules/AboutNewTab.sys.mjs"
);
const { PREFS_CONFIG } = ChromeUtils.importESModule(
  "resource://activity-stream/lib/ActivityStream.sys.mjs"
);

ChromeUtils.defineESModuleGetters(this, {
  BasePromiseWorker: "resource://gre/modules/PromiseWorker.sys.mjs",
});

const CACHE_WORKER_URL = "resource://activity-stream/lib/cache.worker.js";
const NEWTAB_RENDER_URL =
  "resource://activity-stream/data/content/newtab-render.js";

/**
 * In order to make this test less brittle, much of Activity Stream is
 * initialized here in order to generate a state object at runtime, rather
 * than hard-coding one in. This requires quite a bit of machinery in order
 * to work properly. Specifically, we need to launch an HTTP server to serve
 * a dynamic layout, and then have that layout point to a local feed rather
 * than one from the Pocket CDN.
 */
add_setup(async function () {
  do_get_profile();
  // The SearchService is also needed in order to construct the initial state,
  // which means that the AddonManager needs to be available.
  await AddonTestUtils.promiseStartupManager();

  // The example.com domain will be used to host the dynamic layout JSON and
  // the top stories JSON.
  let server = AddonTestUtils.createHttpServer({ hosts: ["example.com"] });
  server.registerDirectory("/", do_get_cwd());

  // Top Stories are disabled by default in our testing profiles.
  Services.prefs.setBoolPref(
    "browser.newtabpage.activity-stream.feeds.section.topstories",
    true
  );
  Services.prefs.setBoolPref(
    "browser.newtabpage.activity-stream.feeds.system.topstories",
    true
  );
  Services.prefs.setStringPref(
    "browser.newtabpage.activity-stream.discoverystream.region-weather-config",
    ""
  );
  Services.prefs.setBoolPref(
    "browser.newtabpage.activity-stream.newtabWallpapers.enabled",
    false
  );

  let defaultDSConfig = JSON.parse(
    PREFS_CONFIG.get("discoverystream.config").getValue({
      geo: "US",
      locale: "en-US",
    })
  );

  const sandbox = sinon.createSandbox();
  sandbox
    .stub(DiscoveryStreamFeed.prototype, "generateFeedUrl")
    .returns("http://example.com/topstories.json");

  // Configure Activity Stream to query for the layout JSON file that points
  // at the local top stories feed.
  Services.prefs.setCharPref(
    "browser.newtabpage.activity-stream.discoverystream.config",
    JSON.stringify(defaultDSConfig)
  );

  // We need to allow example.com as a place to get both the layout and the
  // top stories from.
  Services.prefs.setCharPref(
    "browser.newtabpage.activity-stream.discoverystream.endpoints",
    `http://example.com`
  );

  Services.prefs.setBoolPref(
    "browser.newtabpage.activity-stream.telemetry.structuredIngestion",
    false
  );

  // We need a default search engine set up for rendering the search input.
  await SearchTestUtils.installSearchExtension(
    {
      name: "Test engine",
      keyword: "@testengine",
      search_url_get_params: "s={searchTerms}",
    },
    { setAsDefault: true }
  );

  // Initialize Activity Stream, and pretend that a new window has been loaded
  // to kick off initializing all of the feeds.
  AboutNewTab.init();
  AboutNewTab.onBrowserReady();

  // Much of Activity Stream initializes asynchronously. This is the easiest way
  // I could find to ensure that enough of the feeds had initialized to produce
  // a meaningful cached document.
  await TestUtils.waitForCondition(() => {
    let feed = AboutNewTab.activityStream.store.feeds.get(
      "feeds.discoverystreamfeed"
    );
    return feed?.loaded;
  });
});

/**
 * Gets the Activity Stream Redux state from Activity Stream and sends it
 * into an instance of the cache worker to ensure that the resulting markup
 * and script makes sense.
 */
add_task(async function test_cache_worker() {
  Services.prefs.setBoolPref(
    "security.allow_parent_unrestricted_js_loads",
    true
  );
  registerCleanupFunction(() => {
    Services.prefs.clearUserPref("security.allow_parent_unrestricted_js_loads");
  });

  let state = AboutNewTab.activityStream.store.getState();

  let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL);
  let { page, script } = await cacheWorker.post("construct", [state]);
  ok(!!page.length, "Got page content");
  ok(!!script.length, "Got script content");

  // The template strings should have been replaced.
  equal(
    page.indexOf("{{ MARKUP }}"),
    -1,
    "Page template should have {{ MARKUP }} replaced"
  );
  equal(
    page.indexOf("{{ CACHE_TIME }}"),
    -1,
    "Page template should have {{ CACHE_TIME }} replaced"
  );
  equal(
    script.indexOf("{{ STATE }}"),
    -1,
    "Script template should have {{ STATE }} replaced"
  );

  // Now let's make sure that the generated script makes sense. We'll
  // evaluate it in a sandbox to make sure broken JS doesn't break the
  // test.
  let sandbox = Cu.Sandbox(Cu.getGlobalForObject({}));
  let passedState = null;

  // window.NewtabRenderUtils.renderCache is the exposed API from
  // activity-stream.jsx that the script is expected to call to hydrate
  // the pre-rendered markup. We'll implement that, and use that to ensure
  // that the passed in state object matches the state we sent into the
  // worker.
  sandbox.window = {
    NewtabRenderUtils: {
      renderCache(aState) {
        passedState = aState;
      },
    },
  };
  Cu.evalInSandbox(script, sandbox);

  // The NEWTAB_RENDER_URL script is what ultimately causes the state
  // to be passed into the renderCache function.
  Services.scriptloader.loadSubScript(NEWTAB_RENDER_URL, sandbox);

  equal(
    sandbox.window.__FROM_STARTUP_CACHE__,
    true,
    "Should have set __FROM_STARTUP_CACHE__ to true"
  );

  // The worker is expected to modify the state slightly before running
  // it through ReactDOMServer by setting App.isForStartupCache to true.
  // This allows React components to change their behaviour if the cache
  // is being generated.
  state.App.isForStartupCache = true;

  // Some of the properties on the state might have values set to undefined.
  // There is no way to express a named undefined property on an object in
  // JSON, so we filter those out by stringifying and re-parsing.
  state = JSON.parse(JSON.stringify(state));

  Assert.deepEqual(
    passedState,
    state,
    "Should have called renderCache with the expected state"
  );

  // Now let's do a quick smoke-test on the markup to ensure that the
  // one Top Story from topstories.json is there.
  let parser = new DOMParser();
  let doc = parser.parseFromString(page, "text/html");
  let root = doc.getElementById("root");
  ok(root.childElementCount, "There are children on the root node");

  // There should be the 1 top story, and 20 placeholders.
  equal(
    Array.from(root.querySelectorAll(".ds-card")).length,
    21,
    "There are 21 DSCards"
  );
  let cardHostname = doc.querySelector(
    "[data-section-id='topstories'] .source"
  ).innerText;
  equal(cardHostname, "bbc.com", "Card hostname is bbc.com");

  let placeholders = doc.querySelectorAll(".ds-card.placeholder");
  equal(placeholders.length, 20, "There should be 20 placeholders");
});

/**
 * Tests that if the cache-worker construct method throws an exception
 * that the construct Promise still resolves. Passing a null state should
 * be enough to get it to throw.
 */
add_task(async function test_cache_worker_exception() {
  let cacheWorker = new BasePromiseWorker(CACHE_WORKER_URL);
  let { page, script } = await cacheWorker.post("construct", [null]);
  equal(page, null, "Should have gotten a null page nsIInputStream");
  equal(script, null, "Should have gotten a null script nsIInputStream");
});
