#!/usr/bin/env -S deno run --allow-read --allow-write --allow-env --allow-net --allow-run
// Usage: ./make.js command. Use -l to list commands.
// This is a set of tasks for building and testing Vimium in development.
import * as fs from "https://deno.land/std@0.122.0/fs/mod.ts";
import * as fsCopy from "https://deno.land/std@0.122.0/fs/copy.ts";
import * as path from "https://deno.land/std@0.136.0/path/mod.ts";
import { abort, desc, run, task } from "https://deno.land/x/drake@v1.5.1/mod.ts";
import puppeteer from "https://deno.land/x/puppeteer@16.2.0/mod.ts";
import * as shoulda from "./tests/vendor/shoulda.js";
import JSON5 from "https://deno.land/x/json5@v1.0.0/mod.ts";
import { DOMParser } from "https://deno.land/x/deno_dom/deno-dom-wasm.ts";
import * as fileServer from "https://deno.land/std@0.208.0/http/file_server.ts";
import { getAvailablePort } from "https://deno.land/x/port/mod.ts";

const projectPath = new URL(".", import.meta.url).pathname;

async function shell(procName, argsArray = []) {
  // NOTE(philc): Does drake's `sh` function work on Windows? If so, that can replace this function.
  if (Deno.build.os == "windows") {
    // if win32, prefix arguments with "/c {original command}"
    // e.g. "mkdir c:\git\vimium" becomes "cmd.exe /c mkdir c:\git\vimium"
    optArray.unshift("/c", procName);
    procName = "cmd.exe";
  }
  const p = Deno.run({ cmd: [procName].concat(argsArray) });
  const status = await p.status();
  if (!status.success) {
    throw new Error(`${procName} ${argsArray} exited with status ${status.code}`);
  }
}

// Clones and augments the manifest.json that we use for Chrome with the keys needed for Firefox.
function createFirefoxManifest(manifest) {
  manifest = JSON.parse(JSON.stringify(manifest)); // Deep clone.

  manifest.permissions = manifest.permissions
    // The favicon permission is not yet supported by Firefox.
    .filter((p) => p != "favicon")
    // Firefox needs clipboardRead and clipboardWrite for commands like "copyCurrentUrl", but Chrome
    // does not. See #4186.
    .concat(["clipboardRead", "clipboardWrite"]);

  // As of 2023-07-08 Firefox doesn't yet support background.service_worker.
  delete manifest.background["service_worker"];
  Object.assign(manifest.background, {
    "scripts": ["background_scripts/background.js"],
  });

  // This key is only supported by Firefox.
  Object.assign(manifest.action, {
    "default_area": "navbar",
  });

  Object.assign(manifest, {
    "browser_specific_settings": {
      "gecko": {
        // This ID was generated by the Firefox store upon first submission. It's needed in
        // development mode, or many extension APIs don't work.
        "id": "{d7742d87-e61d-4b78-b8a1-b469842139fa}",
        "strict_min_version": "112.0",
      },
    },
  });

  return manifest;
}

async function parseManifestFile() {
  // Chrome's manifest.json supports JavaScript comment syntax. However, the Chrome Store rejects
  // manifests with JavaScript comments in them! So here we use the JSON5 library, rather than JSON
  // library, to parse our manifest.json and remove its comments.
  return JSON5.parse(await Deno.readTextFile("./manifest.json"));
}

// Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/.
async function buildStorePackage() {
  const excludeList = [
    "*.md",
    ".*",
    "CREDITS",
    "MIT-LICENSE.txt",
    "dist",
    "make.js",
    "deno.json",
    "deno.lock",
    "test_harnesses",
    "tests",
  ];

  const chromeManifest = await parseManifestFile();
  const rsyncOptions = ["-r", ".", "dist/vimium"].concat(
    ...excludeList.map((item) => ["--exclude", item]),
  );
  const version = chromeManifest["version"];
  const writeDistManifest = async (manifest) => {
    await Deno.writeTextFile("dist/vimium/manifest.json", JSON.stringify(manifest, null, 2));
  };
  // cd into "dist/vimium" before building the zip, so that the files in the zip don't each have the
  // path prefix "dist/vimium".
  // --filesync ensures that files in the archive which are no longer on disk are deleted. It's
  // equivalent to removing the zip file before the build.
  const zipCommand = "cd dist/vimium && zip -r --filesync ";

  await shell("rm", ["-rf", "dist/vimium"]);
  await shell("mkdir", [
    "-p",
    "dist/vimium",
    "dist/chrome-canary",
    "dist/chrome-store",
    "dist/firefox",
  ]);
  await shell("rsync", rsyncOptions);

  const firefoxManifest = createFirefoxManifest(chromeManifest);
  await writeDistManifest(firefoxManifest);
  await shell("bash", ["-c", `${zipCommand} ../firefox/vimium-firefox-${version}.zip .`]);

  // Build the Chrome Store package.
  await writeDistManifest(chromeManifest);
  await shell("bash", [
    "-c",
    `${zipCommand} ../chrome-store/vimium-chrome-store-${version}.zip .`,
  ]);

  // Build the Chrome Store dev package.
  await writeDistManifest(Object.assign({}, chromeManifest, {
    name: "Vimium Canary",
    description: "This is the development branch of Vimium (it is beta software).",
  }));
  await shell("bash", [
    "-c",
    `${zipCommand} ../chrome-canary/vimium-canary-${version}.zip .`,
  ]);
}

