/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */
/* vim:set ts=2 sw=2 sts=2 et: */
/* This Source Code Form is subject to the terms of the Mozilla Public
 * License, v. 2.0. If a copy of the MPL was not distributed with this
 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */

// Note that the server script itself already defines Cc, Ci, and Cr for us,
// and because they're constants it's not safe to redefine them.  Scope leakage
// sucks.

// Disable automatic network detection, so tests work correctly when
// not connected to a network.
var ios = Cc["@mozilla.org/network/io-service;1"]
          .getService(Ci.nsIIOService2);
ios.manageOfflineStatus = false;
ios.offline = false;

var server; // for use in the shutdown handler, if necessary

//
// HTML GENERATION
//
var tags = ['A', 'ABBR', 'ACRONYM', 'ADDRESS', 'APPLET', 'AREA', 'B', 'BASE',
            'BASEFONT', 'BDO', 'BIG', 'BLOCKQUOTE', 'BODY', 'BR', 'BUTTON',
            'CAPTION', 'CENTER', 'CITE', 'CODE', 'COL', 'COLGROUP', 'DD',
            'DEL', 'DFN', 'DIR', 'DIV', 'DL', 'DT', 'EM', 'FIELDSET', 'FONT',
            'FORM', 'FRAME', 'FRAMESET', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6',
            'HEAD', 'HR', 'HTML', 'I', 'IFRAME', 'IMG', 'INPUT', 'INS',
            'ISINDEX', 'KBD', 'LABEL', 'LEGEND', 'LI', 'LINK', 'MAP', 'MENU',
            'META', 'NOFRAMES', 'NOSCRIPT', 'OBJECT', 'OL', 'OPTGROUP',
            'OPTION', 'P', 'PARAM', 'PRE', 'Q', 'S', 'SAMP', 'SCRIPT',
            'SELECT', 'SMALL', 'SPAN', 'STRIKE', 'STRONG', 'STYLE', 'SUB',
            'SUP', 'TABLE', 'TBODY', 'TD', 'TEXTAREA', 'TFOOT', 'TH', 'THEAD',
            'TITLE', 'TR', 'TT', 'U', 'UL', 'VAR'];

/**
 * Below, we'll use makeTagFunc to create a function for each of the
 * strings in 'tags'. This will allow us to use s-expression like syntax
 * to create HTML.
 */
function makeTagFunc(tagName)
{
  return function (attrs /* rest... */)
  {
    var startChildren = 0;
    var response = "";

    // write the start tag and attributes
    response += "<" + tagName;
    // if attr is an object, write attributes
    if (attrs && typeof attrs == 'object') {
      startChildren = 1;

      for (let key in attrs) {
        const value = attrs[key];
        var val = "" + value;
        response += " " + key + '="' + val.replace('"','&quot;') + '"';
      }
    }
    response += ">";

    // iterate through the rest of the args
    for (var i = startChildren; i < arguments.length; i++) {
      if (typeof arguments[i] == 'function') {
        response += arguments[i]();
      } else {
        response += arguments[i];
      }
    }

    // write the close tag
    response += "</" + tagName + ">\n";
    return response;
  }
}

function makeTags() {
  // map our global HTML generation functions
  for (let tag of tags) {
      this[tag] = makeTagFunc(tag.toLowerCase());
  }
}

var _quitting = false;

/** Quit when all activity has completed. */
function serverStopped()
{
  _quitting = true;
}

// only run the "main" section if httpd.js was loaded ahead of us
if (this["nsHttpServer"]) {
  //
  // SCRIPT CODE
  //
  runServer();

  // We can only have gotten here if the /server/shutdown path was requested.
  if (_quitting)
  {
    dumpn("HTTP server stopped, all pending requests complete");
    quit(0);
  }

  // Impossible as the stop callback should have been called, but to be safe...
  dumpn("TEST-UNEXPECTED-FAIL | failure to correctly shut down HTTP server");
  quit(1);
}

var serverBasePath;
var displayResults = true;

var gServerAddress;
var SERVER_PORT;

//
// SERVER SETUP
//
function runServer()
{
  serverBasePath = __LOCATION__.parent;
  server = createMochitestServer(serverBasePath);

  //verify server address
  //if a.b.c.d or 'localhost'
  if (typeof(_SERVER_ADDR) != "undefined") {
    if (_SERVER_ADDR == "localhost") {
      gServerAddress = _SERVER_ADDR;      
    } else {
      var quads = _SERVER_ADDR.split('.');
      if (quads.length == 4) {
        var invalid = false;
        for (var i=0; i < 4; i++) {
          if (quads[i] < 0 || quads[i] > 255)
            invalid = true;
        }
        if (!invalid)
          gServerAddress = _SERVER_ADDR;
        else
          throw "invalid _SERVER_ADDR, please specify a valid IP Address";
      }
    }
  } else {
    throw "please defined _SERVER_ADDR (as an ip address) before running server.js";
  }

  if (typeof(_SERVER_PORT) != "undefined") {
    if (parseInt(_SERVER_PORT) > 0 && parseInt(_SERVER_PORT) < 65536)
      SERVER_PORT = _SERVER_PORT;
  } else {
    throw "please define _SERVER_PORT (as a port number) before running server.js";
  }

  // If DISPLAY_RESULTS is not specified, it defaults to true
  if (typeof(_DISPLAY_RESULTS) != "undefined") {
    displayResults = _DISPLAY_RESULTS;
  }

  server._start(SERVER_PORT, gServerAddress);

  // touch a file in the profile directory to indicate we're alive
  var foStream = Cc["@mozilla.org/network/file-output-stream;1"]
                   .createInstance(Ci.nsIFileOutputStream);
  var serverAlive = Cc["@mozilla.org/file/local;1"]
                      .createInstance(Ci.nsIFile);

  if (typeof(_PROFILE_PATH) == "undefined") {
    serverAlive.initWithFile(serverBasePath);
    serverAlive.append("mochitesttestingprofile");
  } else {
    serverAlive.initWithPath(_PROFILE_PATH);
  }

  // If we're running outside of the test harness, there might
  // not be a test profile directory present
  if (serverAlive.exists()) {
    serverAlive.append("server_alive.txt");
    foStream.init(serverAlive,
                  0x02 | 0x08 | 0x20, 436, 0); // write, create, truncate
    var data = "It's alive!";
    foStream.write(data, data.length);
    foStream.close();
  }

  makeTags();

  //
  // The following is threading magic to spin an event loop -- this has to
  // happen manually in xpcshell for the server to actually work.
  //
  var thread = Cc["@mozilla.org/thread-manager;1"]
                 .getService()
                 .currentThread;
  while (!server.isStopped())
    thread.processNextEvent(true);

  // Server stopped by /server/shutdown handler -- go through pending events
  // and return.

  // get rid of any pending requests
  while (thread.hasPendingEvents())
    thread.processNextEvent(true);
}

/** Creates and returns an HTTP server configured to serve Mochitests. */
function createMochitestServer(serverBasePath)
{
  var server = new nsHttpServer();

  server.registerDirectory("/", serverBasePath);
  server.registerPathHandler("/server/shutdown", serverShutdown);
  server.registerPathHandler("/server/debug", serverDebug);
  server.registerPathHandler("/nested_oop", nestedTest);
  server.registerContentType("sjs", "sjs"); // .sjs == CGI-like functionality
  server.registerContentType("jar", "application/x-jar");
  server.registerContentType("ogg", "application/ogg");
  server.registerContentType("pdf", "application/pdf");
  server.registerContentType("ogv", "video/ogg");
  server.registerContentType("oga", "audio/ogg");
  server.registerContentType("opus", "audio/ogg; codecs=opus");
  server.registerContentType("dat", "text/plain; charset=utf-8");
  server.registerContentType("frag", "text/plain"); // .frag == WebGL fragment shader
  server.registerContentType("vert", "text/plain"); // .vert == WebGL vertex shader
  server.registerContentType("wasm", "application/wasm");
  server.setIndexHandler(defaultDirHandler);

  var serverRoot =
    {
      getFile: function getFile(path)
      {
        var file = serverBasePath.clone().QueryInterface(Ci.nsIFile);
        path.split("/").forEach(function(p) {
          file.appendRelativePath(p);
        });
        return file;
      },
      QueryInterface: function(aIID) { return this; }
    };

  server.setObjectState("SERVER_ROOT", serverRoot);

  processLocations(server);

  return server;
}