const runUnitTests = async () => {
  // Import every test file.
  const dir = path.join(projectPath, "tests/unit_tests");
  const files = Array.from(Deno.readDirSync(dir)).map((f) => f.name).sort();
  for (let f of files) {
    if (f.endsWith("_test.js")) {
      await import(path.join(dir, f));
    }
  }

  return await shoulda.run();
};

const runDomTests = async (port) => {
  const testUrl = `http://localhost:${port}/tests/dom_tests/dom_tests.html`;

  const browser = await puppeteer.launch();
  const page = await browser.newPage();
  let receivedErrorOutput = false;

  page.on("console", async (msg) => {
    const args = await Promise.all(msg.args().map((a) => a.jsonValue()));
    console.log(...args);
  });
  page.on("error", (err) => {
    // As far as I can tell, this handler never gets executed.
    console.error(err);
  });
  // pageerror catches the same events that window.onerror would, like JavaScript parsing errors.
  page.on("pageerror", (error) => {
    receivedErrorOutput = true;
    // Whatever type error is, it requires toString() to print the message.
    console.log(error.toString());
  });
  page.on(
    "requestfailed",
    (request) => console.log(console.log(`${request.failure().errorText} ${request.url()}`)),
  );

  page.goto(testUrl);

  await page.waitForNavigation({ waitUntil: "load" });

  const success = await page.evaluate(async () => {
    return await shoulda.run();
  });

  // NOTE(philc): At one point in development, I noticed that the output from Deno would suddenly
  // pause, prior to the tests fully finishing, so closing the browser here may be racy. If it
  // occurs again, we may need to add "await delay(200)".
  await browser.close();
  if (receivedErrorOutput) {
    throw new Error("The tests fail because there was a page-level error.");
  }
  return success;
};

desc("Download and parse list of top-level domains (TLDs)");
task("fetch-tlds", [], async () => {
  const suffixListUrl = "https://www.iana.org/domains/root/db";
  const response = await fetch(suffixListUrl);
  const text = await response.text();
  const doc = new DOMParser().parseFromString(text, "text/html");
  const els = doc.querySelectorAll("span.domain.tld");
  // Each span contains a TLD, e.g. ".com". Trim off the leading period.
  const domains = Array.from(els).map((el) => el.innerText.slice(1));
  const str = domains.join("\n");
  await Deno.writeTextFile("./resources/tlds.txt", str);
});

desc("Run unit tests");
task("test-unit", [], async () => {
  const success = await runUnitTests();
  if (!success) {
    abort("test-unit failed");
  }
});

desc("Run DOM tests");
task("test-dom", [], async () => {
  const port = await getAvailablePort();
  let served404 = false;
  const httpServer = Deno.serve({ port }, async (req) => {
    const url = new URL(req.url);
    let path = decodeURIComponent(url.pathname);
    if (path.startsWith("/")) {
      path = "." + path;
    }
    if (!(await fs.exists(path))) {
      console.error("dom-tests: requested missing file (not found):", path);
      served404 = true;
      return new Response(null, { status: 404 });
    } else {
      return fileServer.serveFile(req, path);
    }
  });

  const success = await runDomTests(port);
  if (served404) {
    console.log("Tests failed because a background or content script requested a missing file.");
  }
  await httpServer.shutdown();
  if (served404 || !success) {
    abort("test-dom failed.");
  }
});

desc("Run unit and DOM tests");
task("test", ["test-unit", "test-dom"]);

desc("Builds a zip file for submission to the Chrome and Firefox stores. The output is in dist/");
task("package", [], async () => {
  await buildStorePackage();
});

desc("Replaces manifest.json with a Firefox-compatible version, for development");
task("write-firefox-manifest", [], async () => {
  const firefoxManifest = createFirefoxManifest(await parseManifestFile());
  await Deno.writeTextFile("./manifest.json", JSON.stringify(firefoxManifest, null, 2));
});

run();