/**
 * Notifies the HTTP server about all the locations at which it might receive
 * requests, so that it can properly respond to requests on any of the hosts it
 * serves.
 */
function processLocations(server)
{
  var serverLocations = serverBasePath.clone();
  serverLocations.append("server-locations.txt");

  const PR_RDONLY = 0x01;
  var fis = new FileInputStream(serverLocations, PR_RDONLY, 292 /* 0444 */,
                                Ci.nsIFileInputStream.CLOSE_ON_EOF);

  var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0);
  lis.QueryInterface(Ci.nsIUnicharLineInputStream);

  const LINE_REGEXP =
    new RegExp("^([a-z][-a-z0-9+.]*)" +
               "://" +
               "(" +
                 "\\d+\\.\\d+\\.\\d+\\.\\d+" +
                 "|" +
                 "(?:[a-z0-9](?:[-a-z0-9]*[a-z0-9])?\\.)*" +
                 "[a-z](?:[-a-z0-9]*[a-z0-9])?" +
               ")" +
               ":" +
               "(\\d+)" +
               "(?:" +
               "\\s+" +
               "(\\S+(?:,\\S+)*)" +
               ")?$");

  var line = {};
  var lineno = 0;
  var seenPrimary = false;
  do
  {
    var more = lis.readLine(line);
    lineno++;

    var lineValue = line.value;
    if (lineValue.charAt(0) == "#" || lineValue == "")
      continue;

    var match = LINE_REGEXP.exec(lineValue);
    if (!match)
      throw "Syntax error in server-locations.txt, line " + lineno;

    var [, scheme, host, port, options] = match;
    if (options)
    {
      if (options.split(",").indexOf("primary") >= 0)
      {
        if (seenPrimary)
        {
          throw "Multiple primary locations in server-locations.txt, " +
                "line " + lineno;
        }
  
        server.identity.setPrimary(scheme, host, port);
        seenPrimary = true;
        continue;
      }
    }

    server.identity.add(scheme, host, port);
  }
  while (more);
}

// PATH HANDLERS

// /server/shutdown
function serverShutdown(metadata, response)
{
  response.setStatusLine("1.1", 200, "OK");
  response.setHeader("Content-type", "text/plain", false);

  var body = "Server shut down.";
  response.bodyOutputStream.write(body, body.length);

  dumpn("Server shutting down now...");
  server.stop(serverStopped);
}

// /server/debug?[012]
function serverDebug(metadata, response)
{
  response.setStatusLine(metadata.httpVersion, 400, "Bad debugging level");
  if (metadata.queryString.length !== 1)
    return;

  var mode;
  if (metadata.queryString === "0") {
    // do this now so it gets logged with the old mode
    dumpn("Server debug logs disabled.");
    DEBUG = false;
    DEBUG_TIMESTAMP = false;
    mode = "disabled";
  } else if (metadata.queryString === "1") {
    DEBUG = true;
    DEBUG_TIMESTAMP = false;
    mode = "enabled";
  } else if (metadata.queryString === "2") {
    DEBUG = true;
    DEBUG_TIMESTAMP = true;
    mode = "enabled, with timestamps";
  } else {
    return;
  }

  response.setStatusLine(metadata.httpVersion, 200, "OK");
  response.setHeader("Content-type", "text/plain", false);
  var body = "Server debug logs " + mode + ".";
  response.bodyOutputStream.write(body, body.length);
  dumpn(body);
}

//
// DIRECTORY LISTINGS
//

/**
 * Creates a generator that iterates over the contents of
 * an nsIFile directory.
 */
function* dirIter(dir)
{
  var en = dir.directoryEntries;
  while (en.hasMoreElements()) {
    var file = en.getNext();
    yield file.QueryInterface(Ci.nsIFile);
  }
}

/**
 * Builds an optionally nested object containing links to the
 * files and directories within dir.
 */
function list(requestPath, directory, recurse)
{
  var count = 0;
  var path = requestPath;
  if (path.charAt(path.length - 1) != "/") {
    path += "/";
  }

  var dir = directory.QueryInterface(Ci.nsIFile);
  var links = {};

  // The SimpleTest directory is hidden
  let files = [];
  for (let file of dirIter(dir)) {
    if (file.exists() && file.path.indexOf("SimpleTest") == -1) {
      files.push(file);
    }
  }

  // Sort files by name, so that tests can be run in a pre-defined order inside
  // a given directory (see bug 384823)
  function leafNameComparator(first, second) {
    if (first.leafName < second.leafName)
      return -1;
    if (first.leafName > second.leafName)
      return 1;
    return 0;
  }
  files.sort(leafNameComparator);

  count = files.length;
  for (let file of files) {
    var key = path + file.leafName;
    var childCount = 0;
    if (file.isDirectory()) {
      key += "/";
    }
    if (recurse && file.isDirectory()) {
      [links[key], childCount] = list(key, file, recurse);
      count += childCount;
    } else {
      if (file.leafName.charAt(0) != '.') {
        links[key] = {'test': {'url': key, 'expected': 'pass'}};
      }
    }
  }

  return [links, count];
}

/**
 * Heuristic function that determines whether a given path
 * is a test case to be executed in the harness, or just
 * a supporting file.
 */
function isTest(filename, pattern)
{
  if (pattern)
    return pattern.test(filename);

  // File name is a URL style path to a test file, make sure that we check for
  // tests that start with the appropriate prefix.
  var testPrefix = typeof(_TEST_PREFIX) == "string" ? _TEST_PREFIX : "test_";
  var testPattern = new RegExp("^" + testPrefix);

  var pathPieces = filename.split('/');
    
  return testPattern.test(pathPieces[pathPieces.length - 1]) &&
         filename.indexOf(".js") == -1 &&
         filename.indexOf(".css") == -1 &&
         !/\^headers\^$/.test(filename);
}

/**
 * Transform nested hashtables of paths to nested HTML lists.
 */
function linksToListItems(links)
{
  var response = "";
  var children = "";
  for (let link in links) {
    const value = links[link];
    var classVal = (!isTest(link) && !(value instanceof Object))
      ? "non-test invisible"
      : "test";
    if (value instanceof Object) {
      children = UL({class: "testdir"}, linksToListItems(value)); 
    } else {
      children = "";
    }

    var bug_title = link.match(/test_bug\S+/);
    var bug_num = null;
    if (bug_title != null) {
        bug_num = bug_title[0].match(/\d+/);
    }

    if ((bug_title == null) || (bug_num == null)) {
      response += LI({class: classVal}, A({href: link}, link), children);
    } else {
      var bug_url = "https://bugzilla.mozilla.org/show_bug.cgi?id="+bug_num;
      response += LI({class: classVal}, A({href: link}, link), " - ", A({href: bug_url}, "Bug "+bug_num), children);
    }

  }
  return response;
}

/**
 * Transform nested hashtables of paths to a flat table rows.
 */
function linksToTableRows(links, recursionLevel)
{
  var response = "";
  for (let link in links) {
    const value = links[link];
    var classVal = (!isTest(link) && ((value instanceof Object) && ('test' in value)))
      ? "non-test invisible"
      : "";

    var spacer = "padding-left: " + (10 * recursionLevel) + "px";

    if ((value instanceof Object) && !('test' in value)) {
      response += TR({class: "dir", id: "tr-" + link },
                     TD({colspan: "3"}, "&#160;"),
                     TD({style: spacer},
                        A({href: link}, link)));
      response += linksToTableRows(value, recursionLevel + 1);
    } else {
      var bug_title = link.match(/test_bug\S+/);
      var bug_num = null;
      if (bug_title != null) {
          bug_num = bug_title[0].match(/\d+/);
      }
      if ((bug_title == null) || (bug_num == null)) {
        response += TR({class: classVal, id: "tr-" + link },
                       TD("0"),
                       TD("0"),
                       TD("0"),
                       TD({style: spacer},
                          A({href: link}, link)));
      } else {
        var bug_url = "https://bugzilla.mozilla.org/show_bug.cgi?id=" + bug_num;
        response += TR({class: classVal, id: "tr-" + link },
                       TD("0"),
                       TD("0"),
                       TD("0"),
                       TD({style: spacer},
                          A({href: link}, link), " - ",
                          A({href: bug_url}, "Bug " + bug_num)));
      }
    }
  }
  return response;
}

function arrayOfTestFiles(linkArray, fileArray, testPattern) {
  for (let link in linkArray) {
    const value = linkArray[link];
    if ((value instanceof Object) && !('test' in value)) {
      arrayOfTestFiles(value, fileArray, testPattern);
    } else if (isTest(link, testPattern) && (value instanceof Object)) {
      fileArray.push(value['test'])
    }
  }
}
/**
 * Produce a flat array of test file paths to be executed in the harness.
 */
function jsonArrayOfTestFiles(links)
{
  var testFiles = [];
  arrayOfTestFiles(links, testFiles);
  testFiles = testFiles.map(function(file) { return '"' + file['url'] + '"'; });

  return "[" + testFiles.join(",\n") + "]";
}

/**
 * Produce a normal directory listing.
 */
function regularListing(metadata, response)
{
  var [links, count] = list(metadata.path,
                            metadata.getProperty("directory"),
                            false);
  response.write(
    HTML(
      HEAD(
        TITLE("mochitest index ", metadata.path)
      ),
      BODY(
        BR(),
        A({href: ".."}, "Up a level"),
        UL(linksToListItems(links))
      )
    )
  );
}

/**
 * Read a manifestFile located at the root of the server's directory and turn
 * it into an object for creating a table of clickable links for each test.
 */
function convertManifestToTestLinks(root, manifest)
{
  Cu.import("resource://gre/modules/NetUtil.jsm");

  var manifestFile = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
  manifestFile.initWithFile(serverBasePath);
  manifestFile.append(manifest);

  var manifestStream = Cc["@mozilla.org/network/file-input-stream;1"].createInstance(Ci.nsIFileInputStream);
  manifestStream.init(manifestFile, -1, 0, 0);

  var manifestObj = JSON.parse(NetUtil.readInputStreamToString(manifestStream,
                                                               manifestStream.available()));
  var paths = manifestObj.tests;
  var pathPrefix = '/' + root + '/'
  return [paths.reduce(function(t, p) { t[pathPrefix + p.path] = true; return t; }, {}),
          paths.length];
}

/**
 * Produce a test harness page that has one remote iframe
 */
function nestedTest(metadata, response)
{
  response.setStatusLine("1.1", 200, "OK");
  response.setHeader("Content-type", "text/html;charset=utf-8", false);
  response.write(
    HTML(
      HEAD(
        TITLE("Mochitest | ", metadata.path),
        LINK({rel: "stylesheet",
              type: "text/css", href: "/static/harness.css"}),
        SCRIPT({type: "text/javascript",
                src: "/nested_setup.js"}),
        SCRIPT({type: "text/javascript"},
               "window.onload = addPermissions; gTestURL = '/tests?" + metadata.queryString + "';")
        ),
      BODY(
        DIV({class: "container"},
          DIV({class: "frameholder", id: "holder-div"})
        )
        )));
}

/**
 * Produce a test harness page containing all the test cases
 * below it, recursively.
 */
function testListing(metadata, response)
{
  var links = {};
  var count = 0;
  if (metadata.queryString.indexOf('manifestFile') == -1) {
    [links, count] = list(metadata.path,
                          metadata.getProperty("directory"),
                          true);
  } else if (typeof(Components) != undefined) {
    var manifest = metadata.queryString.match(/manifestFile=([^&]+)/)[1];

    [links, count] = convertManifestToTestLinks(metadata.path.split('/')[1],
                                                manifest);
  }

  var table_class = metadata.queryString.indexOf("hideResultsTable=1") > -1 ? "invisible": "";

  let testname = (metadata.queryString.indexOf("testname=") > -1)
                 ? metadata.queryString.match(/testname=([^&]+)/)[1]
                 : "";

  dumpn("count: " + count);
  var tests = testname
              ? "['/" + testname + "']"
              : jsonArrayOfTestFiles(links);
  response.write(
    HTML(
      HEAD(
        TITLE("MochiTest | ", metadata.path),
        LINK({rel: "stylesheet",
              type: "text/css", href: "/static/harness.css"}
        ),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/StructuredLog.jsm"}),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/LogController.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/MemoryStats.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/TestRunner.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/MozillaLogger.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/chunkifyTests.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/manifestLibrary.js"}),
        SCRIPT({type: "text/javascript",
                 src: "/tests/SimpleTest/setup.js"}),
        SCRIPT({type: "text/javascript"},
               "window.onload =  hookup; gTestList=" + tests + ";"
        )
      ),
      BODY(
        DIV({class: "container"},
          H2("--> ", A({href: "#", id: "runtests"}, "Run Tests"), " <--"),
            P({style: "float: right;"},
            SMALL(
              "Based on the ",
              A({href:"http://www.mochikit.com/"}, "MochiKit"),
              " unit tests."
            )
          ),
          DIV({class: "status"},
            H1({id: "indicator"}, "Status"),
            H2({id: "pass"}, "Passed: ", SPAN({id: "pass-count"},"0")),
            H2({id: "fail"}, "Failed: ", SPAN({id: "fail-count"},"0")),
            H2({id: "fail"}, "Todo: ", SPAN({id: "todo-count"},"0"))
          ),
          DIV({class: "clear"}),
          DIV({id: "current-test"},
            B("Currently Executing: ",
              SPAN({id: "current-test-path"}, "_")
            )
          ),
          DIV({class: "clear"}),
          DIV({class: "frameholder"},
            IFRAME({scrolling: "no", id: "testframe", "allowfullscreen": true})
          ),
          DIV({class: "clear"}),
          DIV({class: "toggle"},
            A({href: "#", id: "toggleNonTests"}, "Show Non-Tests"),
            BR()
          ),

          (
           displayResults ?
            TABLE({cellpadding: 0, cellspacing: 0, class: table_class, id: "test-table"},
              TR(TD("Passed"), TD("Failed"), TD("Todo"), TD("Test Files")),
              linksToTableRows(links, 0)
            ) : ""
          ),

          BR(),
          TABLE({cellpadding: 0, cellspacing: 0, border: 1, bordercolor: "red", id: "fail-table"}
          ),

          DIV({class: "clear"})
        )
      )
    )
  );
}

/**
 * Respond to requests that match a file system directory.
 * Under the tests/ directory, return a test harness page.
 */
function defaultDirHandler(metadata, response)
{
  response.setStatusLine("1.1", 200, "OK");
  response.setHeader("Content-type", "text/html;charset=utf-8", false);
  try {
    if (metadata.path.indexOf("/tests") != 0) {
      regularListing(metadata, response);
    } else {
      testListing(metadata, response);
    }
  } catch (ex) {
    response.write(ex);
  }  
}
