diff options
author | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
---|---|---|
committer | Matt A. Tobin <mattatobin@localhost.localdomain> | 2018-02-02 04:16:08 -0500 |
commit | 5f8de423f190bbb79a62f804151bc24824fa32d8 (patch) | |
tree | 10027f336435511475e392454359edea8e25895d /addon-sdk/source/test/addons | |
parent | 49ee0794b5d912db1f95dce6eb52d781dc210db5 (diff) | |
download | UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.gz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.lz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.tar.xz UXP-5f8de423f190bbb79a62f804151bc24824fa32d8.zip |
Add m-esr52 at 52.6.0
Diffstat (limited to 'addon-sdk/source/test/addons')
172 files changed, 33879 insertions, 0 deletions
diff --git a/addon-sdk/source/test/addons/addon-manager/lib/main.js b/addon-sdk/source/test/addons/addon-manager/lib/main.js new file mode 100644 index 000000000..043424f59 --- /dev/null +++ b/addon-sdk/source/test/addons/addon-manager/lib/main.js @@ -0,0 +1,8 @@ +/* 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/. */ +"use strict"; + +module.exports = require("./test-main.js"); + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/addon-manager/lib/test-main.js b/addon-sdk/source/test/addons/addon-manager/lib/test-main.js new file mode 100644 index 000000000..7204c4a40 --- /dev/null +++ b/addon-sdk/source/test/addons/addon-manager/lib/test-main.js @@ -0,0 +1,12 @@ +/* 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/. */ +"use strict"; + +const { id } = require("sdk/self"); +const { getAddonByID } = require("sdk/addon/manager"); + +exports["test getAddonByID"] = function*(assert) { + let addon = yield getAddonByID(id); + assert.equal(addon.id, id, "getAddonByID works"); +} diff --git a/addon-sdk/source/test/addons/addon-manager/package.json b/addon-sdk/source/test/addons/addon-manager/package.json new file mode 100644 index 000000000..2ed748498 --- /dev/null +++ b/addon-sdk/source/test/addons/addon-manager/package.json @@ -0,0 +1,7 @@ +{ + "id": "test-addon-manager@jetpack", + "main": "./lib/main.js", + "name": "test-addon-manager", + "version": "0.0.1", + "author": "Erik Vold" +} diff --git a/addon-sdk/source/test/addons/author-email/main.js b/addon-sdk/source/test/addons/author-email/main.js new file mode 100644 index 000000000..34786475a --- /dev/null +++ b/addon-sdk/source/test/addons/author-email/main.js @@ -0,0 +1,14 @@ +/* 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/. */ +'use strict'; + +const { id } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testContributors = function*(assert) { + let addon = yield getAddonByID(id); + assert.equal(addon.creator.name, 'test <test@mozilla.com>', '< and > characters work'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/author-email/package.json b/addon-sdk/source/test/addons/author-email/package.json new file mode 100644 index 000000000..2654ec431 --- /dev/null +++ b/addon-sdk/source/test/addons/author-email/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-addon-author-email@jetpack", + "author": "test <test@mozilla.com>", + "version": "0.0.1", + "main": "./main.js" +} diff --git a/addon-sdk/source/test/addons/child_process/index.js b/addon-sdk/source/test/addons/child_process/index.js new file mode 100644 index 000000000..bf69e0380 --- /dev/null +++ b/addon-sdk/source/test/addons/child_process/index.js @@ -0,0 +1,39 @@ +/* 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/. */ + +"use strict"; + +/** + * Ensures using child_process and underlying subprocess.jsm + * works within an addon + */ + +const { exec } = require("sdk/system/child_process"); +const { platform, pathFor } = require("sdk/system"); +const PROFILE_DIR = pathFor("ProfD"); +const isWindows = platform.toLowerCase().indexOf("win") === 0; +const app = require("sdk/system/xul-app"); + +// Once Bug 903018 is resolved, just move the application testing to +// module.metadata.engines +if (app.is("Firefox")) { + exports["test child_process in an addon"] = (assert, done) => { + exec(isWindows ? "DIR /A-D" : "ls -al", { + cwd: PROFILE_DIR + }, (err, stdout, stderr) => { + assert.equal(err, null, "no errors"); + assert.equal(stderr, "", "stderr is empty"); + assert.ok(/extensions\.ini/.test(stdout), "stdout output of `ls -al` finds files"); + + if (isWindows) + assert.ok(!/<DIR>/.test(stdout), "passing args works"); + else + assert.ok(/d(r[-|w][-|x]){3}/.test(stdout), "passing args works"); + done(); + }); + }; +} else { + exports["test unsupported"] = (assert) => assert.pass("This application is unsupported."); +} +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/child_process/package.json b/addon-sdk/source/test/addons/child_process/package.json new file mode 100644 index 000000000..3b882d0c4 --- /dev/null +++ b/addon-sdk/source/test/addons/child_process/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-child-process@jetpack", + "main": "./index.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/chrome/chrome.manifest b/addon-sdk/source/test/addons/chrome/chrome.manifest new file mode 100644 index 000000000..35e59a107 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome.manifest @@ -0,0 +1,5 @@ +content test chrome/content/ +skin test classic/1.0 chrome/skin/ + +locale test en-US chrome/locale/en-US/ +locale test ja-JP chrome/locale/ja-JP/ diff --git a/addon-sdk/source/test/addons/chrome/chrome/content/new-window.xul b/addon-sdk/source/test/addons/chrome/chrome/content/new-window.xul new file mode 100644 index 000000000..25e219b80 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome/content/new-window.xul @@ -0,0 +1,4 @@ +<?xml version="1.0"?> +<dialog xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul" + windowtype="test:window"> +</dialog> diff --git a/addon-sdk/source/test/addons/chrome/chrome/content/panel.html b/addon-sdk/source/test/addons/chrome/chrome/content/panel.html new file mode 100644 index 000000000..595cc7455 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome/content/panel.html @@ -0,0 +1,10 @@ +<!-- 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/. --> +<!DOCTYPE html> +<html> + <head> + <meta charset="UTF-8"> + </head> + <body></body> +</html> diff --git a/addon-sdk/source/test/addons/chrome/chrome/locale/en-US/description.properties b/addon-sdk/source/test/addons/chrome/chrome/locale/en-US/description.properties new file mode 100644 index 000000000..148e2a127 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome/locale/en-US/description.properties @@ -0,0 +1 @@ +test=Test diff --git a/addon-sdk/source/test/addons/chrome/chrome/locale/ja-JP/description.properties b/addon-sdk/source/test/addons/chrome/chrome/locale/ja-JP/description.properties new file mode 100644 index 000000000..cf01ac85b --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome/locale/ja-JP/description.properties @@ -0,0 +1 @@ +test=テスト diff --git a/addon-sdk/source/test/addons/chrome/chrome/skin/style.css b/addon-sdk/source/test/addons/chrome/chrome/skin/style.css new file mode 100644 index 000000000..22abf3596 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/chrome/skin/style.css @@ -0,0 +1,4 @@ +/* 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/. */ +test{} diff --git a/addon-sdk/source/test/addons/chrome/data/panel.js b/addon-sdk/source/test/addons/chrome/data/panel.js new file mode 100644 index 000000000..c38eca852 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/data/panel.js @@ -0,0 +1,10 @@ +/* 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/. */ +'use strict'; + +self.port.on('echo', _ => { + self.port.emit('echo', ''); +}); + +self.port.emit('start', ''); diff --git a/addon-sdk/source/test/addons/chrome/main.js b/addon-sdk/source/test/addons/chrome/main.js new file mode 100644 index 000000000..84b822458 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/main.js @@ -0,0 +1,97 @@ +/* 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/. */ +'use strict' + +const { Cu, Cc, Ci } = require('chrome'); +const Request = require('sdk/request').Request; +const { WindowTracker } = require('sdk/deprecated/window-utils'); +const { close, open } = require('sdk/window/helpers'); +const { data } = require('sdk/self'); +const { Panel } = require('sdk/panel'); + +const XUL_URL = 'chrome://test/content/new-window.xul' + +const { Services } = Cu.import('resource://gre/modules/Services.jsm', {}); +const { NetUtil } = Cu.import('resource://gre/modules/NetUtil.jsm', {}); + +exports.testChromeSkin = function(assert, done) { + let skinURL = 'chrome://test/skin/style.css'; + + Request({ + url: skinURL, + overrideMimeType: 'text/plain', + onComplete: function (response) { + assert.ok(/test\{\}\s*$/.test(response.text), 'chrome.manifest skin folder was registered!'); + done(); + } + }).get(); + + assert.pass('requesting ' + skinURL); +} + +exports.testChromeContent = function(assert, done) { + let wt = WindowTracker({ + onTrack: function(window) { + if (window.document.documentElement.getAttribute('windowtype') === 'test:window') { + assert.pass('test xul window was opened'); + wt.unload(); + + close(window).then(done, assert.fail); + } + } + }); + + open(XUL_URL).then( + assert.pass.bind(assert, 'opened ' + XUL_URL), + assert.fail); + + assert.pass('opening ' + XUL_URL); +} + +exports.testChromeLocale = function(assert) { + let jpLocalePath = Cc['@mozilla.org/chrome/chrome-registry;1']. + getService(Ci.nsIChromeRegistry). + convertChromeURL(NetUtil.newURI('chrome://test/locale/description.properties')). + spec.replace(/(en\-US|ja\-JP)/, 'ja-JP'); + let enLocalePath = jpLocalePath.replace(/ja\-JP/, 'en-US'); + + let jpStringBundle = Services.strings.createBundle(jpLocalePath); + assert.equal(jpStringBundle.GetStringFromName('test'), + 'テスト', + 'locales ja-JP folder was copied correctly'); + + let enStringBundle = Services.strings.createBundle(enLocalePath); + assert.equal(enStringBundle.GetStringFromName('test'), + 'Test', + 'locales en-US folder was copied correctly'); +} + +exports.testChromeInPanel = function*(assert) { + let panel = Panel({ + contentURL: 'chrome://test/content/panel.html', + contentScriptWhen: 'end', + contentScriptFile: data.url('panel.js') + }); + + yield new Promise(resolve => panel.port.once('start', resolve)); + assert.pass('start was emitted'); + + yield new Promise(resolve => { + panel.once('show', resolve); + panel.show(); + }); + assert.pass('panel shown'); + + yield new Promise(resolve => { + panel.port.once('echo', resolve); + panel.port.emit('echo'); + }); + + assert.pass('got echo'); + + panel.destroy(); + assert.pass('panel is destroyed'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/chrome/package.json b/addon-sdk/source/test/addons/chrome/package.json new file mode 100644 index 000000000..82c6db899 --- /dev/null +++ b/addon-sdk/source/test/addons/chrome/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-chrome@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/content-permissions/httpd.js b/addon-sdk/source/test/addons/content-permissions/httpd.js new file mode 100644 index 000000000..964dc9bbd --- /dev/null +++ b/addon-sdk/source/test/addons/content-permissions/httpd.js @@ -0,0 +1,5211 @@ +/* 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: do not edit this file, this is copied from: +* https://github.com/mozilla/addon-sdk/blob/master/test/lib/httpd.js +*/ + +module.metadata = { + "stability": "experimental" +}; + +const { components, CC, Cc, Ci, Cr, Cu } = require("chrome"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + +const PR_UINT32_MAX = Math.pow(2, 32) - 1; + +/** True if debugging output is enabled, false otherwise. */ +var DEBUG = false; // non-const *only* so tweakable in server tests + +/** True if debugging output should be timestamped. */ +var DEBUG_TIMESTAMP = false; // non-const so tweakable in server tests + +var gGlobalObject = Cc["@mozilla.org/systemprincipal;1"].createInstance(); + +/** +* Asserts that the given condition holds. If it doesn't, the given message is +* dumped, a stack trace is printed, and an exception is thrown to attempt to +* stop execution (which unfortunately must rely upon the exception not being +* accidentally swallowed by the code that uses it). +*/ +function NS_ASSERT(cond, msg) +{ + if (DEBUG && !cond) + { + dumpn("###!!!"); + dumpn("###!!! ASSERTION" + (msg ? ": " + msg : "!")); + dumpn("###!!! Stack follows:"); + + var stack = new Error().stack.split(/\n/); + dumpn(stack.map(function(val) { return "###!!! " + val; }).join("\n")); + + throw Cr.NS_ERROR_ABORT; + } +} + +/** Constructs an HTTP error object. */ +function HttpError(code, description) +{ + this.code = code; + this.description = description; +} +HttpError.prototype = +{ + toString: function() + { + return this.code + " " + this.description; + } +}; + +/** +* Errors thrown to trigger specific HTTP server responses. +*/ +const HTTP_400 = new HttpError(400, "Bad Request"); +const HTTP_401 = new HttpError(401, "Unauthorized"); +const HTTP_402 = new HttpError(402, "Payment Required"); +const HTTP_403 = new HttpError(403, "Forbidden"); +const HTTP_404 = new HttpError(404, "Not Found"); +const HTTP_405 = new HttpError(405, "Method Not Allowed"); +const HTTP_406 = new HttpError(406, "Not Acceptable"); +const HTTP_407 = new HttpError(407, "Proxy Authentication Required"); +const HTTP_408 = new HttpError(408, "Request Timeout"); +const HTTP_409 = new HttpError(409, "Conflict"); +const HTTP_410 = new HttpError(410, "Gone"); +const HTTP_411 = new HttpError(411, "Length Required"); +const HTTP_412 = new HttpError(412, "Precondition Failed"); +const HTTP_413 = new HttpError(413, "Request Entity Too Large"); +const HTTP_414 = new HttpError(414, "Request-URI Too Long"); +const HTTP_415 = new HttpError(415, "Unsupported Media Type"); +const HTTP_417 = new HttpError(417, "Expectation Failed"); + +const HTTP_500 = new HttpError(500, "Internal Server Error"); +const HTTP_501 = new HttpError(501, "Not Implemented"); +const HTTP_502 = new HttpError(502, "Bad Gateway"); +const HTTP_503 = new HttpError(503, "Service Unavailable"); +const HTTP_504 = new HttpError(504, "Gateway Timeout"); +const HTTP_505 = new HttpError(505, "HTTP Version Not Supported"); + +/** Creates a hash with fields corresponding to the values in arr. */ +function array2obj(arr) +{ + var obj = {}; + for (var i = 0; i < arr.length; i++) + obj[arr[i]] = arr[i]; + return obj; +} + +/** Returns an array of the integers x through y, inclusive. */ +function range(x, y) +{ + var arr = []; + for (var i = x; i <= y; i++) + arr.push(i); + return arr; +} + +/** An object (hash) whose fields are the numbers of all HTTP error codes. */ +const HTTP_ERROR_CODES = array2obj(range(400, 417).concat(range(500, 505))); + + +/** +* The character used to distinguish hidden files from non-hidden files, a la +* the leading dot in Apache. Since that mechanism also hides files from +* easy display in LXR, ls output, etc. however, we choose instead to use a +* suffix character. If a requested file ends with it, we append another +* when getting the file on the server. If it doesn't, we just look up that +* file. Therefore, any file whose name ends with exactly one of the character +* is "hidden" and available for use by the server. +*/ +const HIDDEN_CHAR = "^"; + +/** +* The file name suffix indicating the file containing overridden headers for +* a requested file. +*/ +const HEADERS_SUFFIX = HIDDEN_CHAR + "headers" + HIDDEN_CHAR; + +/** Type used to denote SJS scripts for CGI-like functionality. */ +const SJS_TYPE = "sjs"; + +/** Base for relative timestamps produced by dumpn(). */ +var firstStamp = 0; + +/** dump(str) with a trailing "\n" -- only outputs if DEBUG. */ +function dumpn(str) +{ + if (DEBUG) + { + var prefix = "HTTPD-INFO | "; + if (DEBUG_TIMESTAMP) + { + if (firstStamp === 0) + firstStamp = Date.now(); + + var elapsed = Date.now() - firstStamp; // milliseconds + var min = Math.floor(elapsed / 60000); + var sec = (elapsed % 60000) / 1000; + + if (sec < 10) + prefix += min + ":0" + sec.toFixed(3) + " | "; + else + prefix += min + ":" + sec.toFixed(3) + " | "; + } + + dump(prefix + str + "\n"); + } +} + +/** Dumps the current JS stack if DEBUG. */ +function dumpStack() +{ + // peel off the frames for dumpStack() and Error() + var stack = new Error().stack.split(/\n/).slice(2); + stack.forEach(dumpn); +} + + +/** The XPCOM thread manager. */ +var gThreadManager = null; + +/** The XPCOM prefs service. */ +var gRootPrefBranch = null; +function getRootPrefBranch() +{ + if (!gRootPrefBranch) + { + gRootPrefBranch = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefBranch); + } + return gRootPrefBranch; +} + +/** +* JavaScript constructors for commonly-used classes; precreating these is a +* speedup over doing the same from base principles. See the docs at +* http://developer.mozilla.org/en/docs/components.Constructor for details. +*/ +const ServerSocket = CC("@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init"); +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init"); +const Pipe = CC("@mozilla.org/pipe;1", + "nsIPipe", + "init"); +const FileInputStream = CC("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init"); +const ConverterInputStream = CC("@mozilla.org/intl/converter-input-stream;1", + "nsIConverterInputStream", + "init"); +const WritablePropertyBag = CC("@mozilla.org/hash-property-bag;1", + "nsIWritablePropertyBag2"); +const SupportsString = CC("@mozilla.org/supports-string;1", + "nsISupportsString"); + +/* These two are non-const only so a test can overwrite them. */ +var BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream"); +var BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream"); + +/** +* Returns the RFC 822/1123 representation of a date. +* +* @param date : Number +* the date, in milliseconds from midnight (00:00:00), January 1, 1970 GMT +* @returns string +* the representation of the given date +*/ +function toDateString(date) +{ + // + // rfc1123-date = wkday "," SP date1 SP time SP "GMT" + // date1 = 2DIGIT SP month SP 4DIGIT + // ; day month year (e.g., 02 Jun 1982) + // time = 2DIGIT ":" 2DIGIT ":" 2DIGIT + // ; 00:00:00 - 23:59:59 + // wkday = "Mon" | "Tue" | "Wed" + // | "Thu" | "Fri" | "Sat" | "Sun" + // month = "Jan" | "Feb" | "Mar" | "Apr" + // | "May" | "Jun" | "Jul" | "Aug" + // | "Sep" | "Oct" | "Nov" | "Dec" + // + + const wkdayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + /** +* Processes a date and returns the encoded UTC time as a string according to +* the format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toTime(date) + { + var hrs = date.getUTCHours(); + var rv = (hrs < 10) ? "0" + hrs : hrs; + + var mins = date.getUTCMinutes(); + rv += ":"; + rv += (mins < 10) ? "0" + mins : mins; + + var secs = date.getUTCSeconds(); + rv += ":"; + rv += (secs < 10) ? "0" + secs : secs; + + return rv; + } + + /** +* Processes a date and returns the encoded UTC date as a string according to +* the date1 format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toDate1(date) + { + var day = date.getUTCDate(); + var month = date.getUTCMonth(); + var year = date.getUTCFullYear(); + + var rv = (day < 10) ? "0" + day : day; + rv += " " + monthStrings[month]; + rv += " " + year; + + return rv; + } + + date = new Date(date); + + const fmtString = "%wkday%, %date1% %time% GMT"; + var rv = fmtString.replace("%wkday%", wkdayStrings[date.getUTCDay()]); + rv = rv.replace("%time%", toTime(date)); + return rv.replace("%date1%", toDate1(date)); +} + +/** +* Prints out a human-readable representation of the object o and its fields, +* omitting those whose names begin with "_" if showMembers != true (to ignore +* "private" properties exposed via getters/setters). +*/ +function printObj(o, showMembers) +{ + var s = "******************************\n"; + s += "o = {\n"; + for (var i in o) + { + if (typeof(i) != "string" || + (showMembers || (i.length > 0 && i[0] != "_"))) + s+= " " + i + ": " + o[i] + ",\n"; + } + s += " };\n"; + s += "******************************"; + dumpn(s); +} + +/** +* Instantiates a new HTTP server. +*/ +function nsHttpServer() +{ + if (!gThreadManager) + gThreadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + + /** The port on which this server listens. */ + this._port = undefined; + + /** The socket associated with this. */ + this._socket = null; + + /** The handler used to process requests to this server. */ + this._handler = new ServerHandler(this); + + /** Naming information for this server. */ + this._identity = new ServerIdentity(); + + /** +* Indicates when the server is to be shut down at the end of the request. +*/ + this._doQuit = false; + + /** +* True if the socket in this is closed (and closure notifications have been +* sent and processed if the socket was ever opened), false otherwise. +*/ + this._socketClosed = true; + + /** +* Used for tracking existing connections and ensuring that all connections +* are properly cleaned up before server shutdown; increases by 1 for every +* new incoming connection. +*/ + this._connectionGen = 0; + + /** +* Hash of all open connections, indexed by connection number at time of +* creation. +*/ + this._connections = {}; +} +nsHttpServer.prototype = +{ + classID: components.ID("{54ef6f81-30af-4b1d-ac55-8ba811293e41}"), + + // NSISERVERSOCKETLISTENER + + /** +* Processes an incoming request coming in on the given socket and contained +* in the given transport. +* +* @param socket : nsIServerSocket +* the socket through which the request was served +* @param trans : nsISocketTransport +* the transport for the request/response +* @see nsIServerSocketListener.onSocketAccepted +*/ + onSocketAccepted: function(socket, trans) + { + dumpn("*** onSocketAccepted(socket=" + socket + ", trans=" + trans + ")"); + + dumpn(">>> new connection on " + trans.host + ":" + trans.port); + + const SEGMENT_SIZE = 8192; + const SEGMENT_COUNT = 1024; + try + { + var input = trans.openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT) + .QueryInterface(Ci.nsIAsyncInputStream); + var output = trans.openOutputStream(0, 0, 0); + } + catch (e) + { + dumpn("*** error opening transport streams: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + var connectionNumber = ++this._connectionGen; + + try + { + var conn = new Connection(input, output, this, socket.port, trans.port, + connectionNumber); + var reader = new RequestReader(conn); + + // XXX add request timeout functionality here! + + // Note: must use main thread here, or we might get a GC that will cause + // threadsafety assertions. We really need to fix XPConnect so that + // you can actually do things in multi-threaded JS. :-( + input.asyncWait(reader, 0, 0, gThreadManager.mainThread); + } + catch (e) + { + // Assume this connection can't be salvaged and bail on it completely; + // don't attempt to close it so that we can assert that any connection + // being closed is in this._connections. + dumpn("*** error in initial request-processing stages: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + this._connections[connectionNumber] = conn; + dumpn("*** starting connection " + connectionNumber); + }, + + /** +* Called when the socket associated with this is closed. +* +* @param socket : nsIServerSocket +* the socket being closed +* @param status : nsresult +* the reason the socket stopped listening (NS_BINDING_ABORTED if the server +* was stopped using nsIHttpServer.stop) +* @see nsIServerSocketListener.onStopListening +*/ + onStopListening: function(socket, status) + { + dumpn(">>> shutting down server on port " + socket.port); + this._socketClosed = true; + if (!this._hasOpenConnections()) + { + dumpn("*** no open connections, notifying async from onStopListening"); + + // Notify asynchronously so that any pending teardown in stop() has a + // chance to run first. + var self = this; + var stopEvent = + { + run: function() + { + dumpn("*** _notifyStopped async callback"); + self._notifyStopped(); + } + }; + gThreadManager.currentThread + .dispatch(stopEvent, Ci.nsIThread.DISPATCH_NORMAL); + } + }, + + // NSIHTTPSERVER + + // + // see nsIHttpServer.start + // + start: function(port) + { + this._start(port, "localhost") + }, + + _start: function(port, host) + { + if (this._socket) + throw Cr.NS_ERROR_ALREADY_INITIALIZED; + + this._port = port; + this._doQuit = this._socketClosed = false; + + this._host = host; + + // The listen queue needs to be long enough to handle + // network.http.max-persistent-connections-per-server concurrent connections, + // plus a safety margin in case some other process is talking to + // the server as well. + var prefs = getRootPrefBranch(); + var maxConnections; + try { + // Bug 776860: The original pref was removed in favor of this new one: + maxConnections = prefs.getIntPref("network.http.max-persistent-connections-per-server") + 5; + } + catch(e) { + maxConnections = prefs.getIntPref("network.http.max-connections-per-server") + 5; + } + + try + { + var loopback = true; + if (this._host != "127.0.0.1" && this._host != "localhost") { + var loopback = false; + } + + var socket = new ServerSocket(this._port, + loopback, // true = localhost, false = everybody + maxConnections); + dumpn(">>> listening on port " + socket.port + ", " + maxConnections + + " pending connections"); + socket.asyncListen(this); + this._identity._initialize(socket.port, host, true); + this._socket = socket; + } + catch (e) + { + dumpn("!!! could not start server on port " + port + ": " + e); + throw Cr.NS_ERROR_NOT_AVAILABLE; + } + }, + + // + // see nsIHttpServer.stop + // + stop: function(callback) + { + if (!callback) + throw Cr.NS_ERROR_NULL_POINTER; + if (!this._socket) + throw Cr.NS_ERROR_UNEXPECTED; + + this._stopCallback = typeof callback === "function" + ? callback + : function() { callback.onStopped(); }; + + dumpn(">>> stopping listening on port " + this._socket.port); + this._socket.close(); + this._socket = null; + + // We can't have this identity any more, and the port on which we're running + // this server now could be meaningless the next time around. + this._identity._teardown(); + + this._doQuit = false; + + // socket-close notification and pending request completion happen async + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (file && (!file.exists() || file.isDirectory())) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handler.registerFile(path, file); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // XXX true path validation! + if (path.charAt(0) != "/" || + path.charAt(path.length - 1) != "/" || + (directory && + (!directory.exists() || !directory.isDirectory()))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX determine behavior of nonexistent /foo/bar when a /foo/bar/ mapping + // exists! + + this._handler.registerDirectory(path, directory); + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + this._handler.registerPathHandler(path, handler); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + this._handler.registerPrefixHandler(prefix, handler); + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(code, handler) + { + this._handler.registerErrorHandler(code, handler); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + this._handler.setIndexHandler(handler); + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + this._handler.registerContentType(ext, type); + }, + + // + // see nsIHttpServer.serverIdentity + // + get identity() + { + return this._identity; + }, + + // + // see nsIHttpServer.getState + // + getState: function(path, k) + { + return this._handler._getState(path, k); + }, + + // + // see nsIHttpServer.setState + // + setState: function(path, k, v) + { + return this._handler._setState(path, k, v); + }, + + // + // see nsIHttpServer.getSharedState + // + getSharedState: function(k) + { + return this._handler._getSharedState(k); + }, + + // + // see nsIHttpServer.setSharedState + // + setSharedState: function(k, v) + { + return this._handler._setSharedState(k, v); + }, + + // + // see nsIHttpServer.getObjectState + // + getObjectState: function(k) + { + return this._handler._getObjectState(k); + }, + + // + // see nsIHttpServer.setObjectState + // + setObjectState: function(k, v) + { + return this._handler._setObjectState(k, v); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIServerSocketListener) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NON-XPCOM PUBLIC API + + /** +* Returns true iff this server is not running (and is not in the process of +* serving any requests still to be processed when the server was last +* stopped after being run). +*/ + isStopped: function() + { + return this._socketClosed && !this._hasOpenConnections(); + }, + + // PRIVATE IMPLEMENTATION + + /** True if this server has any open connections to it, false otherwise. */ + _hasOpenConnections: function() + { + // + // If we have any open connections, they're tracked as numeric properties on + // |this._connections|. The non-standard __count__ property could be used + // to check whether there are any properties, but standard-wise, even + // looking forward to ES5, there's no less ugly yet still O(1) way to do + // this. + // + for (var n in this._connections) + return true; + return false; + }, + + /** Calls the server-stopped callback provided when stop() was called. */ + _notifyStopped: function() + { + NS_ASSERT(this._stopCallback !== null, "double-notifying?"); + NS_ASSERT(!this._hasOpenConnections(), "should be done serving by now"); + + // + // NB: We have to grab this now, null out the member, *then* call the + // callback here, or otherwise the callback could (indirectly) futz with + // this._stopCallback by starting and immediately stopping this, at + // which point we'd be nulling out a field we no longer have a right to + // modify. + // + var callback = this._stopCallback; + this._stopCallback = null; + try + { + callback(); + } + catch (e) + { + // not throwing because this is specified as being usually (but not + // always) asynchronous + dump("!!! error running onStopped callback: " + e + "\n"); + } + }, + + /** +* Notifies this server that the given connection has been closed. +* +* @param connection : Connection +* the connection that was closed +*/ + _connectionClosed: function(connection) + { + NS_ASSERT(connection.number in this._connections, + "closing a connection " + this + " that we never added to the " + + "set of open connections?"); + NS_ASSERT(this._connections[connection.number] === connection, + "connection number mismatch? " + + this._connections[connection.number]); + delete this._connections[connection.number]; + + // Fire a pending server-stopped notification if it's our responsibility. + if (!this._hasOpenConnections() && this._socketClosed) + this._notifyStopped(); + }, + + /** +* Requests that the server be shut down when possible. +*/ + _requestQuit: function() + { + dumpn(">>> requesting a quit"); + dumpStack(); + this._doQuit = true; + } +}; + + +// +// RFC 2396 section 3.2.2: +// +// host = hostname | IPv4address +// hostname = *( domainlabel "." ) toplabel [ "." ] +// domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum +// toplabel = alpha | alpha *( alphanum | "-" ) alphanum +// IPv4address = 1*digit "." 1*digit "." 1*digit "." 1*digit +// + +const HOST_REGEX = + new RegExp("^(?:" + + // *( domainlabel "." ) + "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*" + + // toplabel + "[a-z](?:[a-z0-9-]*[a-z0-9])?" + + "|" + + // IPv4 address + "\\d+\\.\\d+\\.\\d+\\.\\d+" + + ")$", + "i"); + + +/** +* Represents the identity of a server. An identity consists of a set of +* (scheme, host, port) tuples denoted as locations (allowing a single server to +* serve multiple sites or to be used behind both HTTP and HTTPS proxies for any +* host/port). Any incoming request must be to one of these locations, or it +* will be rejected with an HTTP 400 error. One location, denoted as the +* primary location, is the location assigned in contexts where a location +* cannot otherwise be endogenously derived, such as for HTTP/1.0 requests. +* +* A single identity may contain at most one location per unique host/port pair; +* other than that, no restrictions are placed upon what locations may +* constitute an identity. +*/ +function ServerIdentity() +{ + /** The scheme of the primary location. */ + this._primaryScheme = "http"; + + /** The hostname of the primary location. */ + this._primaryHost = "127.0.0.1" + + /** The port number of the primary location. */ + this._primaryPort = -1; + + /** +* The current port number for the corresponding server, stored so that a new +* primary location can always be set if the current one is removed. +*/ + this._defaultPort = -1; + + /** +* Maps hosts to maps of ports to schemes, e.g. the following would represent +* https://example.com:789/ and http://example.org/: +* +* { +* "xexample.com": { 789: "https" }, +* "xexample.org": { 80: "http" } +* } +* +* Note the "x" prefix on hostnames, which prevents collisions with special +* JS names like "prototype". +*/ + this._locations = { "xlocalhost": {} }; +} +ServerIdentity.prototype = +{ + // NSIHTTPSERVERIDENTITY + + // + // see nsIHttpServerIdentity.primaryScheme + // + get primaryScheme() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryScheme; + }, + + // + // see nsIHttpServerIdentity.primaryHost + // + get primaryHost() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryHost; + }, + + // + // see nsIHttpServerIdentity.primaryPort + // + get primaryPort() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryPort; + }, + + // + // see nsIHttpServerIdentity.add + // + add: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + this._locations["x" + host] = entry = {}; + + entry[port] = scheme; + }, + + // + // see nsIHttpServerIdentity.remove + // + remove: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return false; + + var present = port in entry; + delete entry[port]; + + if (this._primaryScheme == scheme && + this._primaryHost == host && + this._primaryPort == port && + this._defaultPort !== -1) + { + // Always keep at least one identity in existence at any time, unless + // we're in the process of shutting down (the last condition above). + this._primaryPort = -1; + this._initialize(this._defaultPort, host, false); + } + + return present; + }, + + // + // see nsIHttpServerIdentity.has + // + has: function(scheme, host, port) + { + this._validate(scheme, host, port); + + return "x" + host in this._locations && + scheme === this._locations["x" + host][port]; + }, + + // + // see nsIHttpServerIdentity.has + // + getScheme: function(host, port) + { + this._validate("http", host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return ""; + + return entry[port] || ""; + }, + + // + // see nsIHttpServerIdentity.setPrimary + // + setPrimary: function(scheme, host, port) + { + this._validate(scheme, host, port); + + this.add(scheme, host, port); + + this._primaryScheme = scheme; + this._primaryHost = host; + this._primaryPort = port; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpServerIdentity) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Initializes the primary name for the corresponding server, based on the +* provided port number. +*/ + _initialize: function(port, host, addSecondaryDefault) + { + this._host = host; + if (this._primaryPort !== -1) + this.add("http", host, port); + else + this.setPrimary("http", "localhost", port); + this._defaultPort = port; + + // Only add this if we're being called at server startup + if (addSecondaryDefault && host != "127.0.0.1") + this.add("http", "127.0.0.1", port); + }, + + /** +* Called at server shutdown time, unsets the primary location only if it was +* the default-assigned location and removes the default location from the +* set of locations used. +*/ + _teardown: function() + { + if (this._host != "127.0.0.1") { + // Not the default primary location, nothing special to do here + this.remove("http", "127.0.0.1", this._defaultPort); + } + + // This is a *very* tricky bit of reasoning here; make absolutely sure the + // tests for this code pass before you commit changes to it. + if (this._primaryScheme == "http" && + this._primaryHost == this._host && + this._primaryPort == this._defaultPort) + { + // Make sure we don't trigger the readding logic in .remove(), then remove + // the default location. + var port = this._defaultPort; + this._defaultPort = -1; + this.remove("http", this._host, port); + + // Ensure a server start triggers the setPrimary() path in ._initialize() + this._primaryPort = -1; + } + else + { + // No reason not to remove directly as it's not our primary location + this.remove("http", this._host, this._defaultPort); + } + }, + + /** +* Ensures scheme, host, and port are all valid with respect to RFC 2396. +* +* @throws NS_ERROR_ILLEGAL_VALUE +* if any argument doesn't match the corresponding production +*/ + _validate: function(scheme, host, port) + { + if (scheme !== "http" && scheme !== "https") + { + dumpn("*** server only supports http/https schemes: '" + scheme + "'"); + dumpStack(); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (!HOST_REGEX.test(host)) + { + dumpn("*** unexpected host: '" + host + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (port < 0 || port > 65535) + { + dumpn("*** unexpected port: '" + port + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + } +}; + + +/** +* Represents a connection to the server (and possibly in the future the thread +* on which the connection is processed). +* +* @param input : nsIInputStream +* stream from which incoming data on the connection is read +* @param output : nsIOutputStream +* stream to write data out the connection +* @param server : nsHttpServer +* the server handling the connection +* @param port : int +* the port on which the server is running +* @param outgoingPort : int +* the outgoing port used by this connection +* @param number : uint +* a serial number used to uniquely identify this connection +*/ +function Connection(input, output, server, port, outgoingPort, number) +{ + dumpn("*** opening new connection " + number + " on port " + outgoingPort); + + /** Stream of incoming data. */ + this.input = input; + + /** Stream for outgoing data. */ + this.output = output; + + /** The server associated with this request. */ + this.server = server; + + /** The port on which the server is running. */ + this.port = port; + + /** The outgoing poort used by this connection. */ + this._outgoingPort = outgoingPort; + + /** The serial number of this connection. */ + this.number = number; + + /** +* The request for which a response is being generated, null if the +* incoming request has not been fully received or if it had errors. +*/ + this.request = null; + + /** State variables for debugging. */ + this._closed = this._processed = false; +} +Connection.prototype = +{ + /** Closes this connection's input/output streams. */ + close: function() + { + dumpn("*** closing connection " + this.number + + " on port " + this._outgoingPort); + + this.input.close(); + this.output.close(); + this._closed = true; + + var server = this.server; + server._connectionClosed(this); + + // If an error triggered a server shutdown, act on it now + if (server._doQuit) + server.stop(function() { /* not like we can do anything better */ }); + }, + + /** +* Initiates processing of this connection, using the data in the given +* request. +* +* @param request : Request +* the request which should be processed +*/ + process: function(request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + + this.request = request; + this.server._handler.handleResponse(this); + }, + + /** +* Initiates processing of this connection, generating a response with the +* given HTTP error code. +* +* @param code : uint +* an HTTP code, so in the range [0, 1000) +* @param request : Request +* incomplete data about the incoming request (since there were errors +* during its processing +*/ + processError: function(code, request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + this.request = request; + this.server._handler.handleError(code, this); + }, + + /** Converts this to a string for debugging purposes. */ + toString: function() + { + return "<Connection(" + this.number + + (this.request ? ", " + this.request.path : "") +"): " + + (this._closed ? "closed" : "open") + ">"; + } +}; + + + +/** Returns an array of count bytes from the given input stream. */ +function readBytes(inputStream, count) +{ + return new BinaryInputStream(inputStream).readByteArray(count); +} + + + +/** Request reader processing states; see RequestReader for details. */ +const READER_IN_REQUEST_LINE = 0; +const READER_IN_HEADERS = 1; +const READER_IN_BODY = 2; +const READER_FINISHED = 3; + + +/** +* Reads incoming request data asynchronously, does any necessary preprocessing, +* and forwards it to the request handler. Processing occurs in three states: +* +* READER_IN_REQUEST_LINE Reading the request's status line +* READER_IN_HEADERS Reading headers in the request +* READER_IN_BODY Reading the body of the request +* READER_FINISHED Entire request has been read and processed +* +* During the first two stages, initial metadata about the request is gathered +* into a Request object. Once the status line and headers have been processed, +* we start processing the body of the request into the Request. Finally, when +* the entire body has been read, we create a Response and hand it off to the +* ServerHandler to be given to the appropriate request handler. +* +* @param connection : Connection +* the connection for the request being read +*/ +function RequestReader(connection) +{ + /** Connection metadata for this request. */ + this._connection = connection; + + /** +* A container providing line-by-line access to the raw bytes that make up the +* data which has been read from the connection but has not yet been acted +* upon (by passing it to the request handler or by extracting request +* metadata from it). +*/ + this._data = new LineData(); + + /** +* The amount of data remaining to be read from the body of this request. +* After all headers in the request have been read this is the value in the +* Content-Length header, but as the body is read its value decreases to zero. +*/ + this._contentLength = 0; + + /** The current state of parsing the incoming request. */ + this._state = READER_IN_REQUEST_LINE; + + /** Metadata constructed from the incoming request for the request handler. */ + this._metadata = new Request(connection.port); + + /** +* Used to preserve state if we run out of line data midway through a +* multi-line header. _lastHeaderName stores the name of the header, while +* _lastHeaderValue stores the value we've seen so far for the header. +* +* These fields are always either both undefined or both strings. +*/ + this._lastHeaderName = this._lastHeaderValue = undefined; +} +RequestReader.prototype = +{ + // NSIINPUTSTREAMCALLBACK + + /** +* Called when more data from the incoming request is available. This method +* then reads the available data from input and deals with that data as +* necessary, depending upon the syntax of already-downloaded data. +* +* @param input : nsIAsyncInputStream +* the stream of incoming data from the connection +*/ + onInputStreamReady: function(input) + { + dumpn("*** onInputStreamReady(input=" + input + ") on thread " + + gThreadManager.currentThread + " (main is " + + gThreadManager.mainThread + ")"); + dumpn("*** this._state == " + this._state); + + // Handle cases where we get more data after a request error has been + // discovered but *before* we can close the connection. + var data = this._data; + if (!data) + return; + + try + { + data.appendBytes(readBytes(input, input.available())); + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** WARNING: unexpected error when reading from socket; will " + + "be treated as if the input stream had been closed"); + dumpn("*** WARNING: actual error was: " + e); + } + + // We've lost a race -- input has been closed, but we're still expecting + // to read more data. available() will throw in this case, and since + // we're dead in the water now, destroy the connection. + dumpn("*** onInputStreamReady called on a closed input, destroying " + + "connection"); + this._connection.close(); + return; + } + + switch (this._state) + { + default: + NS_ASSERT(false, "invalid state: " + this._state); + break; + + case READER_IN_REQUEST_LINE: + if (!this._processRequestLine()) + break; + /* fall through */ + + case READER_IN_HEADERS: + if (!this._processHeaders()) + break; + /* fall through */ + + case READER_IN_BODY: + this._processBody(); + } + + if (this._state != READER_FINISHED) + input.asyncWait(this, 0, 0, gThreadManager.currentThread); + }, + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIInputStreamCallback) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE API + + /** +* Processes unprocessed, downloaded data as a request line. +* +* @returns boolean +* true iff the request line has been fully processed +*/ + _processRequestLine: function() + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + // Servers SHOULD ignore any empty line(s) received where a Request-Line + // is expected (section 4.1). + var data = this._data; + var line = {}; + var readSuccess; + while ((readSuccess = data.readLine(line)) && line.value == "") + dumpn("*** ignoring beginning blank line..."); + + // if we don't have a full line, wait until we do + if (!readSuccess) + return false; + + // we have the first non-blank line + try + { + this._parseRequestLine(line.value); + this._state = READER_IN_HEADERS; + return true; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing request headers. +* +* @returns boolean +* true iff header data in the request has been fully processed +*/ + _processHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + // XXX things to fix here: + // + // - need to support RFC 2047-encoded non-US-ASCII characters + + try + { + var done = this._parseHeaders(); + if (done) + { + var request = this._metadata; + + // XXX this is wrong for requests with transfer-encodings applied to + // them, particularly chunked (which by its nature can have no + // meaningful Content-Length header)! + this._contentLength = request.hasHeader("Content-Length") + ? parseInt(request.getHeader("Content-Length"), 10) + : 0; + dumpn("_processHeaders, Content-length=" + this._contentLength); + + this._state = READER_IN_BODY; + } + return done; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing the request body. +* +* @returns boolean +* true iff the request body has been fully processed +*/ + _processBody: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + // XXX handle chunked transfer-coding request bodies! + + try + { + if (this._contentLength > 0) + { + var data = this._data.purge(); + var count = Math.min(data.length, this._contentLength); + dumpn("*** loading data=" + data + " len=" + data.length + + " excess=" + (data.length - count)); + + var bos = new BinaryOutputStream(this._metadata._bodyOutputStream); + bos.writeByteArray(data, count); + this._contentLength -= count; + } + + dumpn("*** remaining body data len=" + this._contentLength); + if (this._contentLength == 0) + { + this._validateRequest(); + this._state = READER_FINISHED; + this._handleResponse(); + return true; + } + + return false; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Does various post-header checks on the data in this request. +* +* @throws : HttpError +* if the request was malformed in some way +*/ + _validateRequest: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + dumpn("*** _validateRequest"); + + var metadata = this._metadata; + var headers = metadata._headers; + + // 19.6.1.1 -- servers MUST report 400 to HTTP/1.1 requests w/o Host header + var identity = this._connection.server.identity; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + { + if (!headers.hasHeader("Host")) + { + dumpn("*** malformed HTTP/1.1 or greater request with no Host header!"); + throw HTTP_400; + } + + // If the Request-URI wasn't absolute, then we need to determine our host. + // We have to determine what scheme was used to access us based on the + // server identity data at this point, because the request just doesn't + // contain enough data on its own to do this, sadly. + if (!metadata._host) + { + var host, port; + var hostPort = headers.getHeader("Host"); + var colon = hostPort.indexOf(":"); + if (colon < 0) + { + host = hostPort; + port = ""; + } + else + { + host = hostPort.substring(0, colon); + port = hostPort.substring(colon + 1); + } + + // NB: We allow an empty port here because, oddly, a colon may be + // present even without a port number, e.g. "example.com:"; in this + // case the default port applies. + if (!HOST_REGEX.test(host) || !/^\d*$/.test(port)) + { + dumpn("*** malformed hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + // If we're not given a port, we're stuck, because we don't know what + // scheme to use to look up the correct port here, in general. Since + // the HTTPS case requires a tunnel/proxy and thus requires that the + // requested URI be absolute (and thus contain the necessary + // information), let's assume HTTP will prevail and use that. + port = +port || 80; + + var scheme = identity.getScheme(host, port); + if (!scheme) + { + dumpn("*** unrecognized hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + } + } + else + { + NS_ASSERT(metadata._host === undefined, + "HTTP/1.0 doesn't allow absolute paths in the request line!"); + + metadata._scheme = identity.primaryScheme; + metadata._host = identity.primaryHost; + metadata._port = identity.primaryPort; + } + + NS_ASSERT(identity.has(metadata._scheme, metadata._host, metadata._port), + "must have a location we recognize by now!"); + }, + + /** +* Handles responses in case of error, either in the server or in the request. +* +* @param e +* the specific error encountered, which is an HttpError in the case where +* the request is in some way invalid or cannot be fulfilled; if this isn't +* an HttpError we're going to be paranoid and shut down, because that +* shouldn't happen, ever +*/ + _handleError: function(e) + { + // Don't fall back into normal processing! + this._state = READER_FINISHED; + + var server = this._connection.server; + if (e instanceof HttpError) + { + var code = e.code; + } + else + { + dumpn("!!! UNEXPECTED ERROR: " + e + + (e.lineNumber ? ", line " + e.lineNumber : "")); + + // no idea what happened -- be paranoid and shut down + code = 500; + server._requestQuit(); + } + + // make attempted reuse of data an error + this._data = null; + + this._connection.processError(code, this._metadata); + }, + + /** +* Now that we've read the request line and headers, we can actually hand off +* the request to be handled. +* +* This method is called once per request, after the request line and all +* headers and the body, if any, have been received. +*/ + _handleResponse: function() + { + NS_ASSERT(this._state == READER_FINISHED); + + // We don't need the line-based data any more, so make attempted reuse an + // error. + this._data = null; + + this._connection.process(this._metadata); + }, + + + // PARSING + + /** +* Parses the request line for the HTTP request associated with this. +* +* @param line : string +* the request line +*/ + _parseRequestLine: function(line) + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + dumpn("*** _parseRequestLine('" + line + "')"); + + var metadata = this._metadata; + + // clients and servers SHOULD accept any amount of SP or HT characters + // between fields, even though only a single SP is required (section 19.3) + var request = line.split(/[ \t]+/); + if (!request || request.length != 3) + throw HTTP_400; + + metadata._method = request[0]; + + // get the HTTP version + var ver = request[2]; + var match = ver.match(/^HTTP\/(\d+\.\d+)$/); + if (!match) + throw HTTP_400; + + // determine HTTP version + try + { + metadata._httpVersion = new nsHttpVersion(match[1]); + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_0)) + throw "unsupported HTTP version"; + } + catch (e) + { + // we support HTTP/1.0 and HTTP/1.1 only + throw HTTP_501; + } + + + var fullPath = request[1]; + var serverIdentity = this._connection.server.identity; + + var scheme, host, port; + + if (fullPath.charAt(0) != "/") + { + // No absolute paths in the request line in HTTP prior to 1.1 + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + throw HTTP_400; + + try + { + var uri = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService) + .newURI(fullPath, null, null); + fullPath = uri.path; + scheme = uri.scheme; + host = metadata._host = uri.asciiHost; + port = uri.port; + if (port === -1) + { + if (scheme === "http") + port = 80; + else if (scheme === "https") + port = 443; + else + throw HTTP_400; + } + } + catch (e) + { + // If the host is not a valid host on the server, the response MUST be a + // 400 (Bad Request) error message (section 5.2). Alternately, the URI + // is malformed. + throw HTTP_400; + } + + if (!serverIdentity.has(scheme, host, port) || fullPath.charAt(0) != "/") + throw HTTP_400; + } + + var splitter = fullPath.indexOf("?"); + if (splitter < 0) + { + // _queryString already set in ctor + metadata._path = fullPath; + } + else + { + metadata._path = fullPath.substring(0, splitter); + metadata._queryString = fullPath.substring(splitter + 1); + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + }, + + /** +* Parses all available HTTP headers in this until the header-ending CRLFCRLF, +* adding them to the store of headers in the request. +* +* @throws +* HTTP_400 if the headers are malformed +* @returns boolean +* true if all headers have now been processed, false otherwise +*/ + _parseHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + dumpn("*** _parseHeaders"); + + var data = this._data; + + var headers = this._metadata._headers; + var lastName = this._lastHeaderName; + var lastVal = this._lastHeaderValue; + + var line = {}; + while (true) + { + NS_ASSERT(!((lastVal === undefined) ^ (lastName === undefined)), + lastName === undefined ? + "lastVal without lastName? lastVal: '" + lastVal + "'" : + "lastName without lastVal? lastName: '" + lastName + "'"); + + if (!data.readLine(line)) + { + // save any data we have from the header we might still be processing + this._lastHeaderName = lastName; + this._lastHeaderValue = lastVal; + return false; + } + + var lineText = line.value; + var firstChar = lineText.charAt(0); + + // blank line means end of headers + if (lineText == "") + { + // we're finished with the previous header + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + else + { + // no headers in request -- valid for HTTP/1.0 requests + } + + // either way, we're done processing headers + this._state = READER_IN_BODY; + return true; + } + else if (firstChar == " " || firstChar == "\t") + { + // multi-line header if we've already seen a header line + if (!lastName) + { + // we don't have a header to continue! + throw HTTP_400; + } + + // append this line's text to the value; starts with SP/HT, so no need + // for separating whitespace + lastVal += lineText; + } + else + { + // we have a new header, so set the old one (if one existed) + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + + var colon = lineText.indexOf(":"); // first colon must be splitter + if (colon < 1) + { + // no colon or missing header field-name + throw HTTP_400; + } + + // set header name, value (to be set in the next loop, usually) + lastName = lineText.substring(0, colon); + lastVal = lineText.substring(colon + 1); + } // empty, continuation, start of header + } // while (true) + } +}; + + +/** The character codes for CR and LF. */ +const CR = 0x0D, LF = 0x0A; + +/** +* Calculates the number of characters before the first CRLF pair in array, or +* -1 if the array contains no CRLF pair. +* +* @param array : Array +* an array of numbers in the range [0, 256), each representing a single +* character; the first CRLF is the lowest index i where +* |array[i] == "\r".charCodeAt(0)| and |array[i+1] == "\n".charCodeAt(0)|, +* if such an |i| exists, and -1 otherwise +* @returns int +* the index of the first CRLF if any were present, -1 otherwise +*/ +function findCRLF(array) +{ + for (var i = array.indexOf(CR); i >= 0; i = array.indexOf(CR, i + 1)) + { + if (array[i + 1] == LF) + return i; + } + return -1; +} + + +/** +* A container which provides line-by-line access to the arrays of bytes with +* which it is seeded. +*/ +function LineData() +{ + /** An array of queued bytes from which to get line-based characters. */ + this._data = []; +} +LineData.prototype = +{ + /** +* Appends the bytes in the given array to the internal data cache maintained +* by this. +*/ + appendBytes: function(bytes) + { + Array.prototype.push.apply(this._data, bytes); + }, + + /** +* Removes and returns a line of data, delimited by CRLF, from this. +* +* @param out +* an object whose "value" property will be set to the first line of text +* present in this, sans CRLF, if this contains a full CRLF-delimited line +* of text; if this doesn't contain enough data, the value of the property +* is undefined +* @returns boolean +* true if a full line of data could be read from the data in this, false +* otherwise +*/ + readLine: function(out) + { + var data = this._data; + var length = findCRLF(data); + if (length < 0) + return false; + + // + // We have the index of the CR, so remove all the characters, including + // CRLF, from the array with splice, and convert the removed array into the + // corresponding string, from which we then strip the trailing CRLF. + // + // Getting the line in this matter acknowledges that substring is an O(1) + // operation in SpiderMonkey because strings are immutable, whereas two + // splices, both from the beginning of the data, are less likely to be as + // cheap as a single splice plus two extra character conversions. + // + var line = String.fromCharCode.apply(null, data.splice(0, length + 2)); + out.value = line.substring(0, length); + + return true; + }, + + /** +* Removes the bytes currently within this and returns them in an array. +* +* @returns Array +* the bytes within this when this method is called +*/ + purge: function() + { + var data = this._data; + this._data = []; + return data; + } +}; + + + +/** +* Creates a request-handling function for an nsIHttpRequestHandler object. +*/ +function createHandlerFunc(handler) +{ + return function(metadata, response) { handler.handle(metadata, response); }; +} + + +/** +* The default handler for directories; writes an HTML response containing a +* slightly-formatted directory listing. +*/ +function defaultIndexHandler(metadata, response) +{ + response.setHeader("Content-Type", "text/html", false); + + var path = htmlEscape(decodeURI(metadata.path)); + + // + // Just do a very basic bit of directory listings -- no need for too much + // fanciness, especially since we don't have a style sheet in which we can + // stick rules (don't want to pollute the default path-space). + // + + var body = '<html>\ +<head>\ +<title>' + path + '</title>\ +</head>\ +<body>\ +<h1>' + path + '</h1>\ +<ol style="list-style-type: none">'; + + var directory = metadata.getProperty("directory").QueryInterface(Ci.nsILocalFile); + NS_ASSERT(directory && directory.isDirectory()); + + var fileList = []; + var files = directory.directoryEntries; + while (files.hasMoreElements()) + { + var f = files.getNext().QueryInterface(Ci.nsIFile); + var name = f.leafName; + if (!f.isHidden() && + (name.charAt(name.length - 1) != HIDDEN_CHAR || + name.charAt(name.length - 2) == HIDDEN_CHAR)) + fileList.push(f); + } + + fileList.sort(fileSort); + + for (var i = 0; i < fileList.length; i++) + { + var file = fileList[i]; + try + { + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + var sep = file.isDirectory() ? "/" : ""; + + // Note: using " to delimit the attribute here because encodeURIComponent + // passes through '. + var item = '<li><a href="' + encodeURIComponent(name) + sep + '">' + + htmlEscape(name) + sep + + '</a></li>'; + + body += item; + } + catch (e) { /* some file system error, ignore the file */ } + } + + body += ' </ol>\ +</body>\ +</html>'; + + response.bodyOutputStream.write(body, body.length); +} + +/** +* Sorts a and b (nsIFile objects) into an aesthetically pleasing order. +*/ +function fileSort(a, b) +{ + var dira = a.isDirectory(), dirb = b.isDirectory(); + + if (dira && !dirb) + return -1; + if (dirb && !dira) + return 1; + + var namea = a.leafName.toLowerCase(), nameb = b.leafName.toLowerCase(); + return nameb > namea ? -1 : 1; +} + + +/** +* Converts an externally-provided path into an internal path for use in +* determining file mappings. +* +* @param path +* the path to convert +* @param encoded +* true if the given path should be passed through decodeURI prior to +* conversion +* @throws URIError +* if path is incorrectly encoded +*/ +function toInternalPath(path, encoded) +{ + if (encoded) + path = decodeURI(path); + + var comps = path.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + if (comp.charAt(comp.length - 1) == HIDDEN_CHAR) + comps[i] = comp + HIDDEN_CHAR; + } + return comps.join("/"); +} + + +/** +* Adds custom-specified headers for the given file to the given response, if +* any such headers are specified. +* +* @param file +* the file on the disk which is to be written +* @param metadata +* metadata about the incoming request +* @param response +* the Response to which any specified headers/data should be written +* @throws HTTP_500 +* if an error occurred while processing custom-specified headers +*/ +function maybeAddHeaders(file, metadata, response) +{ + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + + var headerFile = file.parent; + headerFile.append(name + HEADERS_SUFFIX); + + if (!headerFile.exists()) + return; + + const PR_RDONLY = 0x01; + var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0); + lis.QueryInterface(Ci.nsIUnicharLineInputStream); + + var line = {value: ""}; + var more = lis.readLine(line); + + if (!more && line.value == "") + return; + + + // request line + + var status = line.value; + if (status.indexOf("HTTP ") == 0) + { + status = status.substring(5); + var space = status.indexOf(" "); + var code, description; + if (space < 0) + { + code = status; + description = ""; + } + else + { + code = status.substring(0, space); + description = status.substring(space + 1, status.length); + } + + response.setStatusLine(metadata.httpVersion, parseInt(code, 10), description); + + line.value = ""; + more = lis.readLine(line); + } + + // headers + while (more || line.value != "") + { + var header = line.value; + var colon = header.indexOf(":"); + + response.setHeader(header.substring(0, colon), + header.substring(colon + 1, header.length), + false); // allow overriding server-set headers + + line.value = ""; + more = lis.readLine(line); + } + } + catch (e) + { + dumpn("WARNING: error in headers for " + metadata.path + ": " + e); + throw HTTP_500; + } + finally + { + fis.close(); + } +} + + +/** +* An object which handles requests for a server, executing default and +* overridden behaviors as instructed by the code which uses and manipulates it. +* Default behavior includes the paths / and /trace (diagnostics), with some +* support for HTTP error pages for various codes and fallback to HTTP 500 if +* those codes fail for any reason. +* +* @param server : nsHttpServer +* the server in which this handler is being used +*/ +function ServerHandler(server) +{ + // FIELDS + + /** +* The nsHttpServer instance associated with this handler. +*/ + this._server = server; + + /** +* A FileMap object containing the set of path->nsILocalFile mappings for +* all directory mappings set in the server (e.g., "/" for /var/www/html/, +* "/foo/bar/" for /local/path/, and "/foo/bar/baz/" for /local/path2). +* +* Note carefully: the leading and trailing "/" in each path (not file) are +* removed before insertion to simplify the code which uses this. You have +* been warned! +*/ + this._pathDirectoryMap = new FileMap(); + + /** +* Custom request handlers for the server in which this resides. Path-handler +* pairs are stored as property-value pairs in this property. +* +* @see ServerHandler.prototype._defaultPaths +*/ + this._overridePaths = {}; + + /** +* Custom request handlers for the server in which this resides. Prefix-handler +* pairs are stored as property-value pairs in this property. +*/ + this._overridePrefixes = {}; + + /** +* Custom request handlers for the error handlers in the server in which this +* resides. Path-handler pairs are stored as property-value pairs in this +* property. +* +* @see ServerHandler.prototype._defaultErrors +*/ + this._overrideErrors = {}; + + /** +* Maps file extensions to their MIME types in the server, overriding any +* mapping that might or might not exist in the MIME service. +*/ + this._mimeMappings = {}; + + /** +* The default handler for requests for directories, used to serve directories +* when no index file is present. +*/ + this._indexHandler = defaultIndexHandler; + + /** Per-path state storage for the server. */ + this._state = {}; + + /** Entire-server state storage. */ + this._sharedState = {}; + + /** Entire-server state storage for nsISupports values. */ + this._objectState = {}; +} +ServerHandler.prototype = +{ + // PUBLIC API + + /** +* Handles a request to this server, responding to the request appropriately +* and initiating server shutdown if necessary. +* +* This method never throws an exception. +* +* @param connection : Connection +* the connection for this request +*/ + handleResponse: function(connection) + { + var request = connection.request; + var response = new Response(connection); + + var path = request.path; + dumpn("*** path == " + path); + + try + { + try + { + if (path in this._overridePaths) + { + // explicit paths first, then files based on existing directory mappings, + // then (if the file doesn't exist) built-in server default paths + dumpn("calling override for " + path); + this._overridePaths[path](request, response); + } + else + { + let longestPrefix = ""; + for (let prefix in this._overridePrefixes) + { + if (prefix.length > longestPrefix.length && path.startsWith(prefix)) + { + longestPrefix = prefix; + } + } + if (longestPrefix.length > 0) + { + dumpn("calling prefix override for " + longestPrefix); + this._overridePrefixes[longestPrefix](request, response); + } + else + { + this._handleDefault(request, response); + } + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + if (!(e instanceof HttpError)) + { + dumpn("*** unexpected error: e == " + e); + throw HTTP_500; + } + if (e.code !== 404) + throw e; + + dumpn("*** default: " + (path in this._defaultPaths)); + + response = new Response(connection); + if (path in this._defaultPaths) + this._defaultPaths[path](request, response); + else + throw HTTP_404; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + var errorCode = "internal"; + + try + { + if (!(e instanceof HttpError)) + throw e; + + errorCode = e.code; + dumpn("*** errorCode == " + errorCode); + + response = new Response(connection); + if (e.customErrorHandling) + e.customErrorHandling(response); + this._handleError(errorCode, request, response); + return; + } + catch (e2) + { + dumpn("*** error handling " + errorCode + " error: " + + "e2 == " + e2 + ", shutting down server"); + + connection.server._requestQuit(); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (!file) + { + dumpn("*** unregistering '" + path + "' mapping"); + delete this._overridePaths[path]; + return; + } + + dumpn("*** registering '" + path + "' as mapping to " + file.path); + file = file.clone(); + + var self = this; + this._overridePaths[path] = + function(request, response) + { + if (!file.exists()) + throw HTTP_404; + + response.setStatusLine(request.httpVersion, 200, "OK"); + self._writeFileResponse(request, file, response, 0, file.fileSize); + }; + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + // XXX true path validation! + if (path.charAt(0) != "/") + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePaths, path); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + // XXX true prefix validation! + if (!(prefix.startsWith("/") && prefix.endsWith("/"))) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePrefixes, prefix); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // strip off leading and trailing '/' so that we can use lastIndexOf when + // determining exactly how a path maps onto a mapped directory -- + // conditional is required here to deal with "/".substring(1, 0) being + // converted to "/".substring(0, 1) per the JS specification + var key = path.length == 1 ? "" : path.substring(1, path.length - 1); + + // the path-to-directory mapping code requires that the first character not + // be "/", or it will go into an infinite loop + if (key.charAt(0) == "/") + throw Cr.NS_ERROR_INVALID_ARG; + + key = toInternalPath(key, false); + + if (directory) + { + dumpn("*** mapping '" + path + "' to the location " + directory.path); + this._pathDirectoryMap.put(key, directory); + } + else + { + dumpn("*** removing mapping for '" + path + "'"); + this._pathDirectoryMap.put(key, null); + } + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(err, handler) + { + if (!(err in HTTP_ERROR_CODES)) + dumpn("*** WARNING: registering non-HTTP/1.1 error code " + + "(" + err + ") handler -- was this intentional?"); + + this._handlerToField(handler, this._overrideErrors, err); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + if (!handler) + handler = defaultIndexHandler; + else if (typeof(handler) != "function") + handler = createHandlerFunc(handler); + + this._indexHandler = handler; + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + if (!type) + delete this._mimeMappings[ext]; + else + this._mimeMappings[ext] = headerUtils.normalizeFieldValue(type); + }, + + // PRIVATE API + + /** +* Sets or remove (if handler is null) a handler in an object with a key. +* +* @param handler +* a handler, either function or an nsIHttpRequestHandler +* @param dict +* The object to attach the handler to. +* @param key +* The field name of the handler. +*/ + _handlerToField: function(handler, dict, key) + { + // for convenience, handler can be a function if this is run from xpcshell + if (typeof(handler) == "function") + dict[key] = handler; + else if (handler) + dict[key] = createHandlerFunc(handler); + else + delete dict[key]; + }, + + /** +* Handles a request which maps to a file in the local filesystem (if a base +* path has already been set; otherwise the 404 error is thrown). +* +* @param metadata : Request +* metadata for the incoming request +* @param response : Response +* an uninitialized Response to the given request, to be initialized by a +* request handler +* @throws HTTP_### +* if an HTTP error occurred (usually HTTP_404); note that in this case the +* calling code must handle post-processing of the response +*/ + _handleDefault: function(metadata, response) + { + dumpn("*** _handleDefault()"); + + response.setStatusLine(metadata.httpVersion, 200, "OK"); + + var path = metadata.path; + NS_ASSERT(path.charAt(0) == "/", "invalid path: <" + path + ">"); + + // determine the actual on-disk file; this requires finding the deepest + // path-to-directory mapping in the requested URL + var file = this._getFileForPath(path); + + // the "file" might be a directory, in which case we either serve the + // contained index.html or make the index handler write the response + if (file.exists() && file.isDirectory()) + { + file.append("index.html"); // make configurable? + if (!file.exists() || file.isDirectory()) + { + metadata._ensurePropertyBag(); + metadata._bag.setPropertyAsInterface("directory", file.parent); + this._indexHandler(metadata, response); + return; + } + } + + // alternately, the file might not exist + if (!file.exists()) + throw HTTP_404; + + var start, end; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1) && + metadata.hasHeader("Range") && + this._getTypeFromFile(file) !== SJS_TYPE) + { + var rangeMatch = metadata.getHeader("Range").match(/^bytes=(\d+)?-(\d+)?$/); + if (!rangeMatch) + throw HTTP_400; + + if (rangeMatch[1] !== undefined) + start = parseInt(rangeMatch[1], 10); + + if (rangeMatch[2] !== undefined) + end = parseInt(rangeMatch[2], 10); + + if (start === undefined && end === undefined) + throw HTTP_400; + + // No start given, so the end is really the count of bytes from the + // end of the file. + if (start === undefined) + { + start = Math.max(0, file.fileSize - end); + end = file.fileSize - 1; + } + + // start and end are inclusive + if (end === undefined || end >= file.fileSize) + end = file.fileSize - 1; + + if (start !== undefined && start >= file.fileSize) { + var HTTP_416 = new HttpError(416, "Requested Range Not Satisfiable"); + HTTP_416.customErrorHandling = function(errorResponse) + { + maybeAddHeaders(file, metadata, errorResponse); + }; + throw HTTP_416; + } + + if (end < start) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + start = 0; + end = file.fileSize - 1; + } + else + { + response.setStatusLine(metadata.httpVersion, 206, "Partial Content"); + var contentRange = "bytes " + start + "-" + end + "/" + file.fileSize; + response.setHeader("Content-Range", contentRange); + } + } + else + { + start = 0; + end = file.fileSize - 1; + } + + // finally... + dumpn("*** handling '" + path + "' as mapping to " + file.path + " from " + + start + " to " + end + " inclusive"); + this._writeFileResponse(metadata, file, response, start, end - start + 1); + }, + + /** +* Writes an HTTP response for the given file, including setting headers for +* file metadata. +* +* @param metadata : Request +* the Request for which a response is being generated +* @param file : nsILocalFile +* the file which is to be sent in the response +* @param response : Response +* the response to which the file should be written +* @param offset: uint +* the byte offset to skip to when writing +* @param count: uint +* the number of bytes to write +*/ + _writeFileResponse: function(metadata, file, response, offset, count) + { + const PR_RDONLY = 0x01; + + var type = this._getTypeFromFile(file); + if (type === SJS_TYPE) + { + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var sis = new ScriptableInputStream(fis); + var s = Cu.Sandbox(gGlobalObject); + s.importFunction(dump, "dump"); + + // Define a basic key-value state-preservation API across requests, with + // keys initially corresponding to the empty string. + var self = this; + var path = metadata.path; + s.importFunction(function getState(k) + { + return self._getState(path, k); + }); + s.importFunction(function setState(k, v) + { + self._setState(path, k, v); + }); + s.importFunction(function getSharedState(k) + { + return self._getSharedState(k); + }); + s.importFunction(function setSharedState(k, v) + { + self._setSharedState(k, v); + }); + s.importFunction(function getObjectState(k, callback) + { + callback(self._getObjectState(k)); + }); + s.importFunction(function setObjectState(k, v) + { + self._setObjectState(k, v); + }); + s.importFunction(function registerPathHandler(p, h) + { + self.registerPathHandler(p, h); + }); + + // Make it possible for sjs files to access their location + this._setState(path, "__LOCATION__", file.path); + + try + { + // Alas, the line number in errors dumped to console when calling the + // request handler is simply an offset from where we load the SJS file. + // Work around this in a reasonably non-fragile way by dynamically + // getting the line number where we evaluate the SJS file. Don't + // separate these two lines! + var line = new Error().lineNumber; + Cu.evalInSandbox(sis.read(file.fileSize), s); + } + catch (e) + { + dumpn("*** syntax error in SJS at " + file.path + ": " + e); + throw HTTP_500; + } + + try + { + s.handleRequest(metadata, response); + } + catch (e) + { + dump("*** error running SJS at " + file.path + ": " + + e + " on line " + + (e instanceof Error + ? e.lineNumber + " in httpd.js" + : (e.lineNumber - line)) + "\n"); + throw HTTP_500; + } + } + finally + { + fis.close(); + } + } + else + { + try + { + response.setHeader("Last-Modified", + toDateString(file.lastModifiedTime), + false); + } + catch (e) { /* lastModifiedTime threw, ignore */ } + + response.setHeader("Content-Type", type, false); + maybeAddHeaders(file, metadata, response); + response.setHeader("Content-Length", "" + count, false); + + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + offset = offset || 0; + count = count || file.fileSize; + NS_ASSERT(offset === 0 || offset < file.fileSize, "bad offset"); + NS_ASSERT(count >= 0, "bad count"); + NS_ASSERT(offset + count <= file.fileSize, "bad total data size"); + + try + { + if (offset !== 0) + { + // Seek (or read, if seeking isn't supported) to the correct offset so + // the data sent to the client matches the requested range. + if (fis instanceof Ci.nsISeekableStream) + fis.seek(Ci.nsISeekableStream.NS_SEEK_SET, offset); + else + new ScriptableInputStream(fis).read(offset); + } + } + catch (e) + { + fis.close(); + throw e; + } + + let writeMore = function writeMore() + { + gThreadManager.currentThread + .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL); + } + + var input = new BinaryInputStream(fis); + var output = new BinaryOutputStream(response.bodyOutputStream); + var writeData = + { + run: function() + { + var chunkSize = Math.min(65536, count); + count -= chunkSize; + NS_ASSERT(count >= 0, "underflow"); + + try + { + var data = input.readByteArray(chunkSize); + NS_ASSERT(data.length === chunkSize, + "incorrect data returned? got " + data.length + + ", expected " + chunkSize); + output.writeByteArray(data, data.length); + if (count === 0) + { + fis.close(); + response.finish(); + } + else + { + writeMore(); + } + } + catch (e) + { + try + { + fis.close(); + } + finally + { + response.finish(); + } + throw e; + } + } + }; + + writeMore(); + + // Now that we know copying will start, flag the response as async. + response.processAsync(); + } + }, + + /** +* Get the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getState: function(path, k) + { + var state = this._state; + if (path in state && k in state[path]) + return state[path][k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setState: function(path, k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + var state = this._state; + if (!(path in state)) + state[path] = {}; + state[path][k] = v; + }, + + /** +* Get the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getSharedState: function(k) + { + var state = this._sharedState; + if (k in state) + return state[k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setSharedState: function(k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + this._sharedState[k] = v; + }, + + /** +* Returns the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be returned +* @returns nsISupports +* the corresponding object, or null if none was present +*/ + _getObjectState: function(k) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + return this._objectState[k] || null; + }, + + /** +* Sets the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be set +* @param v : nsISupports +* the object to be associated with the given key; may be null +*/ + _setObjectState: function(k, v) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + if (typeof v !== "object") + throw new Error("non-object value passed"); + if (v && !("QueryInterface" in v)) + { + throw new Error("must pass an nsISupports; use wrappedJSObject to ease " + + "pain when using the server from JS"); + } + + this._objectState[k] = v; + }, + + /** +* Gets a content-type for the given file, first by checking for any custom +* MIME-types registered with this handler for the file's extension, second by +* asking the global MIME service for a content-type, and finally by failing +* over to application/octet-stream. +* +* @param file : nsIFile +* the nsIFile for which to get a file type +* @returns string +* the best content-type which can be determined for the file +*/ + _getTypeFromFile: function(file) + { + try + { + var name = file.leafName; + var dot = name.lastIndexOf("."); + if (dot > 0) + { + var ext = name.slice(dot + 1); + if (ext in this._mimeMappings) + return this._mimeMappings[ext]; + } + return Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(file); + } + catch (e) + { + return "application/octet-stream"; + } + }, + + /** +* Returns the nsILocalFile which corresponds to the path, as determined using +* all registered path->directory mappings and any paths which are explicitly +* overridden. +* +* @param path : string +* the server path for which a file should be retrieved, e.g. "/foo/bar" +* @throws HttpError +* when the correct action is the corresponding HTTP error (i.e., because no +* mapping was found for a directory in path, the referenced file doesn't +* exist, etc.) +* @returns nsILocalFile +* the file to be sent as the response to a request for the path +*/ + _getFileForPath: function(path) + { + // decode and add underscores as necessary + try + { + path = toInternalPath(path, true); + } + catch (e) + { + throw HTTP_400; // malformed path + } + + // next, get the directory which contains this path + var pathMap = this._pathDirectoryMap; + + // An example progression of tmp for a path "/foo/bar/baz/" might be: + // "foo/bar/baz/", "foo/bar/baz", "foo/bar", "foo", "" + var tmp = path.substring(1); + while (true) + { + // do we have a match for current head of the path? + var file = pathMap.get(tmp); + if (file) + { + // XXX hack; basically disable showing mapping for /foo/bar/ when the + // requested path was /foo/bar, because relative links on the page + // will all be incorrect -- we really need the ability to easily + // redirect here instead + if (tmp == path.substring(1) && + tmp.length != 0 && + tmp.charAt(tmp.length - 1) != "/") + file = null; + else + break; + } + + // if we've finished trying all prefixes, exit + if (tmp == "") + break; + + tmp = tmp.substring(0, tmp.lastIndexOf("/")); + } + + // no mapping applies, so 404 + if (!file) + throw HTTP_404; + + + // last, get the file for the path within the determined directory + var parentFolder = file.parent; + var dirIsRoot = (parentFolder == null); + + // Strategy here is to append components individually, making sure we + // never move above the given directory; this allows paths such as + // "<file>/foo/../bar" but prevents paths such as "<file>/../base-sibling"; + // this component-wise approach also means the code works even on platforms + // which don't use "/" as the directory separator, such as Windows + var leafPath = path.substring(tmp.length + 1); + var comps = leafPath.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + + if (comp == "..") + file = file.parent; + else if (comp == "." || comp == "") + continue; + else + file.append(comp); + + if (!dirIsRoot && file.equals(parentFolder)) + throw HTTP_403; + } + + return file; + }, + + /** +* Writes the error page for the given HTTP error code over the given +* connection. +* +* @param errorCode : uint +* the HTTP error code to be used +* @param connection : Connection +* the connection on which the error occurred +*/ + handleError: function(errorCode, connection) + { + var response = new Response(connection); + + dumpn("*** error in request: " + errorCode); + + this._handleError(errorCode, new Request(connection.port), response); + }, + + /** +* Handles a request which generates the given error code, using the +* user-defined error handler if one has been set, gracefully falling back to +* the x00 status code if the code has no handler, and failing to status code +* 500 if all else fails. +* +* @param errorCode : uint +* the HTTP error which is to be returned +* @param metadata : Request +* metadata for the request, which will often be incomplete since this is an +* error +* @param response : Response +* an uninitialized Response should be initialized when this method +* completes with information which represents the desired error code in the +* ideal case or a fallback code in abnormal circumstances (i.e., 500 is a +* fallback for 505, per HTTP specs) +*/ + _handleError: function(errorCode, metadata, response) + { + if (!metadata) + throw Cr.NS_ERROR_NULL_POINTER; + + var errorX00 = errorCode - (errorCode % 100); + + try + { + if (!(errorCode in HTTP_ERROR_CODES)) + dumpn("*** WARNING: requested invalid error: " + errorCode); + + // RFC 2616 says that we should try to handle an error by its class if we + // can't otherwise handle it -- if that fails, we revert to handling it as + // a 500 internal server error, and if that fails we throw and shut down + // the server + + // actually handle the error + try + { + if (errorCode in this._overrideErrors) + this._overrideErrors[errorCode](metadata, response); + else + this._defaultErrors[errorCode](metadata, response); + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + // don't retry the handler that threw + if (errorX00 == errorCode) + throw HTTP_500; + + dumpn("*** error in handling for error code " + errorCode + ", " + + "falling back to " + errorX00 + "..."); + response = new Response(response._connection); + if (errorX00 in this._overrideErrors) + this._overrideErrors[errorX00](metadata, response); + else if (errorX00 in this._defaultErrors) + this._defaultErrors[errorX00](metadata, response); + else + throw HTTP_500; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(); + return; + } + + // we've tried everything possible for a meaningful error -- now try 500 + dumpn("*** error in handling for error code " + errorX00 + ", falling " + + "back to 500..."); + + try + { + response = new Response(response._connection); + if (500 in this._overrideErrors) + this._overrideErrors[500](metadata, response); + else + this._defaultErrors[500](metadata, response); + } + catch (e2) + { + dumpn("*** multiple errors in default error handlers!"); + dumpn("*** e == " + e + ", e2 == " + e2); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // FIELDS + + /** +* This object contains the default handlers for the various HTTP error codes. +*/ + _defaultErrors: + { + 400: function(metadata, response) + { + // none of the data in metadata is reliable, so hard-code everything here + response.setStatusLine("1.1", 400, "Bad Request"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Bad request\n"; + response.bodyOutputStream.write(body, body.length); + }, + 403: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 403, "Forbidden"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>403 Forbidden</title></head>\ +<body>\ +<h1>403 Forbidden</h1>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 404: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>404 Not Found</title></head>\ +<body>\ +<h1>404 Not Found</h1>\ +<p>\ +<span style='font-family: monospace;'>" + + htmlEscape(metadata.path) + + "</span> was not found.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 416: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 416, + "Requested Range Not Satisfiable"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head>\ +<title>416 Requested Range Not Satisfiable</title></head>\ +<body>\ +<h1>416 Requested Range Not Satisfiable</h1>\ +<p>The byte range was not valid for the\ +requested resource.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 500: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 500, + "Internal Server Error"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>500 Internal Server Error</title></head>\ +<body>\ +<h1>500 Internal Server Error</h1>\ +<p>Something's broken in this server and\ +needs to be fixed.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 501: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 501, "Not Implemented"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>501 Not Implemented</title></head>\ +<body>\ +<h1>501 Not Implemented</h1>\ +<p>This server is not (yet) Apache.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 505: function(metadata, response) + { + response.setStatusLine("1.1", 505, "HTTP Version Not Supported"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>505 HTTP Version Not Supported</title></head>\ +<body>\ +<h1>505 HTTP Version Not Supported</h1>\ +<p>This server only supports HTTP/1.0 and HTTP/1.1\ +connections.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + } + }, + + /** +* Contains handlers for the default set of URIs contained in this server. +*/ + _defaultPaths: + { + "/": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>httpd.js</title></head>\ +<body>\ +<h1>httpd.js</h1>\ +<p>If you're seeing this page, httpd.js is up and\ +serving requests! Now set a base path and serve some\ +files!</p>\ +</body>\ +</html>"; + + response.bodyOutputStream.write(body, body.length); + }, + + "/trace": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Request-URI: " + + metadata.scheme + "://" + metadata.host + ":" + metadata.port + + metadata.path + "\n\n"; + body += "Request (semantically equivalent, slightly reformatted):\n\n"; + body += metadata.method + " " + metadata.path; + + if (metadata.queryString) + body += "?" + metadata.queryString; + + body += " HTTP/" + metadata.httpVersion + "\r\n"; + + var headEnum = metadata.headers; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + body += fieldName + ": " + metadata.getHeader(fieldName) + "\r\n"; + } + + response.bodyOutputStream.write(body, body.length); + } + } +}; + + +/** +* Maps absolute paths to files on the local file system (as nsILocalFiles). +*/ +function FileMap() +{ + /** Hash which will map paths to nsILocalFiles. */ + this._map = {}; +} +FileMap.prototype = +{ + // PUBLIC API + + /** +* Maps key to a clone of the nsILocalFile value if value is non-null; +* otherwise, removes any extant mapping for key. +* +* @param key : string +* string to which a clone of value is mapped +* @param value : nsILocalFile +* the file to map to key, or null to remove a mapping +*/ + put: function(key, value) + { + if (value) + this._map[key] = value.clone(); + else + delete this._map[key]; + }, + + /** +* Returns a clone of the nsILocalFile mapped to key, or null if no such +* mapping exists. +* +* @param key : string +* key to which the returned file maps +* @returns nsILocalFile +* a clone of the mapped file, or null if no mapping exists +*/ + get: function(key) + { + var val = this._map[key]; + return val ? val.clone() : null; + } +}; + + +// Response CONSTANTS + +// token = *<any CHAR except CTLs or separators> +// CHAR = <any US-ASCII character (0-127)> +// CTL = <any US-ASCII control character (0-31) and DEL (127)> +// separators = "(" | ")" | "<" | ">" | "@" +// | "," | ";" | ":" | "\" | <"> +// | "/" | "[" | "]" | "?" | "=" +// | "{" | "}" | SP | HT +const IS_TOKEN_ARRAY = + [0, 0, 0, 0, 0, 0, 0, 0, // 0 + 0, 0, 0, 0, 0, 0, 0, 0, // 8 + 0, 0, 0, 0, 0, 0, 0, 0, // 16 + 0, 0, 0, 0, 0, 0, 0, 0, // 24 + + 0, 1, 0, 1, 1, 1, 1, 1, // 32 + 0, 0, 1, 1, 0, 1, 1, 0, // 40 + 1, 1, 1, 1, 1, 1, 1, 1, // 48 + 1, 1, 0, 0, 0, 0, 0, 0, // 56 + + 0, 1, 1, 1, 1, 1, 1, 1, // 64 + 1, 1, 1, 1, 1, 1, 1, 1, // 72 + 1, 1, 1, 1, 1, 1, 1, 1, // 80 + 1, 1, 1, 0, 0, 0, 1, 1, // 88 + + 1, 1, 1, 1, 1, 1, 1, 1, // 96 + 1, 1, 1, 1, 1, 1, 1, 1, // 104 + 1, 1, 1, 1, 1, 1, 1, 1, // 112 + 1, 1, 1, 0, 1, 0, 1]; // 120 + + +/** +* Determines whether the given character code is a CTL. +* +* @param code : uint +* the character code +* @returns boolean +* true if code is a CTL, false otherwise +*/ +function isCTL(code) +{ + return (code >= 0 && code <= 31) || (code == 127); +} + +/** +* Represents a response to an HTTP request, encapsulating all details of that +* response. This includes all headers, the HTTP version, status code and +* explanation, and the entity itself. +* +* @param connection : Connection +* the connection over which this response is to be written +*/ +function Response(connection) +{ + /** The connection over which this response will be written. */ + this._connection = connection; + + /** +* The HTTP version of this response; defaults to 1.1 if not set by the +* handler. +*/ + this._httpVersion = nsHttpVersion.HTTP_1_1; + + /** +* The HTTP code of this response; defaults to 200. +*/ + this._httpCode = 200; + + /** +* The description of the HTTP code in this response; defaults to "OK". +*/ + this._httpDescription = "OK"; + + /** +* An nsIHttpHeaders object in which the headers in this response should be +* stored. This property is null after the status line and headers have been +* written to the network, and it may be modified up until it is cleared, +* except if this._finished is set first (in which case headers are written +* asynchronously in response to a finish() call not preceded by +* flushHeaders()). +*/ + this._headers = new nsHttpHeaders(); + + /** +* Set to true when this response is ended (completely constructed if possible +* and the connection closed); further actions on this will then fail. +*/ + this._ended = false; + + /** +* A stream used to hold data written to the body of this response. +*/ + this._bodyOutputStream = null; + + /** +* A stream containing all data that has been written to the body of this +* response so far. (Async handlers make the data contained in this +* unreliable as a way of determining content length in general, but auxiliary +* saved information can sometimes be used to guarantee reliability.) +*/ + this._bodyInputStream = null; + + /** +* A stream copier which copies data to the network. It is initially null +* until replaced with a copier for response headers; when headers have been +* fully sent it is replaced with a copier for the response body, remaining +* so for the duration of response processing. +*/ + this._asyncCopier = null; + + /** +* True if this response has been designated as being processed +* asynchronously rather than for the duration of a single call to +* nsIHttpRequestHandler.handle. +*/ + this._processAsync = false; + + /** +* True iff finish() has been called on this, signaling that no more changes +* to this may be made. +*/ + this._finished = false; + + /** +* True iff powerSeized() has been called on this, signaling that this +* response is to be handled manually by the response handler (which may then +* send arbitrary data in response, even non-HTTP responses). +*/ + this._powerSeized = false; +} +Response.prototype = +{ + // PUBLIC CONSTRUCTION API + + // + // see nsIHttpResponse.bodyOutputStream + // + get bodyOutputStream() + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + if (!this._bodyOutputStream) + { + var pipe = new Pipe(true, false, Response.SEGMENT_SIZE, PR_UINT32_MAX, + null); + this._bodyOutputStream = pipe.outputStream; + this._bodyInputStream = pipe.inputStream; + if (this._processAsync || this._powerSeized) + this._startAsyncProcessor(); + } + + return this._bodyOutputStream; + }, + + // + // see nsIHttpResponse.write + // + write: function(data) + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + var dataAsString = String(data); + this.bodyOutputStream.write(dataAsString, dataAsString.length); + }, + + // + // see nsIHttpResponse.setStatusLine + // + setStatusLine: function(httpVersion, code, description) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + if (!(code >= 0 && code < 1000)) + throw Cr.NS_ERROR_INVALID_ARG; + + try + { + var httpVer; + // avoid version construction for the most common cases + if (!httpVersion || httpVersion == "1.1") + httpVer = nsHttpVersion.HTTP_1_1; + else if (httpVersion == "1.0") + httpVer = nsHttpVersion.HTTP_1_0; + else + httpVer = new nsHttpVersion(httpVersion); + } + catch (e) + { + throw Cr.NS_ERROR_INVALID_ARG; + } + + // Reason-Phrase = *<TEXT, excluding CR, LF> + // TEXT = <any OCTET except CTLs, but including LWS> + // + // XXX this ends up disallowing octets which aren't Unicode, I think -- not + // much to do if description is IDL'd as string + if (!description) + description = ""; + for (var i = 0; i < description.length; i++) + if (isCTL(description.charCodeAt(i)) && description.charAt(i) != "\t") + throw Cr.NS_ERROR_INVALID_ARG; + + // set the values only after validation to preserve atomicity + this._httpDescription = description; + this._httpCode = code; + this._httpVersion = httpVer; + }, + + // + // see nsIHttpResponse.setHeader + // + setHeader: function(name, value, merge) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + this._headers.setHeader(name, value, merge); + }, + + // + // see nsIHttpResponse.processAsync + // + processAsync: function() + { + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._processAsync) + return; + this._ensureAlive(); + + dumpn("*** processing connection " + this._connection.number + " async"); + this._processAsync = true; + + /* +* Either the bodyOutputStream getter or this method is responsible for +* starting the asynchronous processor and catching writes of data to the +* response body of async responses as they happen, for the purpose of +* forwarding those writes to the actual connection's output stream. +* If bodyOutputStream is accessed first, calling this method will create +* the processor (when it first is clear that body data is to be written +* immediately, not buffered). If this method is called first, accessing +* bodyOutputStream will create the processor. If only this method is +* called, we'll write nothing, neither headers nor the nonexistent body, +* until finish() is called. Since that delay is easily avoided by simply +* getting bodyOutputStream or calling write(""), we don't worry about it. +*/ + if (this._bodyOutputStream && !this._asyncCopier) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.seizePower + // + seizePower: function() + { + if (this._processAsync) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + return; + this._ensureAlive(); + + dumpn("*** forcefully seizing power over connection " + + this._connection.number + "..."); + + // Purge any already-written data without sending it. We could as easily + // swap out the streams entirely, but that makes it possible to acquire and + // unknowingly use a stale reference, so we require there only be one of + // each stream ever for any response to avoid this complication. + if (this._asyncCopier) + this._asyncCopier.cancel(Cr.NS_BINDING_ABORTED); + this._asyncCopier = null; + if (this._bodyOutputStream) + { + var input = new BinaryInputStream(this._bodyInputStream); + var avail; + while ((avail = input.available()) > 0) + input.readByteArray(avail); + } + + this._powerSeized = true; + if (this._bodyOutputStream) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.finish + // + finish: function() + { + if (!this._processAsync && !this._powerSeized) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._finished) + return; + + dumpn("*** finishing connection " + this._connection.number); + this._startAsyncProcessor(); // in case bodyOutputStream was never accessed + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + this._finished = true; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpResponse) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // POST-CONSTRUCTION API (not exposed externally) + + /** +* The HTTP version number of this, as a string (e.g. "1.1"). +*/ + get httpVersion() + { + this._ensureAlive(); + return this._httpVersion.toString(); + }, + + /** +* The HTTP status code of this response, as a string of three characters per +* RFC 2616. +*/ + get httpCode() + { + this._ensureAlive(); + + var codeString = (this._httpCode < 10 ? "0" : "") + + (this._httpCode < 100 ? "0" : "") + + this._httpCode; + return codeString; + }, + + /** +* The description of the HTTP status code of this response, or "" if none is +* set. +*/ + get httpDescription() + { + this._ensureAlive(); + + return this._httpDescription; + }, + + /** +* The headers in this response, as an nsHttpHeaders object. +*/ + get headers() + { + this._ensureAlive(); + + return this._headers; + }, + + // + // see nsHttpHeaders.getHeader + // + getHeader: function(name) + { + this._ensureAlive(); + + return this._headers.getHeader(name); + }, + + /** +* Determines whether this response may be abandoned in favor of a newly +* constructed response. A response may be abandoned only if it is not being +* sent asynchronously and if raw control over it has not been taken from the +* server. +* +* @returns boolean +* true iff no data has been written to the network +*/ + partiallySent: function() + { + dumpn("*** partiallySent()"); + return this._processAsync || this._powerSeized; + }, + + /** +* If necessary, kicks off the remaining request processing needed to be done +* after a request handler performs its initial work upon this response. +*/ + complete: function() + { + dumpn("*** complete()"); + if (this._processAsync || this._powerSeized) + { + NS_ASSERT(this._processAsync ^ this._powerSeized, + "can't both send async and relinquish power"); + return; + } + + NS_ASSERT(!this.partiallySent(), "completing a partially-sent response?"); + + this._startAsyncProcessor(); + + // Now make sure we finish processing this request! + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + }, + + /** +* Abruptly ends processing of this response, usually due to an error in an +* incoming request but potentially due to a bad error handler. Since we +* cannot handle the error in the usual way (giving an HTTP error page in +* response) because data may already have been sent (or because the response +* might be expected to have been generated asynchronously or completely from +* scratch by the handler), we stop processing this response and abruptly +* close the connection. +* +* @param e : Error +* the exception which precipitated this abort, or null if no such exception +* was generated +*/ + abort: function(e) + { + dumpn("*** abort(<" + e + ">)"); + + // This response will be ended by the processor if one was created. + var copier = this._asyncCopier; + if (copier) + { + // We dispatch asynchronously here so that any pending writes of data to + // the connection will be deterministically written. This makes it easier + // to specify exact behavior, and it makes observable behavior more + // predictable for clients. Note that the correctness of this depends on + // callbacks in response to _waitToReadData in WriteThroughCopier + // happening asynchronously with respect to the actual writing of data to + // bodyOutputStream, as they currently do; if they happened synchronously, + // an event which ran before this one could write more data to the + // response body before we get around to canceling the copier. We have + // tests for this in test_seizepower.js, however, and I can't think of a + // way to handle both cases without removing bodyOutputStream access and + // moving its effective write(data, length) method onto Response, which + // would be slower and require more code than this anyway. + gThreadManager.currentThread.dispatch({ + run: function() + { + dumpn("*** canceling copy asynchronously..."); + copier.cancel(Cr.NS_ERROR_UNEXPECTED); + } + }, Ci.nsIThread.DISPATCH_NORMAL); + } + else + { + this.end(); + } + }, + + /** +* Closes this response's network connection, marks the response as finished, +* and notifies the server handler that the request is done being processed. +*/ + end: function() + { + NS_ASSERT(!this._ended, "ending this response twice?!?!"); + + this._connection.close(); + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + + this._finished = true; + this._ended = true; + }, + + // PRIVATE IMPLEMENTATION + + /** +* Sends the status line and headers of this response if they haven't been +* sent and initiates the process of copying data written to this response's +* body to the network. +*/ + _startAsyncProcessor: function() + { + dumpn("*** _startAsyncProcessor()"); + + // Handle cases where we're being called a second time. The former case + // happens when this is triggered both by complete() and by processAsync(), + // while the latter happens when processAsync() in conjunction with sent + // data causes abort() to be called. + if (this._asyncCopier || this._ended) + { + dumpn("*** ignoring second call to _startAsyncProcessor"); + return; + } + + // Send headers if they haven't been sent already and should be sent, then + // asynchronously continue to send the body. + if (this._headers && !this._powerSeized) + { + this._sendHeaders(); + return; + } + + this._headers = null; + this._sendBody(); + }, + + /** +* Signals that all modifications to the response status line and headers are +* complete and then sends that data over the network to the client. Once +* this method completes, a different response to the request that resulted +* in this response cannot be sent -- the only possible action in case of +* error is to abort the response and close the connection. +*/ + _sendHeaders: function() + { + dumpn("*** _sendHeaders()"); + + NS_ASSERT(this._headers); + NS_ASSERT(!this._powerSeized); + + // request-line + var statusLine = "HTTP/" + this.httpVersion + " " + + this.httpCode + " " + + this.httpDescription + "\r\n"; + + // header post-processing + + var headers = this._headers; + headers.setHeader("Connection", "close", false); + headers.setHeader("Server", "httpd.js", false); + if (!headers.hasHeader("Date")) + headers.setHeader("Date", toDateString(Date.now()), false); + + // Any response not being processed asynchronously must have an associated + // Content-Length header for reasons of backwards compatibility with the + // initial server, which fully buffered every response before sending it. + // Beyond that, however, it's good to do this anyway because otherwise it's + // impossible to test behaviors that depend on the presence or absence of a + // Content-Length header. + if (!this._processAsync) + { + dumpn("*** non-async response, set Content-Length"); + + var bodyStream = this._bodyInputStream; + var avail = bodyStream ? bodyStream.available() : 0; + + // XXX assumes stream will always report the full amount of data available + headers.setHeader("Content-Length", "" + avail, false); + } + + + // construct and send response + dumpn("*** header post-processing completed, sending response head..."); + + // request-line + var preambleData = [statusLine]; + + // headers + var headEnum = headers.enumerator; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + var values = headers.getHeaderValues(fieldName); + for (var i = 0, sz = values.length; i < sz; i++) + preambleData.push(fieldName + ": " + values[i] + "\r\n"); + } + + // end request-line/headers + preambleData.push("\r\n"); + + var preamble = preambleData.join(""); + + var responseHeadPipe = new Pipe(true, false, 0, PR_UINT32_MAX, null); + responseHeadPipe.outputStream.write(preamble, preamble.length); + + var response = this; + var copyObserver = + { + onStartRequest: function(request, cx) + { + dumpn("*** preamble copying started"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** preamble copying complete " + + "[status=0x" + statusCode.toString(16) + "]"); + + if (!components.isSuccessCode(statusCode)) + { + dumpn("!!! header copying problems: non-success statusCode, " + + "ending response"); + + response.end(); + } + else + { + response._sendBody(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + var headerCopier = this._asyncCopier = + new WriteThroughCopier(responseHeadPipe.inputStream, + this._connection.output, + copyObserver, null); + + responseHeadPipe.outputStream.close(); + + // Forbid setting any more headers or modifying the request line. + this._headers = null; + }, + + /** +* Asynchronously writes the body of the response (or the entire response, if +* seizePower() has been called) to the network. +*/ + _sendBody: function() + { + dumpn("*** _sendBody"); + + NS_ASSERT(!this._headers, "still have headers around but sending body?"); + + // If no body data was written, we're done + if (!this._bodyInputStream) + { + dumpn("*** empty body, response finished"); + this.end(); + return; + } + + var response = this; + var copyObserver = + { + onStartRequest: function(request, context) + { + dumpn("*** onStartRequest"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** onStopRequest [status=0x" + statusCode.toString(16) + "]"); + + if (statusCode === Cr.NS_BINDING_ABORTED) + { + dumpn("*** terminating copy observer without ending the response"); + } + else + { + if (!components.isSuccessCode(statusCode)) + dumpn("*** WARNING: non-success statusCode in onStopRequest"); + + response.end(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + dumpn("*** starting async copier of body data..."); + this._asyncCopier = + new WriteThroughCopier(this._bodyInputStream, this._connection.output, + copyObserver, null); + }, + + /** Ensures that this hasn't been ended. */ + _ensureAlive: function() + { + NS_ASSERT(!this._ended, "not handling response lifetime correctly"); + } +}; + +/** +* Size of the segments in the buffer used in storing response data and writing +* it to the socket. +*/ +Response.SEGMENT_SIZE = 8192; + +/** Serves double duty in WriteThroughCopier implementation. */ +function notImplemented() +{ + throw Cr.NS_ERROR_NOT_IMPLEMENTED; +} + +/** Returns true iff the given exception represents stream closure. */ +function streamClosed(e) +{ + return e === Cr.NS_BASE_STREAM_CLOSED || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_CLOSED); +} + +/** Returns true iff the given exception represents a blocked stream. */ +function wouldBlock(e) +{ + return e === Cr.NS_BASE_STREAM_WOULD_BLOCK || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_WOULD_BLOCK); +} + +/** +* Copies data from source to sink as it becomes available, when that data can +* be written to sink without blocking. +* +* @param source : nsIAsyncInputStream +* the stream from which data is to be read +* @param sink : nsIAsyncOutputStream +* the stream to which data is to be copied +* @param observer : nsIRequestObserver +* an observer which will be notified when the copy starts and finishes +* @param context : nsISupports +* context passed to observer when notified of start/stop +* @throws NS_ERROR_NULL_POINTER +* if source, sink, or observer are null +*/ +function WriteThroughCopier(source, sink, observer, context) +{ + if (!source || !sink || !observer) + throw Cr.NS_ERROR_NULL_POINTER; + + /** Stream from which data is being read. */ + this._source = source; + + /** Stream to which data is being written. */ + this._sink = sink; + + /** Observer watching this copy. */ + this._observer = observer; + + /** Context for the observer watching this. */ + this._context = context; + + /** +* True iff this is currently being canceled (cancel has been called, the +* callback may not yet have been made). +*/ + this._canceled = false; + + /** +* False until all data has been read from input and written to output, at +* which point this copy is completed and cancel() is asynchronously called. +*/ + this._completed = false; + + /** Required by nsIRequest, meaningless. */ + this.loadFlags = 0; + /** Required by nsIRequest, meaningless. */ + this.loadGroup = null; + /** Required by nsIRequest, meaningless. */ + this.name = "response-body-copy"; + + /** Status of this request. */ + this.status = Cr.NS_OK; + + /** Arrays of byte strings waiting to be written to output. */ + this._pendingData = []; + + // start copying + try + { + observer.onStartRequest(this, context); + this._waitToReadData(); + this._waitForSinkClosure(); + } + catch (e) + { + dumpn("!!! error starting copy: " + e + + ("lineNumber" in e ? ", line " + e.lineNumber : "")); + dumpn(e.stack); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + } +} +WriteThroughCopier.prototype = +{ + /* nsISupports implementation */ + + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIInputStreamCallback) || + iid.equals(Ci.nsIOutputStreamCallback) || + iid.equals(Ci.nsIRequest) || + iid.equals(Ci.nsISupports)) + { + return this; + } + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NSIINPUTSTREAMCALLBACK + + /** +* Receives a more-data-in-input notification and writes the corresponding +* data to the output. +* +* @param input : nsIAsyncInputStream +* the input stream on whose data we have been waiting +*/ + onInputStreamReady: function(input) + { + if (this._source === null) + return; + + dumpn("*** onInputStreamReady"); + + // + // Ordinarily we'll read a non-zero amount of data from input, queue it up + // to be written and then wait for further callbacks. The complications in + // this method are the cases where we deviate from that behavior when errors + // occur or when copying is drawing to a finish. + // + // The edge cases when reading data are: + // + // Zero data is read + // If zero data was read, we're at the end of available data, so we can + // should stop reading and move on to writing out what we have (or, if + // we've already done that, onto notifying of completion). + // A stream-closed exception is thrown + // This is effectively a less kind version of zero data being read; the + // only difference is that we notify of completion with that result + // rather than with NS_OK. + // Some other exception is thrown + // This is the least kind result. We don't know what happened, so we + // act as though the stream closed except that we notify of completion + // with the result NS_ERROR_UNEXPECTED. + // + + var bytesWanted = 0, bytesConsumed = -1; + try + { + input = new BinaryInputStream(input); + + bytesWanted = Math.min(input.available(), Response.SEGMENT_SIZE); + dumpn("*** input wanted: " + bytesWanted); + + if (bytesWanted > 0) + { + var data = input.readByteArray(bytesWanted); + bytesConsumed = data.length; + this._pendingData.push(String.fromCharCode.apply(String, data)); + } + + dumpn("*** " + bytesConsumed + " bytes read"); + + // Handle the zero-data edge case in the same place as all other edge + // cases are handled. + if (bytesWanted === 0) + throw Cr.NS_BASE_STREAM_CLOSED; + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** input stream closed"); + e = bytesWanted === 0 ? Cr.NS_OK : Cr.NS_ERROR_UNEXPECTED; + } + else + { + dumpn("!!! unexpected error reading from input, canceling: " + e); + e = Cr.NS_ERROR_UNEXPECTED; + } + + this._doneReadingSource(e); + return; + } + + var pendingData = this._pendingData; + + NS_ASSERT(bytesConsumed > 0); + NS_ASSERT(pendingData.length > 0, "no pending data somehow?"); + NS_ASSERT(pendingData[pendingData.length - 1].length > 0, + "buffered zero bytes of data?"); + + NS_ASSERT(this._source !== null); + + // Reading has gone great, and we've gotten data to write now. What if we + // don't have a place to write that data, because output went away just + // before this read? Drop everything on the floor, including new data, and + // cancel at this point. + if (this._sink === null) + { + pendingData.length = 0; + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we've read the data, and we know we have a place to write it. We + // need to queue up the data to be written, but *only* if none is queued + // already -- if data's already queued, the code that actually writes the + // data will make sure to wait on unconsumed pending data. + try + { + if (pendingData.length === 1) + this._waitToWriteData(); + } + catch (e) + { + dumpn("!!! error waiting to write data just read, swallowing and " + + "writing only what we already have: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Whee! We successfully read some data, and it's successfully queued up to + // be written. All that remains now is to wait for more data to read. + try + { + this._waitToReadData(); + } + catch (e) + { + dumpn("!!! error waiting to read more data: " + e); + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + } + }, + + + // NSIOUTPUTSTREAMCALLBACK + + /** +* Callback when data may be written to the output stream without blocking, or +* when the output stream has been closed. +* +* @param output : nsIAsyncOutputStream +* the output stream on whose writability we've been waiting, also known as +* this._sink +*/ + onOutputStreamReady: function(output) + { + if (this._sink === null) + return; + + dumpn("*** onOutputStreamReady"); + + var pendingData = this._pendingData; + if (pendingData.length === 0) + { + // There's no pending data to write. The only way this can happen is if + // we're waiting on the output stream's closure, so we can respond to a + // copying failure as quickly as possible (rather than waiting for data to + // be available to read and then fail to be copied). Therefore, we must + // be done now -- don't bother to attempt to write anything and wrap + // things up. + dumpn("!!! output stream closed prematurely, ending copy"); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + + NS_ASSERT(pendingData[0].length > 0, "queued up an empty quantum?"); + + // + // Write out the first pending quantum of data. The possible errors here + // are: + // + // The write might fail because we can't write that much data + // Okay, we've written what we can now, so re-queue what's left and + // finish writing it out later. + // The write failed because the stream was closed + // Discard pending data that we can no longer write, stop reading, and + // signal that copying finished. + // Some other error occurred. + // Same as if the stream were closed, but notify with the status + // NS_ERROR_UNEXPECTED so the observer knows something was wonky. + // + + try + { + var quantum = pendingData[0]; + + // XXX |quantum| isn't guaranteed to be ASCII, so we're relying on + // undefined behavior! We're only using this because writeByteArray + // is unusably broken for asynchronous output streams; see bug 532834 + // for details. + var bytesWritten = output.write(quantum, quantum.length); + if (bytesWritten === quantum.length) + pendingData.shift(); + else + pendingData[0] = quantum.substring(bytesWritten); + + dumpn("*** wrote " + bytesWritten + " bytes of data"); + } + catch (e) + { + if (wouldBlock(e)) + { + NS_ASSERT(pendingData.length > 0, + "stream-blocking exception with no data to write?"); + NS_ASSERT(pendingData[0].length > 0, + "stream-blocking exception with empty quantum?"); + this._waitToWriteData(); + return; + } + + if (streamClosed(e)) + dumpn("!!! output stream prematurely closed, signaling error..."); + else + dumpn("!!! unknown error: " + e + ", quantum=" + quantum); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // The day is ours! Quantum written, now let's see if we have more data + // still to write. + try + { + if (pendingData.length > 0) + { + this._waitToWriteData(); + return; + } + } + catch (e) + { + dumpn("!!! unexpected error waiting to write pending data: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we have no more pending data to write -- but might we get more in + // the future? + if (this._source !== null) + { + /* +* If we might, then wait for the output stream to be closed. (We wait +* only for closure because we have no data to write -- and if we waited +* for a specific amount of data, we would get repeatedly notified for no +* reason if over time the output stream permitted more and more data to +* be written to it without blocking.) +*/ + this._waitForSinkClosure(); + } + else + { + /* +* On the other hand, if we can't have more data because the input +* stream's gone away, then it's time to notify of copy completion. +* Victory! +*/ + this._sink = null; + this._cancelOrDispatchCancelCallback(Cr.NS_OK); + } + }, + + + // NSIREQUEST + + /** Returns true if the cancel observer hasn't been notified yet. */ + isPending: function() + { + return !this._completed; + }, + + /** Not implemented, don't use! */ + suspend: notImplemented, + /** Not implemented, don't use! */ + resume: notImplemented, + + /** +* Cancels data reading from input, asynchronously writes out any pending +* data, and causes the observer to be notified with the given error code when +* all writing has finished. +* +* @param status : nsresult +* the status to pass to the observer when data copying has been canceled +*/ + cancel: function(status) + { + dumpn("*** cancel(" + status.toString(16) + ")"); + + if (this._canceled) + { + dumpn("*** suppressing a late cancel"); + return; + } + + this._canceled = true; + this.status = status; + + // We could be in the middle of absolutely anything at this point. Both + // input and output might still be around, we might have pending data to + // write, and in general we know nothing about the state of the world. We + // therefore must assume everything's in progress and take everything to its + // final steady state (or so far as it can go before we need to finish + // writing out remaining data). + + this._doneReadingSource(status); + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Stop reading input if we haven't already done so, passing e as the status +* when closing the stream, and kick off a copy-completion notice if no more +* data remains to be written. +* +* @param e : nsresult +* the status to be used when closing the input stream +*/ + _doneReadingSource: function(e) + { + dumpn("*** _doneReadingSource(0x" + e.toString(16) + ")"); + + this._finishSource(e); + if (this._pendingData.length === 0) + this._sink = null; + else + NS_ASSERT(this._sink !== null, "null output?"); + + // If we've written out all data read up to this point, then it's time to + // signal completion. + if (this._sink === null) + { + NS_ASSERT(this._pendingData.length === 0, "pending data still?"); + this._cancelOrDispatchCancelCallback(e); + } + }, + + /** +* Stop writing output if we haven't already done so, discard any data that +* remained to be sent, close off input if it wasn't already closed, and kick +* off a copy-completion notice. +* +* @param e : nsresult +* the status to be used when closing input if it wasn't already closed +*/ + _doneWritingToSink: function(e) + { + dumpn("*** _doneWritingToSink(0x" + e.toString(16) + ")"); + + this._pendingData.length = 0; + this._sink = null; + this._doneReadingSource(e); + }, + + /** +* Completes processing of this copy: either by canceling the copy if it +* hasn't already been canceled using the provided status, or by dispatching +* the cancel callback event (with the originally provided status, of course) +* if it already has been canceled. +* +* @param status : nsresult +* the status code to use to cancel this, if this hasn't already been +* canceled +*/ + _cancelOrDispatchCancelCallback: function(status) + { + dumpn("*** _cancelOrDispatchCancelCallback(" + status + ")"); + + NS_ASSERT(this._source === null, "should have finished input"); + NS_ASSERT(this._sink === null, "should have finished output"); + NS_ASSERT(this._pendingData.length === 0, "should have no pending data"); + + if (!this._canceled) + { + this.cancel(status); + return; + } + + var self = this; + var event = + { + run: function() + { + dumpn("*** onStopRequest async callback"); + + self._completed = true; + try + { + self._observer.onStopRequest(self, self._context, self.status); + } + catch (e) + { + NS_ASSERT(false, + "how are we throwing an exception here? we control " + + "all the callers! " + e); + } + } + }; + + gThreadManager.currentThread.dispatch(event, Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** +* Kicks off another wait for more data to be available from the input stream. +*/ + _waitToReadData: function() + { + dumpn("*** _waitToReadData"); + this._source.asyncWait(this, 0, Response.SEGMENT_SIZE, + gThreadManager.mainThread); + }, + + /** +* Kicks off another wait until data can be written to the output stream. +*/ + _waitToWriteData: function() + { + dumpn("*** _waitToWriteData"); + + var pendingData = this._pendingData; + NS_ASSERT(pendingData.length > 0, "no pending data to write?"); + NS_ASSERT(pendingData[0].length > 0, "buffered an empty write?"); + + this._sink.asyncWait(this, 0, pendingData[0].length, + gThreadManager.mainThread); + }, + + /** +* Kicks off a wait for the sink to which data is being copied to be closed. +* We wait for stream closure when we don't have any data to be copied, rather +* than waiting to write a specific amount of data. We can't wait to write +* data because the sink might be infinitely writable, and if no data appears +* in the source for a long time we might have to spin quite a bit waiting to +* write, waiting to write again, &c. Waiting on stream closure instead means +* we'll get just one notification if the sink dies. Note that when data +* starts arriving from the sink we'll resume waiting for data to be written, +* dropping this closure-only callback entirely. +*/ + _waitForSinkClosure: function() + { + dumpn("*** _waitForSinkClosure"); + + this._sink.asyncWait(this, Ci.nsIAsyncOutputStream.WAIT_CLOSURE_ONLY, 0, + gThreadManager.mainThread); + }, + + /** +* Closes input with the given status, if it hasn't already been closed; +* otherwise a no-op. +* +* @param status : nsresult +* status code use to close the source stream if necessary +*/ + _finishSource: function(status) + { + dumpn("*** _finishSource(" + status.toString(16) + ")"); + + if (this._source !== null) + { + this._source.closeWithStatus(status); + this._source = null; + } + } +}; + + +/** +* A container for utility functions used with HTTP headers. +*/ +const headerUtils = +{ + /** +* Normalizes fieldName (by converting it to lowercase) and ensures it is a +* valid header field name (although not necessarily one specified in RFC +* 2616). +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not match the field-name production in RFC 2616 +* @returns string +* fieldName converted to lowercase if it is a valid header, for characters +* where case conversion is possible +*/ + normalizeFieldName: function(fieldName) + { + if (fieldName == "") + throw Cr.NS_ERROR_INVALID_ARG; + + for (var i = 0, sz = fieldName.length; i < sz; i++) + { + if (!IS_TOKEN_ARRAY[fieldName.charCodeAt(i)]) + { + dumpn(fieldName + " is not a valid header field name!"); + throw Cr.NS_ERROR_INVALID_ARG; + } + } + + return fieldName.toLowerCase(); + }, + + /** +* Ensures that fieldValue is a valid header field value (although not +* necessarily as specified in RFC 2616 if the corresponding field name is +* part of the HTTP protocol), normalizes the value if it is, and +* returns the normalized value. +* +* @param fieldValue : string +* a value to be normalized as an HTTP header field value +* @throws NS_ERROR_INVALID_ARG +* if fieldValue does not match the field-value production in RFC 2616 +* @returns string +* fieldValue as a normalized HTTP header field value +*/ + normalizeFieldValue: function(fieldValue) + { + // field-value = *( field-content | LWS ) + // field-content = <the OCTETs making up the field-value + // and consisting of either *TEXT or combinations + // of token, separators, and quoted-string> + // TEXT = <any OCTET except CTLs, + // but including LWS> + // LWS = [CRLF] 1*( SP | HT ) + // + // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + // qdtext = <any TEXT except <">> + // quoted-pair = "\" CHAR + // CHAR = <any US-ASCII character (octets 0 - 127)> + + // Any LWS that occurs between field-content MAY be replaced with a single + // SP before interpreting the field value or forwarding the message + // downstream (section 4.2); we replace 1*LWS with a single SP + var val = fieldValue.replace(/(?:(?:\r\n)?[ \t]+)+/g, " "); + + // remove leading/trailing LWS (which has been converted to SP) + val = val.replace(/^ +/, "").replace(/ +$/, ""); + + // that should have taken care of all CTLs, so val should contain no CTLs + for (var i = 0, len = val.length; i < len; i++) + if (isCTL(val.charCodeAt(i))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX disallows quoted-pair where CHAR is a CTL -- will not invalidly + // normalize, however, so this can be construed as a tightening of the + // spec and not entirely as a bug + return val; + } +}; + + + +/** +* Converts the given string into a string which is safe for use in an HTML +* context. +* +* @param str : string +* the string to make HTML-safe +* @returns string +* an HTML-safe version of str +*/ +function htmlEscape(str) +{ + // this is naive, but it'll work + var s = ""; + for (var i = 0; i < str.length; i++) + s += "&#" + str.charCodeAt(i) + ";"; + return s; +} + + +/** +* Constructs an object representing an HTTP version (see section 3.1). +* +* @param versionString +* a string of the form "#.#", where # is an non-negative decimal integer with +* or without leading zeros +* @throws +* if versionString does not specify a valid HTTP version number +*/ +function nsHttpVersion(versionString) +{ + var matches = /^(\d+)\.(\d+)$/.exec(versionString); + if (!matches) + throw "Not a valid HTTP version!"; + + /** The major version number of this, as a number. */ + this.major = parseInt(matches[1], 10); + + /** The minor version number of this, as a number. */ + this.minor = parseInt(matches[2], 10); + + if (isNaN(this.major) || isNaN(this.minor) || + this.major < 0 || this.minor < 0) + throw "Not a valid HTTP version!"; +} +nsHttpVersion.prototype = +{ + /** +* Returns the standard string representation of the HTTP version represented +* by this (e.g., "1.1"). +*/ + toString: function () + { + return this.major + "." + this.minor; + }, + + /** +* Returns true if this represents the same HTTP version as otherVersion, +* false otherwise. +* +* @param otherVersion : nsHttpVersion +* the version to compare against this +*/ + equals: function (otherVersion) + { + return this.major == otherVersion.major && + this.minor == otherVersion.minor; + }, + + /** True if this >= otherVersion, false otherwise. */ + atLeast: function(otherVersion) + { + return this.major > otherVersion.major || + (this.major == otherVersion.major && + this.minor >= otherVersion.minor); + } +}; + +nsHttpVersion.HTTP_1_0 = new nsHttpVersion("1.0"); +nsHttpVersion.HTTP_1_1 = new nsHttpVersion("1.1"); + + +/** +* An object which stores HTTP headers for a request or response. +* +* Note that since headers are case-insensitive, this object converts headers to +* lowercase before storing them. This allows the getHeader and hasHeader +* methods to work correctly for any case of a header, but it means that the +* values returned by .enumerator may not be equal case-sensitively to the +* values passed to setHeader when adding headers to this. +*/ +function nsHttpHeaders() +{ + /** +* A hash of headers, with header field names as the keys and header field +* values as the values. Header field names are case-insensitive, but upon +* insertion here they are converted to lowercase. Header field values are +* normalized upon insertion to contain no leading or trailing whitespace. +* +* Note also that per RFC 2616, section 4.2, two headers with the same name in +* a message may be treated as one header with the same field name and a field +* value consisting of the separate field values joined together with a "," in +* their original order. This hash stores multiple headers with the same name +* in this manner. +*/ + this._headers = {}; +} +nsHttpHeaders.prototype = +{ + /** +* Sets the header represented by name and value in this. +* +* @param name : string +* the header name +* @param value : string +* the header value +* @throws NS_ERROR_INVALID_ARG +* if name or value is not a valid header component +*/ + setHeader: function(fieldName, fieldValue, merge) + { + var name = headerUtils.normalizeFieldName(fieldName); + var value = headerUtils.normalizeFieldValue(fieldValue); + + // The following three headers are stored as arrays because their real-world + // syntax prevents joining individual headers into a single header using + // ",". See also <http://hg.mozilla.org/mozilla-central/diff/9b2a99adc05e/netwerk/protocol/http/src/nsHttpHeaderArray.cpp#l77> + if (merge && name in this._headers) + { + if (name === "www-authenticate" || + name === "proxy-authenticate" || + name === "set-cookie") + { + this._headers[name].push(value); + } + else + { + this._headers[name][0] += "," + value; + NS_ASSERT(this._headers[name].length === 1, + "how'd a non-special header have multiple values?") + } + } + else + { + this._headers[name] = [value]; + } + }, + + /** +* Returns the value for the header specified by this. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns string +* the field value for the given header, possibly with non-semantic changes +* (i.e., leading/trailing whitespace stripped, whitespace runs replaced +* with spaces, etc.) at the option of the implementation; multiple +* instances of the header will be combined with a comma, except for +* the three headers noted in the description of getHeaderValues +*/ + getHeader: function(fieldName) + { + return this.getHeaderValues(fieldName).join("\n"); + }, + + /** +* Returns the value for the header specified by fieldName as an array. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns [string] +* an array of all the header values in this for the given +* header name. Header values will generally be collapsed +* into a single header by joining all header values together +* with commas, but certain headers (Proxy-Authenticate, +* WWW-Authenticate, and Set-Cookie) violate the HTTP spec +* and cannot be collapsed in this manner. For these headers +* only, the returned array may contain multiple elements if +* that header has been added more than once. +*/ + getHeaderValues: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + + if (name in this._headers) + return this._headers[name]; + else + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** +* Returns true if a header with the given field name exists in this, false +* otherwise. +* +* @param fieldName : string +* the field name whose existence is to be determined in this +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @returns boolean +* true if the header's present, false otherwise +*/ + hasHeader: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + return (name in this._headers); + }, + + /** +* Returns a new enumerator over the field names of the headers in this, as +* nsISupportsStrings. The names returned will be in lowercase, regardless of +* how they were input using setHeader (header names are case-insensitive per +* RFC 2616). +*/ + get enumerator() + { + var headers = []; + for (var i in this._headers) + { + var supports = new SupportsString(); + supports.data = i; + headers.push(supports); + } + + return new nsSimpleEnumerator(headers); + } +}; + + +/** +* Constructs an nsISimpleEnumerator for the given array of items. +* +* @param items : Array +* the items, which must all implement nsISupports +*/ +function nsSimpleEnumerator(items) +{ + this._items = items; + this._nextIndex = 0; +} +nsSimpleEnumerator.prototype = +{ + hasMoreElements: function() + { + return this._nextIndex < this._items.length; + }, + getNext: function() + { + if (!this.hasMoreElements()) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + return this._items[this._nextIndex++]; + }, + QueryInterface: function(aIID) + { + if (Ci.nsISimpleEnumerator.equals(aIID) || + Ci.nsISupports.equals(aIID)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +}; + + +/** +* A representation of the data in an HTTP request. +* +* @param port : uint +* the port on which the server receiving this request runs +*/ +function Request(port) +{ + /** Method of this request, e.g. GET or POST. */ + this._method = ""; + + /** Path of the requested resource; empty paths are converted to '/'. */ + this._path = ""; + + /** Query string, if any, associated with this request (not including '?'). */ + this._queryString = ""; + + /** Scheme of requested resource, usually http, always lowercase. */ + this._scheme = "http"; + + /** Hostname on which the requested resource resides. */ + this._host = undefined; + + /** Port number over which the request was received. */ + this._port = port; + + var bodyPipe = new Pipe(false, false, 0, PR_UINT32_MAX, null); + + /** Stream from which data in this request's body may be read. */ + this._bodyInputStream = bodyPipe.inputStream; + + /** Stream to which data in this request's body is written. */ + this._bodyOutputStream = bodyPipe.outputStream; + + /** +* The headers in this request. +*/ + this._headers = new nsHttpHeaders(); + + /** +* For the addition of ad-hoc properties and new functionality without having +* to change nsIHttpRequest every time; currently lazily created, as its only +* use is in directory listings. +*/ + this._bag = null; +} +Request.prototype = +{ + // SERVER METADATA + + // + // see nsIHttpRequest.scheme + // + get scheme() + { + return this._scheme; + }, + + // + // see nsIHttpRequest.host + // + get host() + { + return this._host; + }, + + // + // see nsIHttpRequest.port + // + get port() + { + return this._port; + }, + + // REQUEST LINE + + // + // see nsIHttpRequest.method + // + get method() + { + return this._method; + }, + + // + // see nsIHttpRequest.httpVersion + // + get httpVersion() + { + return this._httpVersion.toString(); + }, + + // + // see nsIHttpRequest.path + // + get path() + { + return this._path; + }, + + // + // see nsIHttpRequest.queryString + // + get queryString() + { + return this._queryString; + }, + + // HEADERS + + // + // see nsIHttpRequest.getHeader + // + getHeader: function(name) + { + return this._headers.getHeader(name); + }, + + // + // see nsIHttpRequest.hasHeader + // + hasHeader: function(name) + { + return this._headers.hasHeader(name); + }, + + // + // see nsIHttpRequest.headers + // + get headers() + { + return this._headers.enumerator; + }, + + // + // see nsIPropertyBag.enumerator + // + get enumerator() + { + this._ensurePropertyBag(); + return this._bag.enumerator; + }, + + // + // see nsIHttpRequest.headers + // + get bodyInputStream() + { + return this._bodyInputStream; + }, + + // + // see nsIPropertyBag.getProperty + // + getProperty: function(name) + { + this._ensurePropertyBag(); + return this._bag.getProperty(name); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpRequest) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** Ensures a property bag has been created for ad-hoc behaviors. */ + _ensurePropertyBag: function() + { + if (!this._bag) + this._bag = new WritablePropertyBag(); + } +}; + + +// XPCOM trappings +if ("XPCOMUtils" in this && // Firefox 3.6 doesn't load XPCOMUtils in this scope for some reason... + "generateNSGetFactory" in XPCOMUtils) { + var NSGetFactory = XPCOMUtils.generateNSGetFactory([nsHttpServer]); +} + + + +/** +* Creates a new HTTP server listening for loopback traffic on the given port, +* starts it, and runs the server until the server processes a shutdown request, +* spinning an event loop so that events posted by the server's socket are +* processed. +* +* This method is primarily intended for use in running this script from within +* xpcshell and running a functional HTTP server without having to deal with +* non-essential details. +* +* Note that running multiple servers using variants of this method probably +* doesn't work, simply due to how the internal event loop is spun and stopped. +* +* @note +* This method only works with Mozilla 1.9 (i.e., Firefox 3 or trunk code); +* you should use this server as a component in Mozilla 1.8. +* @param port +* the port on which the server will run, or -1 if there exists no preference +* for a specific port; note that attempting to use some values for this +* parameter (particularly those below 1024) may cause this method to throw or +* may result in the server being prematurely shut down +* @param basePath +* a local directory from which requests will be served (i.e., if this is +* "/home/jwalden/" then a request to /index.html will load +* /home/jwalden/index.html); if this is omitted, only the default URLs in +* this server implementation will be functional +*/ +function server(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + // if you're running this, you probably want to see debugging info + DEBUG = true; + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", SJS_TYPE); + srv.start(port); + + var thread = gThreadManager.currentThread; + while (!srv.isStopped()) + thread.processNextEvent(true); + + // get rid of any pending requests + while (thread.hasPendingEvents()) + thread.processNextEvent(true); + + DEBUG = false; +} + +function startServerAsync(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", "sjs"); + srv.start(port); + return srv; +} + +exports.nsHttpServer = nsHttpServer; +exports.ScriptableInputStream = ScriptableInputStream; +exports.server = server; +exports.startServerAsync = startServerAsync; diff --git a/addon-sdk/source/test/addons/content-permissions/main.js b/addon-sdk/source/test/addons/content-permissions/main.js new file mode 100644 index 000000000..b476ccb74 --- /dev/null +++ b/addon-sdk/source/test/addons/content-permissions/main.js @@ -0,0 +1,89 @@ +/* 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/. */ +"use strict"; + +const { PageMod } = require("sdk/page-mod"); +const tabs = require("sdk/tabs"); +const { startServerAsync } = require("./httpd"); + +const serverPort = 8099; +const TEST_TAB_URL = "about:mozilla"; + +exports.testCrossDomainIframe = function(assert, done) { + let server = startServerAsync(serverPort); + server.registerPathHandler("/iframe", function handle(request, response) { + response.write("<html><body>foo</body></html>"); + }); + + let pageMod = PageMod({ + include: TEST_TAB_URL, + contentScript: "new " + function ContentScriptScope() { + self.on("message", function (url) { + let iframe = document.createElement("iframe"); + iframe.addEventListener("load", function onload() { + iframe.removeEventListener("load", onload, false); + self.postMessage(iframe.contentWindow.document.body.innerHTML); + }, false); + iframe.setAttribute("src", url); + document.documentElement.appendChild(iframe); + }); + }, + onAttach: function(w) { + w.on("message", function (body) { + assert.equal(body, "foo", "received iframe html content"); + pageMod.destroy(); + w.tab.close(function() { + server.stop(done); + }); + }); + + w.postMessage("http://localhost:" + serverPort + "/iframe"); + } + }); + + tabs.open({ + url: TEST_TAB_URL, + inBackground: true + }); +}; + +exports.testCrossDomainXHR = function(assert, done) { + let server = startServerAsync(serverPort); + server.registerPathHandler("/xhr", function handle(request, response) { + response.write("foo"); + }); + + let pageMod = PageMod({ + include: TEST_TAB_URL, + contentScript: "new " + function ContentScriptScope() { + self.on("message", function (url) { + let request = new XMLHttpRequest(); + request.overrideMimeType("text/plain"); + request.open("GET", url, true); + request.onload = function () { + self.postMessage(request.responseText); + }; + request.send(null); + }); + }, + onAttach: function(w) { + w.on("message", function (body) { + assert.equal(body, "foo", "received XHR content"); + pageMod.destroy(); + w.tab.close(function() { + server.stop(done); + }); + }); + + w.postMessage("http://localhost:" + serverPort + "/xhr"); + } + }); + + tabs.open({ + url: TEST_TAB_URL, + inBackground: true + }); +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/content-permissions/package.json b/addon-sdk/source/test/addons/content-permissions/package.json new file mode 100644 index 000000000..6e75d2044 --- /dev/null +++ b/addon-sdk/source/test/addons/content-permissions/package.json @@ -0,0 +1,8 @@ +{ + "id": "content-permissions@jetpack", + "permissions": { + "cross-domain-content": ["http://localhost:8099"] + }, + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/content-script-messages-latency/httpd.js b/addon-sdk/source/test/addons/content-script-messages-latency/httpd.js new file mode 100644 index 000000000..964dc9bbd --- /dev/null +++ b/addon-sdk/source/test/addons/content-script-messages-latency/httpd.js @@ -0,0 +1,5211 @@ +/* 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: do not edit this file, this is copied from: +* https://github.com/mozilla/addon-sdk/blob/master/test/lib/httpd.js +*/ + +module.metadata = { + "stability": "experimental" +}; + +const { components, CC, Cc, Ci, Cr, Cu } = require("chrome"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + +const PR_UINT32_MAX = Math.pow(2, 32) - 1; + +/** True if debugging output is enabled, false otherwise. */ +var DEBUG = false; // non-const *only* so tweakable in server tests + +/** True if debugging output should be timestamped. */ +var DEBUG_TIMESTAMP = false; // non-const so tweakable in server tests + +var gGlobalObject = Cc["@mozilla.org/systemprincipal;1"].createInstance(); + +/** +* Asserts that the given condition holds. If it doesn't, the given message is +* dumped, a stack trace is printed, and an exception is thrown to attempt to +* stop execution (which unfortunately must rely upon the exception not being +* accidentally swallowed by the code that uses it). +*/ +function NS_ASSERT(cond, msg) +{ + if (DEBUG && !cond) + { + dumpn("###!!!"); + dumpn("###!!! ASSERTION" + (msg ? ": " + msg : "!")); + dumpn("###!!! Stack follows:"); + + var stack = new Error().stack.split(/\n/); + dumpn(stack.map(function(val) { return "###!!! " + val; }).join("\n")); + + throw Cr.NS_ERROR_ABORT; + } +} + +/** Constructs an HTTP error object. */ +function HttpError(code, description) +{ + this.code = code; + this.description = description; +} +HttpError.prototype = +{ + toString: function() + { + return this.code + " " + this.description; + } +}; + +/** +* Errors thrown to trigger specific HTTP server responses. +*/ +const HTTP_400 = new HttpError(400, "Bad Request"); +const HTTP_401 = new HttpError(401, "Unauthorized"); +const HTTP_402 = new HttpError(402, "Payment Required"); +const HTTP_403 = new HttpError(403, "Forbidden"); +const HTTP_404 = new HttpError(404, "Not Found"); +const HTTP_405 = new HttpError(405, "Method Not Allowed"); +const HTTP_406 = new HttpError(406, "Not Acceptable"); +const HTTP_407 = new HttpError(407, "Proxy Authentication Required"); +const HTTP_408 = new HttpError(408, "Request Timeout"); +const HTTP_409 = new HttpError(409, "Conflict"); +const HTTP_410 = new HttpError(410, "Gone"); +const HTTP_411 = new HttpError(411, "Length Required"); +const HTTP_412 = new HttpError(412, "Precondition Failed"); +const HTTP_413 = new HttpError(413, "Request Entity Too Large"); +const HTTP_414 = new HttpError(414, "Request-URI Too Long"); +const HTTP_415 = new HttpError(415, "Unsupported Media Type"); +const HTTP_417 = new HttpError(417, "Expectation Failed"); + +const HTTP_500 = new HttpError(500, "Internal Server Error"); +const HTTP_501 = new HttpError(501, "Not Implemented"); +const HTTP_502 = new HttpError(502, "Bad Gateway"); +const HTTP_503 = new HttpError(503, "Service Unavailable"); +const HTTP_504 = new HttpError(504, "Gateway Timeout"); +const HTTP_505 = new HttpError(505, "HTTP Version Not Supported"); + +/** Creates a hash with fields corresponding to the values in arr. */ +function array2obj(arr) +{ + var obj = {}; + for (var i = 0; i < arr.length; i++) + obj[arr[i]] = arr[i]; + return obj; +} + +/** Returns an array of the integers x through y, inclusive. */ +function range(x, y) +{ + var arr = []; + for (var i = x; i <= y; i++) + arr.push(i); + return arr; +} + +/** An object (hash) whose fields are the numbers of all HTTP error codes. */ +const HTTP_ERROR_CODES = array2obj(range(400, 417).concat(range(500, 505))); + + +/** +* The character used to distinguish hidden files from non-hidden files, a la +* the leading dot in Apache. Since that mechanism also hides files from +* easy display in LXR, ls output, etc. however, we choose instead to use a +* suffix character. If a requested file ends with it, we append another +* when getting the file on the server. If it doesn't, we just look up that +* file. Therefore, any file whose name ends with exactly one of the character +* is "hidden" and available for use by the server. +*/ +const HIDDEN_CHAR = "^"; + +/** +* The file name suffix indicating the file containing overridden headers for +* a requested file. +*/ +const HEADERS_SUFFIX = HIDDEN_CHAR + "headers" + HIDDEN_CHAR; + +/** Type used to denote SJS scripts for CGI-like functionality. */ +const SJS_TYPE = "sjs"; + +/** Base for relative timestamps produced by dumpn(). */ +var firstStamp = 0; + +/** dump(str) with a trailing "\n" -- only outputs if DEBUG. */ +function dumpn(str) +{ + if (DEBUG) + { + var prefix = "HTTPD-INFO | "; + if (DEBUG_TIMESTAMP) + { + if (firstStamp === 0) + firstStamp = Date.now(); + + var elapsed = Date.now() - firstStamp; // milliseconds + var min = Math.floor(elapsed / 60000); + var sec = (elapsed % 60000) / 1000; + + if (sec < 10) + prefix += min + ":0" + sec.toFixed(3) + " | "; + else + prefix += min + ":" + sec.toFixed(3) + " | "; + } + + dump(prefix + str + "\n"); + } +} + +/** Dumps the current JS stack if DEBUG. */ +function dumpStack() +{ + // peel off the frames for dumpStack() and Error() + var stack = new Error().stack.split(/\n/).slice(2); + stack.forEach(dumpn); +} + + +/** The XPCOM thread manager. */ +var gThreadManager = null; + +/** The XPCOM prefs service. */ +var gRootPrefBranch = null; +function getRootPrefBranch() +{ + if (!gRootPrefBranch) + { + gRootPrefBranch = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefBranch); + } + return gRootPrefBranch; +} + +/** +* JavaScript constructors for commonly-used classes; precreating these is a +* speedup over doing the same from base principles. See the docs at +* http://developer.mozilla.org/en/docs/components.Constructor for details. +*/ +const ServerSocket = CC("@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init"); +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init"); +const Pipe = CC("@mozilla.org/pipe;1", + "nsIPipe", + "init"); +const FileInputStream = CC("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init"); +const ConverterInputStream = CC("@mozilla.org/intl/converter-input-stream;1", + "nsIConverterInputStream", + "init"); +const WritablePropertyBag = CC("@mozilla.org/hash-property-bag;1", + "nsIWritablePropertyBag2"); +const SupportsString = CC("@mozilla.org/supports-string;1", + "nsISupportsString"); + +/* These two are non-const only so a test can overwrite them. */ +var BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream"); +var BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream"); + +/** +* Returns the RFC 822/1123 representation of a date. +* +* @param date : Number +* the date, in milliseconds from midnight (00:00:00), January 1, 1970 GMT +* @returns string +* the representation of the given date +*/ +function toDateString(date) +{ + // + // rfc1123-date = wkday "," SP date1 SP time SP "GMT" + // date1 = 2DIGIT SP month SP 4DIGIT + // ; day month year (e.g., 02 Jun 1982) + // time = 2DIGIT ":" 2DIGIT ":" 2DIGIT + // ; 00:00:00 - 23:59:59 + // wkday = "Mon" | "Tue" | "Wed" + // | "Thu" | "Fri" | "Sat" | "Sun" + // month = "Jan" | "Feb" | "Mar" | "Apr" + // | "May" | "Jun" | "Jul" | "Aug" + // | "Sep" | "Oct" | "Nov" | "Dec" + // + + const wkdayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + /** +* Processes a date and returns the encoded UTC time as a string according to +* the format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toTime(date) + { + var hrs = date.getUTCHours(); + var rv = (hrs < 10) ? "0" + hrs : hrs; + + var mins = date.getUTCMinutes(); + rv += ":"; + rv += (mins < 10) ? "0" + mins : mins; + + var secs = date.getUTCSeconds(); + rv += ":"; + rv += (secs < 10) ? "0" + secs : secs; + + return rv; + } + + /** +* Processes a date and returns the encoded UTC date as a string according to +* the date1 format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toDate1(date) + { + var day = date.getUTCDate(); + var month = date.getUTCMonth(); + var year = date.getUTCFullYear(); + + var rv = (day < 10) ? "0" + day : day; + rv += " " + monthStrings[month]; + rv += " " + year; + + return rv; + } + + date = new Date(date); + + const fmtString = "%wkday%, %date1% %time% GMT"; + var rv = fmtString.replace("%wkday%", wkdayStrings[date.getUTCDay()]); + rv = rv.replace("%time%", toTime(date)); + return rv.replace("%date1%", toDate1(date)); +} + +/** +* Prints out a human-readable representation of the object o and its fields, +* omitting those whose names begin with "_" if showMembers != true (to ignore +* "private" properties exposed via getters/setters). +*/ +function printObj(o, showMembers) +{ + var s = "******************************\n"; + s += "o = {\n"; + for (var i in o) + { + if (typeof(i) != "string" || + (showMembers || (i.length > 0 && i[0] != "_"))) + s+= " " + i + ": " + o[i] + ",\n"; + } + s += " };\n"; + s += "******************************"; + dumpn(s); +} + +/** +* Instantiates a new HTTP server. +*/ +function nsHttpServer() +{ + if (!gThreadManager) + gThreadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + + /** The port on which this server listens. */ + this._port = undefined; + + /** The socket associated with this. */ + this._socket = null; + + /** The handler used to process requests to this server. */ + this._handler = new ServerHandler(this); + + /** Naming information for this server. */ + this._identity = new ServerIdentity(); + + /** +* Indicates when the server is to be shut down at the end of the request. +*/ + this._doQuit = false; + + /** +* True if the socket in this is closed (and closure notifications have been +* sent and processed if the socket was ever opened), false otherwise. +*/ + this._socketClosed = true; + + /** +* Used for tracking existing connections and ensuring that all connections +* are properly cleaned up before server shutdown; increases by 1 for every +* new incoming connection. +*/ + this._connectionGen = 0; + + /** +* Hash of all open connections, indexed by connection number at time of +* creation. +*/ + this._connections = {}; +} +nsHttpServer.prototype = +{ + classID: components.ID("{54ef6f81-30af-4b1d-ac55-8ba811293e41}"), + + // NSISERVERSOCKETLISTENER + + /** +* Processes an incoming request coming in on the given socket and contained +* in the given transport. +* +* @param socket : nsIServerSocket +* the socket through which the request was served +* @param trans : nsISocketTransport +* the transport for the request/response +* @see nsIServerSocketListener.onSocketAccepted +*/ + onSocketAccepted: function(socket, trans) + { + dumpn("*** onSocketAccepted(socket=" + socket + ", trans=" + trans + ")"); + + dumpn(">>> new connection on " + trans.host + ":" + trans.port); + + const SEGMENT_SIZE = 8192; + const SEGMENT_COUNT = 1024; + try + { + var input = trans.openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT) + .QueryInterface(Ci.nsIAsyncInputStream); + var output = trans.openOutputStream(0, 0, 0); + } + catch (e) + { + dumpn("*** error opening transport streams: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + var connectionNumber = ++this._connectionGen; + + try + { + var conn = new Connection(input, output, this, socket.port, trans.port, + connectionNumber); + var reader = new RequestReader(conn); + + // XXX add request timeout functionality here! + + // Note: must use main thread here, or we might get a GC that will cause + // threadsafety assertions. We really need to fix XPConnect so that + // you can actually do things in multi-threaded JS. :-( + input.asyncWait(reader, 0, 0, gThreadManager.mainThread); + } + catch (e) + { + // Assume this connection can't be salvaged and bail on it completely; + // don't attempt to close it so that we can assert that any connection + // being closed is in this._connections. + dumpn("*** error in initial request-processing stages: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + this._connections[connectionNumber] = conn; + dumpn("*** starting connection " + connectionNumber); + }, + + /** +* Called when the socket associated with this is closed. +* +* @param socket : nsIServerSocket +* the socket being closed +* @param status : nsresult +* the reason the socket stopped listening (NS_BINDING_ABORTED if the server +* was stopped using nsIHttpServer.stop) +* @see nsIServerSocketListener.onStopListening +*/ + onStopListening: function(socket, status) + { + dumpn(">>> shutting down server on port " + socket.port); + this._socketClosed = true; + if (!this._hasOpenConnections()) + { + dumpn("*** no open connections, notifying async from onStopListening"); + + // Notify asynchronously so that any pending teardown in stop() has a + // chance to run first. + var self = this; + var stopEvent = + { + run: function() + { + dumpn("*** _notifyStopped async callback"); + self._notifyStopped(); + } + }; + gThreadManager.currentThread + .dispatch(stopEvent, Ci.nsIThread.DISPATCH_NORMAL); + } + }, + + // NSIHTTPSERVER + + // + // see nsIHttpServer.start + // + start: function(port) + { + this._start(port, "localhost") + }, + + _start: function(port, host) + { + if (this._socket) + throw Cr.NS_ERROR_ALREADY_INITIALIZED; + + this._port = port; + this._doQuit = this._socketClosed = false; + + this._host = host; + + // The listen queue needs to be long enough to handle + // network.http.max-persistent-connections-per-server concurrent connections, + // plus a safety margin in case some other process is talking to + // the server as well. + var prefs = getRootPrefBranch(); + var maxConnections; + try { + // Bug 776860: The original pref was removed in favor of this new one: + maxConnections = prefs.getIntPref("network.http.max-persistent-connections-per-server") + 5; + } + catch(e) { + maxConnections = prefs.getIntPref("network.http.max-connections-per-server") + 5; + } + + try + { + var loopback = true; + if (this._host != "127.0.0.1" && this._host != "localhost") { + var loopback = false; + } + + var socket = new ServerSocket(this._port, + loopback, // true = localhost, false = everybody + maxConnections); + dumpn(">>> listening on port " + socket.port + ", " + maxConnections + + " pending connections"); + socket.asyncListen(this); + this._identity._initialize(socket.port, host, true); + this._socket = socket; + } + catch (e) + { + dumpn("!!! could not start server on port " + port + ": " + e); + throw Cr.NS_ERROR_NOT_AVAILABLE; + } + }, + + // + // see nsIHttpServer.stop + // + stop: function(callback) + { + if (!callback) + throw Cr.NS_ERROR_NULL_POINTER; + if (!this._socket) + throw Cr.NS_ERROR_UNEXPECTED; + + this._stopCallback = typeof callback === "function" + ? callback + : function() { callback.onStopped(); }; + + dumpn(">>> stopping listening on port " + this._socket.port); + this._socket.close(); + this._socket = null; + + // We can't have this identity any more, and the port on which we're running + // this server now could be meaningless the next time around. + this._identity._teardown(); + + this._doQuit = false; + + // socket-close notification and pending request completion happen async + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (file && (!file.exists() || file.isDirectory())) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handler.registerFile(path, file); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // XXX true path validation! + if (path.charAt(0) != "/" || + path.charAt(path.length - 1) != "/" || + (directory && + (!directory.exists() || !directory.isDirectory()))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX determine behavior of nonexistent /foo/bar when a /foo/bar/ mapping + // exists! + + this._handler.registerDirectory(path, directory); + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + this._handler.registerPathHandler(path, handler); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + this._handler.registerPrefixHandler(prefix, handler); + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(code, handler) + { + this._handler.registerErrorHandler(code, handler); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + this._handler.setIndexHandler(handler); + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + this._handler.registerContentType(ext, type); + }, + + // + // see nsIHttpServer.serverIdentity + // + get identity() + { + return this._identity; + }, + + // + // see nsIHttpServer.getState + // + getState: function(path, k) + { + return this._handler._getState(path, k); + }, + + // + // see nsIHttpServer.setState + // + setState: function(path, k, v) + { + return this._handler._setState(path, k, v); + }, + + // + // see nsIHttpServer.getSharedState + // + getSharedState: function(k) + { + return this._handler._getSharedState(k); + }, + + // + // see nsIHttpServer.setSharedState + // + setSharedState: function(k, v) + { + return this._handler._setSharedState(k, v); + }, + + // + // see nsIHttpServer.getObjectState + // + getObjectState: function(k) + { + return this._handler._getObjectState(k); + }, + + // + // see nsIHttpServer.setObjectState + // + setObjectState: function(k, v) + { + return this._handler._setObjectState(k, v); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIServerSocketListener) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NON-XPCOM PUBLIC API + + /** +* Returns true iff this server is not running (and is not in the process of +* serving any requests still to be processed when the server was last +* stopped after being run). +*/ + isStopped: function() + { + return this._socketClosed && !this._hasOpenConnections(); + }, + + // PRIVATE IMPLEMENTATION + + /** True if this server has any open connections to it, false otherwise. */ + _hasOpenConnections: function() + { + // + // If we have any open connections, they're tracked as numeric properties on + // |this._connections|. The non-standard __count__ property could be used + // to check whether there are any properties, but standard-wise, even + // looking forward to ES5, there's no less ugly yet still O(1) way to do + // this. + // + for (var n in this._connections) + return true; + return false; + }, + + /** Calls the server-stopped callback provided when stop() was called. */ + _notifyStopped: function() + { + NS_ASSERT(this._stopCallback !== null, "double-notifying?"); + NS_ASSERT(!this._hasOpenConnections(), "should be done serving by now"); + + // + // NB: We have to grab this now, null out the member, *then* call the + // callback here, or otherwise the callback could (indirectly) futz with + // this._stopCallback by starting and immediately stopping this, at + // which point we'd be nulling out a field we no longer have a right to + // modify. + // + var callback = this._stopCallback; + this._stopCallback = null; + try + { + callback(); + } + catch (e) + { + // not throwing because this is specified as being usually (but not + // always) asynchronous + dump("!!! error running onStopped callback: " + e + "\n"); + } + }, + + /** +* Notifies this server that the given connection has been closed. +* +* @param connection : Connection +* the connection that was closed +*/ + _connectionClosed: function(connection) + { + NS_ASSERT(connection.number in this._connections, + "closing a connection " + this + " that we never added to the " + + "set of open connections?"); + NS_ASSERT(this._connections[connection.number] === connection, + "connection number mismatch? " + + this._connections[connection.number]); + delete this._connections[connection.number]; + + // Fire a pending server-stopped notification if it's our responsibility. + if (!this._hasOpenConnections() && this._socketClosed) + this._notifyStopped(); + }, + + /** +* Requests that the server be shut down when possible. +*/ + _requestQuit: function() + { + dumpn(">>> requesting a quit"); + dumpStack(); + this._doQuit = true; + } +}; + + +// +// RFC 2396 section 3.2.2: +// +// host = hostname | IPv4address +// hostname = *( domainlabel "." ) toplabel [ "." ] +// domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum +// toplabel = alpha | alpha *( alphanum | "-" ) alphanum +// IPv4address = 1*digit "." 1*digit "." 1*digit "." 1*digit +// + +const HOST_REGEX = + new RegExp("^(?:" + + // *( domainlabel "." ) + "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*" + + // toplabel + "[a-z](?:[a-z0-9-]*[a-z0-9])?" + + "|" + + // IPv4 address + "\\d+\\.\\d+\\.\\d+\\.\\d+" + + ")$", + "i"); + + +/** +* Represents the identity of a server. An identity consists of a set of +* (scheme, host, port) tuples denoted as locations (allowing a single server to +* serve multiple sites or to be used behind both HTTP and HTTPS proxies for any +* host/port). Any incoming request must be to one of these locations, or it +* will be rejected with an HTTP 400 error. One location, denoted as the +* primary location, is the location assigned in contexts where a location +* cannot otherwise be endogenously derived, such as for HTTP/1.0 requests. +* +* A single identity may contain at most one location per unique host/port pair; +* other than that, no restrictions are placed upon what locations may +* constitute an identity. +*/ +function ServerIdentity() +{ + /** The scheme of the primary location. */ + this._primaryScheme = "http"; + + /** The hostname of the primary location. */ + this._primaryHost = "127.0.0.1" + + /** The port number of the primary location. */ + this._primaryPort = -1; + + /** +* The current port number for the corresponding server, stored so that a new +* primary location can always be set if the current one is removed. +*/ + this._defaultPort = -1; + + /** +* Maps hosts to maps of ports to schemes, e.g. the following would represent +* https://example.com:789/ and http://example.org/: +* +* { +* "xexample.com": { 789: "https" }, +* "xexample.org": { 80: "http" } +* } +* +* Note the "x" prefix on hostnames, which prevents collisions with special +* JS names like "prototype". +*/ + this._locations = { "xlocalhost": {} }; +} +ServerIdentity.prototype = +{ + // NSIHTTPSERVERIDENTITY + + // + // see nsIHttpServerIdentity.primaryScheme + // + get primaryScheme() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryScheme; + }, + + // + // see nsIHttpServerIdentity.primaryHost + // + get primaryHost() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryHost; + }, + + // + // see nsIHttpServerIdentity.primaryPort + // + get primaryPort() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryPort; + }, + + // + // see nsIHttpServerIdentity.add + // + add: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + this._locations["x" + host] = entry = {}; + + entry[port] = scheme; + }, + + // + // see nsIHttpServerIdentity.remove + // + remove: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return false; + + var present = port in entry; + delete entry[port]; + + if (this._primaryScheme == scheme && + this._primaryHost == host && + this._primaryPort == port && + this._defaultPort !== -1) + { + // Always keep at least one identity in existence at any time, unless + // we're in the process of shutting down (the last condition above). + this._primaryPort = -1; + this._initialize(this._defaultPort, host, false); + } + + return present; + }, + + // + // see nsIHttpServerIdentity.has + // + has: function(scheme, host, port) + { + this._validate(scheme, host, port); + + return "x" + host in this._locations && + scheme === this._locations["x" + host][port]; + }, + + // + // see nsIHttpServerIdentity.has + // + getScheme: function(host, port) + { + this._validate("http", host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return ""; + + return entry[port] || ""; + }, + + // + // see nsIHttpServerIdentity.setPrimary + // + setPrimary: function(scheme, host, port) + { + this._validate(scheme, host, port); + + this.add(scheme, host, port); + + this._primaryScheme = scheme; + this._primaryHost = host; + this._primaryPort = port; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpServerIdentity) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Initializes the primary name for the corresponding server, based on the +* provided port number. +*/ + _initialize: function(port, host, addSecondaryDefault) + { + this._host = host; + if (this._primaryPort !== -1) + this.add("http", host, port); + else + this.setPrimary("http", "localhost", port); + this._defaultPort = port; + + // Only add this if we're being called at server startup + if (addSecondaryDefault && host != "127.0.0.1") + this.add("http", "127.0.0.1", port); + }, + + /** +* Called at server shutdown time, unsets the primary location only if it was +* the default-assigned location and removes the default location from the +* set of locations used. +*/ + _teardown: function() + { + if (this._host != "127.0.0.1") { + // Not the default primary location, nothing special to do here + this.remove("http", "127.0.0.1", this._defaultPort); + } + + // This is a *very* tricky bit of reasoning here; make absolutely sure the + // tests for this code pass before you commit changes to it. + if (this._primaryScheme == "http" && + this._primaryHost == this._host && + this._primaryPort == this._defaultPort) + { + // Make sure we don't trigger the readding logic in .remove(), then remove + // the default location. + var port = this._defaultPort; + this._defaultPort = -1; + this.remove("http", this._host, port); + + // Ensure a server start triggers the setPrimary() path in ._initialize() + this._primaryPort = -1; + } + else + { + // No reason not to remove directly as it's not our primary location + this.remove("http", this._host, this._defaultPort); + } + }, + + /** +* Ensures scheme, host, and port are all valid with respect to RFC 2396. +* +* @throws NS_ERROR_ILLEGAL_VALUE +* if any argument doesn't match the corresponding production +*/ + _validate: function(scheme, host, port) + { + if (scheme !== "http" && scheme !== "https") + { + dumpn("*** server only supports http/https schemes: '" + scheme + "'"); + dumpStack(); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (!HOST_REGEX.test(host)) + { + dumpn("*** unexpected host: '" + host + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (port < 0 || port > 65535) + { + dumpn("*** unexpected port: '" + port + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + } +}; + + +/** +* Represents a connection to the server (and possibly in the future the thread +* on which the connection is processed). +* +* @param input : nsIInputStream +* stream from which incoming data on the connection is read +* @param output : nsIOutputStream +* stream to write data out the connection +* @param server : nsHttpServer +* the server handling the connection +* @param port : int +* the port on which the server is running +* @param outgoingPort : int +* the outgoing port used by this connection +* @param number : uint +* a serial number used to uniquely identify this connection +*/ +function Connection(input, output, server, port, outgoingPort, number) +{ + dumpn("*** opening new connection " + number + " on port " + outgoingPort); + + /** Stream of incoming data. */ + this.input = input; + + /** Stream for outgoing data. */ + this.output = output; + + /** The server associated with this request. */ + this.server = server; + + /** The port on which the server is running. */ + this.port = port; + + /** The outgoing poort used by this connection. */ + this._outgoingPort = outgoingPort; + + /** The serial number of this connection. */ + this.number = number; + + /** +* The request for which a response is being generated, null if the +* incoming request has not been fully received or if it had errors. +*/ + this.request = null; + + /** State variables for debugging. */ + this._closed = this._processed = false; +} +Connection.prototype = +{ + /** Closes this connection's input/output streams. */ + close: function() + { + dumpn("*** closing connection " + this.number + + " on port " + this._outgoingPort); + + this.input.close(); + this.output.close(); + this._closed = true; + + var server = this.server; + server._connectionClosed(this); + + // If an error triggered a server shutdown, act on it now + if (server._doQuit) + server.stop(function() { /* not like we can do anything better */ }); + }, + + /** +* Initiates processing of this connection, using the data in the given +* request. +* +* @param request : Request +* the request which should be processed +*/ + process: function(request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + + this.request = request; + this.server._handler.handleResponse(this); + }, + + /** +* Initiates processing of this connection, generating a response with the +* given HTTP error code. +* +* @param code : uint +* an HTTP code, so in the range [0, 1000) +* @param request : Request +* incomplete data about the incoming request (since there were errors +* during its processing +*/ + processError: function(code, request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + this.request = request; + this.server._handler.handleError(code, this); + }, + + /** Converts this to a string for debugging purposes. */ + toString: function() + { + return "<Connection(" + this.number + + (this.request ? ", " + this.request.path : "") +"): " + + (this._closed ? "closed" : "open") + ">"; + } +}; + + + +/** Returns an array of count bytes from the given input stream. */ +function readBytes(inputStream, count) +{ + return new BinaryInputStream(inputStream).readByteArray(count); +} + + + +/** Request reader processing states; see RequestReader for details. */ +const READER_IN_REQUEST_LINE = 0; +const READER_IN_HEADERS = 1; +const READER_IN_BODY = 2; +const READER_FINISHED = 3; + + +/** +* Reads incoming request data asynchronously, does any necessary preprocessing, +* and forwards it to the request handler. Processing occurs in three states: +* +* READER_IN_REQUEST_LINE Reading the request's status line +* READER_IN_HEADERS Reading headers in the request +* READER_IN_BODY Reading the body of the request +* READER_FINISHED Entire request has been read and processed +* +* During the first two stages, initial metadata about the request is gathered +* into a Request object. Once the status line and headers have been processed, +* we start processing the body of the request into the Request. Finally, when +* the entire body has been read, we create a Response and hand it off to the +* ServerHandler to be given to the appropriate request handler. +* +* @param connection : Connection +* the connection for the request being read +*/ +function RequestReader(connection) +{ + /** Connection metadata for this request. */ + this._connection = connection; + + /** +* A container providing line-by-line access to the raw bytes that make up the +* data which has been read from the connection but has not yet been acted +* upon (by passing it to the request handler or by extracting request +* metadata from it). +*/ + this._data = new LineData(); + + /** +* The amount of data remaining to be read from the body of this request. +* After all headers in the request have been read this is the value in the +* Content-Length header, but as the body is read its value decreases to zero. +*/ + this._contentLength = 0; + + /** The current state of parsing the incoming request. */ + this._state = READER_IN_REQUEST_LINE; + + /** Metadata constructed from the incoming request for the request handler. */ + this._metadata = new Request(connection.port); + + /** +* Used to preserve state if we run out of line data midway through a +* multi-line header. _lastHeaderName stores the name of the header, while +* _lastHeaderValue stores the value we've seen so far for the header. +* +* These fields are always either both undefined or both strings. +*/ + this._lastHeaderName = this._lastHeaderValue = undefined; +} +RequestReader.prototype = +{ + // NSIINPUTSTREAMCALLBACK + + /** +* Called when more data from the incoming request is available. This method +* then reads the available data from input and deals with that data as +* necessary, depending upon the syntax of already-downloaded data. +* +* @param input : nsIAsyncInputStream +* the stream of incoming data from the connection +*/ + onInputStreamReady: function(input) + { + dumpn("*** onInputStreamReady(input=" + input + ") on thread " + + gThreadManager.currentThread + " (main is " + + gThreadManager.mainThread + ")"); + dumpn("*** this._state == " + this._state); + + // Handle cases where we get more data after a request error has been + // discovered but *before* we can close the connection. + var data = this._data; + if (!data) + return; + + try + { + data.appendBytes(readBytes(input, input.available())); + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** WARNING: unexpected error when reading from socket; will " + + "be treated as if the input stream had been closed"); + dumpn("*** WARNING: actual error was: " + e); + } + + // We've lost a race -- input has been closed, but we're still expecting + // to read more data. available() will throw in this case, and since + // we're dead in the water now, destroy the connection. + dumpn("*** onInputStreamReady called on a closed input, destroying " + + "connection"); + this._connection.close(); + return; + } + + switch (this._state) + { + default: + NS_ASSERT(false, "invalid state: " + this._state); + break; + + case READER_IN_REQUEST_LINE: + if (!this._processRequestLine()) + break; + /* fall through */ + + case READER_IN_HEADERS: + if (!this._processHeaders()) + break; + /* fall through */ + + case READER_IN_BODY: + this._processBody(); + } + + if (this._state != READER_FINISHED) + input.asyncWait(this, 0, 0, gThreadManager.currentThread); + }, + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIInputStreamCallback) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE API + + /** +* Processes unprocessed, downloaded data as a request line. +* +* @returns boolean +* true iff the request line has been fully processed +*/ + _processRequestLine: function() + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + // Servers SHOULD ignore any empty line(s) received where a Request-Line + // is expected (section 4.1). + var data = this._data; + var line = {}; + var readSuccess; + while ((readSuccess = data.readLine(line)) && line.value == "") + dumpn("*** ignoring beginning blank line..."); + + // if we don't have a full line, wait until we do + if (!readSuccess) + return false; + + // we have the first non-blank line + try + { + this._parseRequestLine(line.value); + this._state = READER_IN_HEADERS; + return true; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing request headers. +* +* @returns boolean +* true iff header data in the request has been fully processed +*/ + _processHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + // XXX things to fix here: + // + // - need to support RFC 2047-encoded non-US-ASCII characters + + try + { + var done = this._parseHeaders(); + if (done) + { + var request = this._metadata; + + // XXX this is wrong for requests with transfer-encodings applied to + // them, particularly chunked (which by its nature can have no + // meaningful Content-Length header)! + this._contentLength = request.hasHeader("Content-Length") + ? parseInt(request.getHeader("Content-Length"), 10) + : 0; + dumpn("_processHeaders, Content-length=" + this._contentLength); + + this._state = READER_IN_BODY; + } + return done; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing the request body. +* +* @returns boolean +* true iff the request body has been fully processed +*/ + _processBody: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + // XXX handle chunked transfer-coding request bodies! + + try + { + if (this._contentLength > 0) + { + var data = this._data.purge(); + var count = Math.min(data.length, this._contentLength); + dumpn("*** loading data=" + data + " len=" + data.length + + " excess=" + (data.length - count)); + + var bos = new BinaryOutputStream(this._metadata._bodyOutputStream); + bos.writeByteArray(data, count); + this._contentLength -= count; + } + + dumpn("*** remaining body data len=" + this._contentLength); + if (this._contentLength == 0) + { + this._validateRequest(); + this._state = READER_FINISHED; + this._handleResponse(); + return true; + } + + return false; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Does various post-header checks on the data in this request. +* +* @throws : HttpError +* if the request was malformed in some way +*/ + _validateRequest: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + dumpn("*** _validateRequest"); + + var metadata = this._metadata; + var headers = metadata._headers; + + // 19.6.1.1 -- servers MUST report 400 to HTTP/1.1 requests w/o Host header + var identity = this._connection.server.identity; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + { + if (!headers.hasHeader("Host")) + { + dumpn("*** malformed HTTP/1.1 or greater request with no Host header!"); + throw HTTP_400; + } + + // If the Request-URI wasn't absolute, then we need to determine our host. + // We have to determine what scheme was used to access us based on the + // server identity data at this point, because the request just doesn't + // contain enough data on its own to do this, sadly. + if (!metadata._host) + { + var host, port; + var hostPort = headers.getHeader("Host"); + var colon = hostPort.indexOf(":"); + if (colon < 0) + { + host = hostPort; + port = ""; + } + else + { + host = hostPort.substring(0, colon); + port = hostPort.substring(colon + 1); + } + + // NB: We allow an empty port here because, oddly, a colon may be + // present even without a port number, e.g. "example.com:"; in this + // case the default port applies. + if (!HOST_REGEX.test(host) || !/^\d*$/.test(port)) + { + dumpn("*** malformed hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + // If we're not given a port, we're stuck, because we don't know what + // scheme to use to look up the correct port here, in general. Since + // the HTTPS case requires a tunnel/proxy and thus requires that the + // requested URI be absolute (and thus contain the necessary + // information), let's assume HTTP will prevail and use that. + port = +port || 80; + + var scheme = identity.getScheme(host, port); + if (!scheme) + { + dumpn("*** unrecognized hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + } + } + else + { + NS_ASSERT(metadata._host === undefined, + "HTTP/1.0 doesn't allow absolute paths in the request line!"); + + metadata._scheme = identity.primaryScheme; + metadata._host = identity.primaryHost; + metadata._port = identity.primaryPort; + } + + NS_ASSERT(identity.has(metadata._scheme, metadata._host, metadata._port), + "must have a location we recognize by now!"); + }, + + /** +* Handles responses in case of error, either in the server or in the request. +* +* @param e +* the specific error encountered, which is an HttpError in the case where +* the request is in some way invalid or cannot be fulfilled; if this isn't +* an HttpError we're going to be paranoid and shut down, because that +* shouldn't happen, ever +*/ + _handleError: function(e) + { + // Don't fall back into normal processing! + this._state = READER_FINISHED; + + var server = this._connection.server; + if (e instanceof HttpError) + { + var code = e.code; + } + else + { + dumpn("!!! UNEXPECTED ERROR: " + e + + (e.lineNumber ? ", line " + e.lineNumber : "")); + + // no idea what happened -- be paranoid and shut down + code = 500; + server._requestQuit(); + } + + // make attempted reuse of data an error + this._data = null; + + this._connection.processError(code, this._metadata); + }, + + /** +* Now that we've read the request line and headers, we can actually hand off +* the request to be handled. +* +* This method is called once per request, after the request line and all +* headers and the body, if any, have been received. +*/ + _handleResponse: function() + { + NS_ASSERT(this._state == READER_FINISHED); + + // We don't need the line-based data any more, so make attempted reuse an + // error. + this._data = null; + + this._connection.process(this._metadata); + }, + + + // PARSING + + /** +* Parses the request line for the HTTP request associated with this. +* +* @param line : string +* the request line +*/ + _parseRequestLine: function(line) + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + dumpn("*** _parseRequestLine('" + line + "')"); + + var metadata = this._metadata; + + // clients and servers SHOULD accept any amount of SP or HT characters + // between fields, even though only a single SP is required (section 19.3) + var request = line.split(/[ \t]+/); + if (!request || request.length != 3) + throw HTTP_400; + + metadata._method = request[0]; + + // get the HTTP version + var ver = request[2]; + var match = ver.match(/^HTTP\/(\d+\.\d+)$/); + if (!match) + throw HTTP_400; + + // determine HTTP version + try + { + metadata._httpVersion = new nsHttpVersion(match[1]); + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_0)) + throw "unsupported HTTP version"; + } + catch (e) + { + // we support HTTP/1.0 and HTTP/1.1 only + throw HTTP_501; + } + + + var fullPath = request[1]; + var serverIdentity = this._connection.server.identity; + + var scheme, host, port; + + if (fullPath.charAt(0) != "/") + { + // No absolute paths in the request line in HTTP prior to 1.1 + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + throw HTTP_400; + + try + { + var uri = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService) + .newURI(fullPath, null, null); + fullPath = uri.path; + scheme = uri.scheme; + host = metadata._host = uri.asciiHost; + port = uri.port; + if (port === -1) + { + if (scheme === "http") + port = 80; + else if (scheme === "https") + port = 443; + else + throw HTTP_400; + } + } + catch (e) + { + // If the host is not a valid host on the server, the response MUST be a + // 400 (Bad Request) error message (section 5.2). Alternately, the URI + // is malformed. + throw HTTP_400; + } + + if (!serverIdentity.has(scheme, host, port) || fullPath.charAt(0) != "/") + throw HTTP_400; + } + + var splitter = fullPath.indexOf("?"); + if (splitter < 0) + { + // _queryString already set in ctor + metadata._path = fullPath; + } + else + { + metadata._path = fullPath.substring(0, splitter); + metadata._queryString = fullPath.substring(splitter + 1); + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + }, + + /** +* Parses all available HTTP headers in this until the header-ending CRLFCRLF, +* adding them to the store of headers in the request. +* +* @throws +* HTTP_400 if the headers are malformed +* @returns boolean +* true if all headers have now been processed, false otherwise +*/ + _parseHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + dumpn("*** _parseHeaders"); + + var data = this._data; + + var headers = this._metadata._headers; + var lastName = this._lastHeaderName; + var lastVal = this._lastHeaderValue; + + var line = {}; + while (true) + { + NS_ASSERT(!((lastVal === undefined) ^ (lastName === undefined)), + lastName === undefined ? + "lastVal without lastName? lastVal: '" + lastVal + "'" : + "lastName without lastVal? lastName: '" + lastName + "'"); + + if (!data.readLine(line)) + { + // save any data we have from the header we might still be processing + this._lastHeaderName = lastName; + this._lastHeaderValue = lastVal; + return false; + } + + var lineText = line.value; + var firstChar = lineText.charAt(0); + + // blank line means end of headers + if (lineText == "") + { + // we're finished with the previous header + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + else + { + // no headers in request -- valid for HTTP/1.0 requests + } + + // either way, we're done processing headers + this._state = READER_IN_BODY; + return true; + } + else if (firstChar == " " || firstChar == "\t") + { + // multi-line header if we've already seen a header line + if (!lastName) + { + // we don't have a header to continue! + throw HTTP_400; + } + + // append this line's text to the value; starts with SP/HT, so no need + // for separating whitespace + lastVal += lineText; + } + else + { + // we have a new header, so set the old one (if one existed) + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + + var colon = lineText.indexOf(":"); // first colon must be splitter + if (colon < 1) + { + // no colon or missing header field-name + throw HTTP_400; + } + + // set header name, value (to be set in the next loop, usually) + lastName = lineText.substring(0, colon); + lastVal = lineText.substring(colon + 1); + } // empty, continuation, start of header + } // while (true) + } +}; + + +/** The character codes for CR and LF. */ +const CR = 0x0D, LF = 0x0A; + +/** +* Calculates the number of characters before the first CRLF pair in array, or +* -1 if the array contains no CRLF pair. +* +* @param array : Array +* an array of numbers in the range [0, 256), each representing a single +* character; the first CRLF is the lowest index i where +* |array[i] == "\r".charCodeAt(0)| and |array[i+1] == "\n".charCodeAt(0)|, +* if such an |i| exists, and -1 otherwise +* @returns int +* the index of the first CRLF if any were present, -1 otherwise +*/ +function findCRLF(array) +{ + for (var i = array.indexOf(CR); i >= 0; i = array.indexOf(CR, i + 1)) + { + if (array[i + 1] == LF) + return i; + } + return -1; +} + + +/** +* A container which provides line-by-line access to the arrays of bytes with +* which it is seeded. +*/ +function LineData() +{ + /** An array of queued bytes from which to get line-based characters. */ + this._data = []; +} +LineData.prototype = +{ + /** +* Appends the bytes in the given array to the internal data cache maintained +* by this. +*/ + appendBytes: function(bytes) + { + Array.prototype.push.apply(this._data, bytes); + }, + + /** +* Removes and returns a line of data, delimited by CRLF, from this. +* +* @param out +* an object whose "value" property will be set to the first line of text +* present in this, sans CRLF, if this contains a full CRLF-delimited line +* of text; if this doesn't contain enough data, the value of the property +* is undefined +* @returns boolean +* true if a full line of data could be read from the data in this, false +* otherwise +*/ + readLine: function(out) + { + var data = this._data; + var length = findCRLF(data); + if (length < 0) + return false; + + // + // We have the index of the CR, so remove all the characters, including + // CRLF, from the array with splice, and convert the removed array into the + // corresponding string, from which we then strip the trailing CRLF. + // + // Getting the line in this matter acknowledges that substring is an O(1) + // operation in SpiderMonkey because strings are immutable, whereas two + // splices, both from the beginning of the data, are less likely to be as + // cheap as a single splice plus two extra character conversions. + // + var line = String.fromCharCode.apply(null, data.splice(0, length + 2)); + out.value = line.substring(0, length); + + return true; + }, + + /** +* Removes the bytes currently within this and returns them in an array. +* +* @returns Array +* the bytes within this when this method is called +*/ + purge: function() + { + var data = this._data; + this._data = []; + return data; + } +}; + + + +/** +* Creates a request-handling function for an nsIHttpRequestHandler object. +*/ +function createHandlerFunc(handler) +{ + return function(metadata, response) { handler.handle(metadata, response); }; +} + + +/** +* The default handler for directories; writes an HTML response containing a +* slightly-formatted directory listing. +*/ +function defaultIndexHandler(metadata, response) +{ + response.setHeader("Content-Type", "text/html", false); + + var path = htmlEscape(decodeURI(metadata.path)); + + // + // Just do a very basic bit of directory listings -- no need for too much + // fanciness, especially since we don't have a style sheet in which we can + // stick rules (don't want to pollute the default path-space). + // + + var body = '<html>\ +<head>\ +<title>' + path + '</title>\ +</head>\ +<body>\ +<h1>' + path + '</h1>\ +<ol style="list-style-type: none">'; + + var directory = metadata.getProperty("directory").QueryInterface(Ci.nsILocalFile); + NS_ASSERT(directory && directory.isDirectory()); + + var fileList = []; + var files = directory.directoryEntries; + while (files.hasMoreElements()) + { + var f = files.getNext().QueryInterface(Ci.nsIFile); + var name = f.leafName; + if (!f.isHidden() && + (name.charAt(name.length - 1) != HIDDEN_CHAR || + name.charAt(name.length - 2) == HIDDEN_CHAR)) + fileList.push(f); + } + + fileList.sort(fileSort); + + for (var i = 0; i < fileList.length; i++) + { + var file = fileList[i]; + try + { + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + var sep = file.isDirectory() ? "/" : ""; + + // Note: using " to delimit the attribute here because encodeURIComponent + // passes through '. + var item = '<li><a href="' + encodeURIComponent(name) + sep + '">' + + htmlEscape(name) + sep + + '</a></li>'; + + body += item; + } + catch (e) { /* some file system error, ignore the file */ } + } + + body += ' </ol>\ +</body>\ +</html>'; + + response.bodyOutputStream.write(body, body.length); +} + +/** +* Sorts a and b (nsIFile objects) into an aesthetically pleasing order. +*/ +function fileSort(a, b) +{ + var dira = a.isDirectory(), dirb = b.isDirectory(); + + if (dira && !dirb) + return -1; + if (dirb && !dira) + return 1; + + var namea = a.leafName.toLowerCase(), nameb = b.leafName.toLowerCase(); + return nameb > namea ? -1 : 1; +} + + +/** +* Converts an externally-provided path into an internal path for use in +* determining file mappings. +* +* @param path +* the path to convert +* @param encoded +* true if the given path should be passed through decodeURI prior to +* conversion +* @throws URIError +* if path is incorrectly encoded +*/ +function toInternalPath(path, encoded) +{ + if (encoded) + path = decodeURI(path); + + var comps = path.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + if (comp.charAt(comp.length - 1) == HIDDEN_CHAR) + comps[i] = comp + HIDDEN_CHAR; + } + return comps.join("/"); +} + + +/** +* Adds custom-specified headers for the given file to the given response, if +* any such headers are specified. +* +* @param file +* the file on the disk which is to be written +* @param metadata +* metadata about the incoming request +* @param response +* the Response to which any specified headers/data should be written +* @throws HTTP_500 +* if an error occurred while processing custom-specified headers +*/ +function maybeAddHeaders(file, metadata, response) +{ + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + + var headerFile = file.parent; + headerFile.append(name + HEADERS_SUFFIX); + + if (!headerFile.exists()) + return; + + const PR_RDONLY = 0x01; + var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0); + lis.QueryInterface(Ci.nsIUnicharLineInputStream); + + var line = {value: ""}; + var more = lis.readLine(line); + + if (!more && line.value == "") + return; + + + // request line + + var status = line.value; + if (status.indexOf("HTTP ") == 0) + { + status = status.substring(5); + var space = status.indexOf(" "); + var code, description; + if (space < 0) + { + code = status; + description = ""; + } + else + { + code = status.substring(0, space); + description = status.substring(space + 1, status.length); + } + + response.setStatusLine(metadata.httpVersion, parseInt(code, 10), description); + + line.value = ""; + more = lis.readLine(line); + } + + // headers + while (more || line.value != "") + { + var header = line.value; + var colon = header.indexOf(":"); + + response.setHeader(header.substring(0, colon), + header.substring(colon + 1, header.length), + false); // allow overriding server-set headers + + line.value = ""; + more = lis.readLine(line); + } + } + catch (e) + { + dumpn("WARNING: error in headers for " + metadata.path + ": " + e); + throw HTTP_500; + } + finally + { + fis.close(); + } +} + + +/** +* An object which handles requests for a server, executing default and +* overridden behaviors as instructed by the code which uses and manipulates it. +* Default behavior includes the paths / and /trace (diagnostics), with some +* support for HTTP error pages for various codes and fallback to HTTP 500 if +* those codes fail for any reason. +* +* @param server : nsHttpServer +* the server in which this handler is being used +*/ +function ServerHandler(server) +{ + // FIELDS + + /** +* The nsHttpServer instance associated with this handler. +*/ + this._server = server; + + /** +* A FileMap object containing the set of path->nsILocalFile mappings for +* all directory mappings set in the server (e.g., "/" for /var/www/html/, +* "/foo/bar/" for /local/path/, and "/foo/bar/baz/" for /local/path2). +* +* Note carefully: the leading and trailing "/" in each path (not file) are +* removed before insertion to simplify the code which uses this. You have +* been warned! +*/ + this._pathDirectoryMap = new FileMap(); + + /** +* Custom request handlers for the server in which this resides. Path-handler +* pairs are stored as property-value pairs in this property. +* +* @see ServerHandler.prototype._defaultPaths +*/ + this._overridePaths = {}; + + /** +* Custom request handlers for the server in which this resides. Prefix-handler +* pairs are stored as property-value pairs in this property. +*/ + this._overridePrefixes = {}; + + /** +* Custom request handlers for the error handlers in the server in which this +* resides. Path-handler pairs are stored as property-value pairs in this +* property. +* +* @see ServerHandler.prototype._defaultErrors +*/ + this._overrideErrors = {}; + + /** +* Maps file extensions to their MIME types in the server, overriding any +* mapping that might or might not exist in the MIME service. +*/ + this._mimeMappings = {}; + + /** +* The default handler for requests for directories, used to serve directories +* when no index file is present. +*/ + this._indexHandler = defaultIndexHandler; + + /** Per-path state storage for the server. */ + this._state = {}; + + /** Entire-server state storage. */ + this._sharedState = {}; + + /** Entire-server state storage for nsISupports values. */ + this._objectState = {}; +} +ServerHandler.prototype = +{ + // PUBLIC API + + /** +* Handles a request to this server, responding to the request appropriately +* and initiating server shutdown if necessary. +* +* This method never throws an exception. +* +* @param connection : Connection +* the connection for this request +*/ + handleResponse: function(connection) + { + var request = connection.request; + var response = new Response(connection); + + var path = request.path; + dumpn("*** path == " + path); + + try + { + try + { + if (path in this._overridePaths) + { + // explicit paths first, then files based on existing directory mappings, + // then (if the file doesn't exist) built-in server default paths + dumpn("calling override for " + path); + this._overridePaths[path](request, response); + } + else + { + let longestPrefix = ""; + for (let prefix in this._overridePrefixes) + { + if (prefix.length > longestPrefix.length && path.startsWith(prefix)) + { + longestPrefix = prefix; + } + } + if (longestPrefix.length > 0) + { + dumpn("calling prefix override for " + longestPrefix); + this._overridePrefixes[longestPrefix](request, response); + } + else + { + this._handleDefault(request, response); + } + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + if (!(e instanceof HttpError)) + { + dumpn("*** unexpected error: e == " + e); + throw HTTP_500; + } + if (e.code !== 404) + throw e; + + dumpn("*** default: " + (path in this._defaultPaths)); + + response = new Response(connection); + if (path in this._defaultPaths) + this._defaultPaths[path](request, response); + else + throw HTTP_404; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + var errorCode = "internal"; + + try + { + if (!(e instanceof HttpError)) + throw e; + + errorCode = e.code; + dumpn("*** errorCode == " + errorCode); + + response = new Response(connection); + if (e.customErrorHandling) + e.customErrorHandling(response); + this._handleError(errorCode, request, response); + return; + } + catch (e2) + { + dumpn("*** error handling " + errorCode + " error: " + + "e2 == " + e2 + ", shutting down server"); + + connection.server._requestQuit(); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (!file) + { + dumpn("*** unregistering '" + path + "' mapping"); + delete this._overridePaths[path]; + return; + } + + dumpn("*** registering '" + path + "' as mapping to " + file.path); + file = file.clone(); + + var self = this; + this._overridePaths[path] = + function(request, response) + { + if (!file.exists()) + throw HTTP_404; + + response.setStatusLine(request.httpVersion, 200, "OK"); + self._writeFileResponse(request, file, response, 0, file.fileSize); + }; + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + // XXX true path validation! + if (path.charAt(0) != "/") + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePaths, path); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + // XXX true prefix validation! + if (!(prefix.startsWith("/") && prefix.endsWith("/"))) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePrefixes, prefix); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // strip off leading and trailing '/' so that we can use lastIndexOf when + // determining exactly how a path maps onto a mapped directory -- + // conditional is required here to deal with "/".substring(1, 0) being + // converted to "/".substring(0, 1) per the JS specification + var key = path.length == 1 ? "" : path.substring(1, path.length - 1); + + // the path-to-directory mapping code requires that the first character not + // be "/", or it will go into an infinite loop + if (key.charAt(0) == "/") + throw Cr.NS_ERROR_INVALID_ARG; + + key = toInternalPath(key, false); + + if (directory) + { + dumpn("*** mapping '" + path + "' to the location " + directory.path); + this._pathDirectoryMap.put(key, directory); + } + else + { + dumpn("*** removing mapping for '" + path + "'"); + this._pathDirectoryMap.put(key, null); + } + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(err, handler) + { + if (!(err in HTTP_ERROR_CODES)) + dumpn("*** WARNING: registering non-HTTP/1.1 error code " + + "(" + err + ") handler -- was this intentional?"); + + this._handlerToField(handler, this._overrideErrors, err); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + if (!handler) + handler = defaultIndexHandler; + else if (typeof(handler) != "function") + handler = createHandlerFunc(handler); + + this._indexHandler = handler; + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + if (!type) + delete this._mimeMappings[ext]; + else + this._mimeMappings[ext] = headerUtils.normalizeFieldValue(type); + }, + + // PRIVATE API + + /** +* Sets or remove (if handler is null) a handler in an object with a key. +* +* @param handler +* a handler, either function or an nsIHttpRequestHandler +* @param dict +* The object to attach the handler to. +* @param key +* The field name of the handler. +*/ + _handlerToField: function(handler, dict, key) + { + // for convenience, handler can be a function if this is run from xpcshell + if (typeof(handler) == "function") + dict[key] = handler; + else if (handler) + dict[key] = createHandlerFunc(handler); + else + delete dict[key]; + }, + + /** +* Handles a request which maps to a file in the local filesystem (if a base +* path has already been set; otherwise the 404 error is thrown). +* +* @param metadata : Request +* metadata for the incoming request +* @param response : Response +* an uninitialized Response to the given request, to be initialized by a +* request handler +* @throws HTTP_### +* if an HTTP error occurred (usually HTTP_404); note that in this case the +* calling code must handle post-processing of the response +*/ + _handleDefault: function(metadata, response) + { + dumpn("*** _handleDefault()"); + + response.setStatusLine(metadata.httpVersion, 200, "OK"); + + var path = metadata.path; + NS_ASSERT(path.charAt(0) == "/", "invalid path: <" + path + ">"); + + // determine the actual on-disk file; this requires finding the deepest + // path-to-directory mapping in the requested URL + var file = this._getFileForPath(path); + + // the "file" might be a directory, in which case we either serve the + // contained index.html or make the index handler write the response + if (file.exists() && file.isDirectory()) + { + file.append("index.html"); // make configurable? + if (!file.exists() || file.isDirectory()) + { + metadata._ensurePropertyBag(); + metadata._bag.setPropertyAsInterface("directory", file.parent); + this._indexHandler(metadata, response); + return; + } + } + + // alternately, the file might not exist + if (!file.exists()) + throw HTTP_404; + + var start, end; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1) && + metadata.hasHeader("Range") && + this._getTypeFromFile(file) !== SJS_TYPE) + { + var rangeMatch = metadata.getHeader("Range").match(/^bytes=(\d+)?-(\d+)?$/); + if (!rangeMatch) + throw HTTP_400; + + if (rangeMatch[1] !== undefined) + start = parseInt(rangeMatch[1], 10); + + if (rangeMatch[2] !== undefined) + end = parseInt(rangeMatch[2], 10); + + if (start === undefined && end === undefined) + throw HTTP_400; + + // No start given, so the end is really the count of bytes from the + // end of the file. + if (start === undefined) + { + start = Math.max(0, file.fileSize - end); + end = file.fileSize - 1; + } + + // start and end are inclusive + if (end === undefined || end >= file.fileSize) + end = file.fileSize - 1; + + if (start !== undefined && start >= file.fileSize) { + var HTTP_416 = new HttpError(416, "Requested Range Not Satisfiable"); + HTTP_416.customErrorHandling = function(errorResponse) + { + maybeAddHeaders(file, metadata, errorResponse); + }; + throw HTTP_416; + } + + if (end < start) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + start = 0; + end = file.fileSize - 1; + } + else + { + response.setStatusLine(metadata.httpVersion, 206, "Partial Content"); + var contentRange = "bytes " + start + "-" + end + "/" + file.fileSize; + response.setHeader("Content-Range", contentRange); + } + } + else + { + start = 0; + end = file.fileSize - 1; + } + + // finally... + dumpn("*** handling '" + path + "' as mapping to " + file.path + " from " + + start + " to " + end + " inclusive"); + this._writeFileResponse(metadata, file, response, start, end - start + 1); + }, + + /** +* Writes an HTTP response for the given file, including setting headers for +* file metadata. +* +* @param metadata : Request +* the Request for which a response is being generated +* @param file : nsILocalFile +* the file which is to be sent in the response +* @param response : Response +* the response to which the file should be written +* @param offset: uint +* the byte offset to skip to when writing +* @param count: uint +* the number of bytes to write +*/ + _writeFileResponse: function(metadata, file, response, offset, count) + { + const PR_RDONLY = 0x01; + + var type = this._getTypeFromFile(file); + if (type === SJS_TYPE) + { + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var sis = new ScriptableInputStream(fis); + var s = Cu.Sandbox(gGlobalObject); + s.importFunction(dump, "dump"); + + // Define a basic key-value state-preservation API across requests, with + // keys initially corresponding to the empty string. + var self = this; + var path = metadata.path; + s.importFunction(function getState(k) + { + return self._getState(path, k); + }); + s.importFunction(function setState(k, v) + { + self._setState(path, k, v); + }); + s.importFunction(function getSharedState(k) + { + return self._getSharedState(k); + }); + s.importFunction(function setSharedState(k, v) + { + self._setSharedState(k, v); + }); + s.importFunction(function getObjectState(k, callback) + { + callback(self._getObjectState(k)); + }); + s.importFunction(function setObjectState(k, v) + { + self._setObjectState(k, v); + }); + s.importFunction(function registerPathHandler(p, h) + { + self.registerPathHandler(p, h); + }); + + // Make it possible for sjs files to access their location + this._setState(path, "__LOCATION__", file.path); + + try + { + // Alas, the line number in errors dumped to console when calling the + // request handler is simply an offset from where we load the SJS file. + // Work around this in a reasonably non-fragile way by dynamically + // getting the line number where we evaluate the SJS file. Don't + // separate these two lines! + var line = new Error().lineNumber; + Cu.evalInSandbox(sis.read(file.fileSize), s); + } + catch (e) + { + dumpn("*** syntax error in SJS at " + file.path + ": " + e); + throw HTTP_500; + } + + try + { + s.handleRequest(metadata, response); + } + catch (e) + { + dump("*** error running SJS at " + file.path + ": " + + e + " on line " + + (e instanceof Error + ? e.lineNumber + " in httpd.js" + : (e.lineNumber - line)) + "\n"); + throw HTTP_500; + } + } + finally + { + fis.close(); + } + } + else + { + try + { + response.setHeader("Last-Modified", + toDateString(file.lastModifiedTime), + false); + } + catch (e) { /* lastModifiedTime threw, ignore */ } + + response.setHeader("Content-Type", type, false); + maybeAddHeaders(file, metadata, response); + response.setHeader("Content-Length", "" + count, false); + + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + offset = offset || 0; + count = count || file.fileSize; + NS_ASSERT(offset === 0 || offset < file.fileSize, "bad offset"); + NS_ASSERT(count >= 0, "bad count"); + NS_ASSERT(offset + count <= file.fileSize, "bad total data size"); + + try + { + if (offset !== 0) + { + // Seek (or read, if seeking isn't supported) to the correct offset so + // the data sent to the client matches the requested range. + if (fis instanceof Ci.nsISeekableStream) + fis.seek(Ci.nsISeekableStream.NS_SEEK_SET, offset); + else + new ScriptableInputStream(fis).read(offset); + } + } + catch (e) + { + fis.close(); + throw e; + } + + let writeMore = function writeMore() + { + gThreadManager.currentThread + .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL); + } + + var input = new BinaryInputStream(fis); + var output = new BinaryOutputStream(response.bodyOutputStream); + var writeData = + { + run: function() + { + var chunkSize = Math.min(65536, count); + count -= chunkSize; + NS_ASSERT(count >= 0, "underflow"); + + try + { + var data = input.readByteArray(chunkSize); + NS_ASSERT(data.length === chunkSize, + "incorrect data returned? got " + data.length + + ", expected " + chunkSize); + output.writeByteArray(data, data.length); + if (count === 0) + { + fis.close(); + response.finish(); + } + else + { + writeMore(); + } + } + catch (e) + { + try + { + fis.close(); + } + finally + { + response.finish(); + } + throw e; + } + } + }; + + writeMore(); + + // Now that we know copying will start, flag the response as async. + response.processAsync(); + } + }, + + /** +* Get the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getState: function(path, k) + { + var state = this._state; + if (path in state && k in state[path]) + return state[path][k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setState: function(path, k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + var state = this._state; + if (!(path in state)) + state[path] = {}; + state[path][k] = v; + }, + + /** +* Get the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getSharedState: function(k) + { + var state = this._sharedState; + if (k in state) + return state[k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setSharedState: function(k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + this._sharedState[k] = v; + }, + + /** +* Returns the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be returned +* @returns nsISupports +* the corresponding object, or null if none was present +*/ + _getObjectState: function(k) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + return this._objectState[k] || null; + }, + + /** +* Sets the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be set +* @param v : nsISupports +* the object to be associated with the given key; may be null +*/ + _setObjectState: function(k, v) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + if (typeof v !== "object") + throw new Error("non-object value passed"); + if (v && !("QueryInterface" in v)) + { + throw new Error("must pass an nsISupports; use wrappedJSObject to ease " + + "pain when using the server from JS"); + } + + this._objectState[k] = v; + }, + + /** +* Gets a content-type for the given file, first by checking for any custom +* MIME-types registered with this handler for the file's extension, second by +* asking the global MIME service for a content-type, and finally by failing +* over to application/octet-stream. +* +* @param file : nsIFile +* the nsIFile for which to get a file type +* @returns string +* the best content-type which can be determined for the file +*/ + _getTypeFromFile: function(file) + { + try + { + var name = file.leafName; + var dot = name.lastIndexOf("."); + if (dot > 0) + { + var ext = name.slice(dot + 1); + if (ext in this._mimeMappings) + return this._mimeMappings[ext]; + } + return Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(file); + } + catch (e) + { + return "application/octet-stream"; + } + }, + + /** +* Returns the nsILocalFile which corresponds to the path, as determined using +* all registered path->directory mappings and any paths which are explicitly +* overridden. +* +* @param path : string +* the server path for which a file should be retrieved, e.g. "/foo/bar" +* @throws HttpError +* when the correct action is the corresponding HTTP error (i.e., because no +* mapping was found for a directory in path, the referenced file doesn't +* exist, etc.) +* @returns nsILocalFile +* the file to be sent as the response to a request for the path +*/ + _getFileForPath: function(path) + { + // decode and add underscores as necessary + try + { + path = toInternalPath(path, true); + } + catch (e) + { + throw HTTP_400; // malformed path + } + + // next, get the directory which contains this path + var pathMap = this._pathDirectoryMap; + + // An example progression of tmp for a path "/foo/bar/baz/" might be: + // "foo/bar/baz/", "foo/bar/baz", "foo/bar", "foo", "" + var tmp = path.substring(1); + while (true) + { + // do we have a match for current head of the path? + var file = pathMap.get(tmp); + if (file) + { + // XXX hack; basically disable showing mapping for /foo/bar/ when the + // requested path was /foo/bar, because relative links on the page + // will all be incorrect -- we really need the ability to easily + // redirect here instead + if (tmp == path.substring(1) && + tmp.length != 0 && + tmp.charAt(tmp.length - 1) != "/") + file = null; + else + break; + } + + // if we've finished trying all prefixes, exit + if (tmp == "") + break; + + tmp = tmp.substring(0, tmp.lastIndexOf("/")); + } + + // no mapping applies, so 404 + if (!file) + throw HTTP_404; + + + // last, get the file for the path within the determined directory + var parentFolder = file.parent; + var dirIsRoot = (parentFolder == null); + + // Strategy here is to append components individually, making sure we + // never move above the given directory; this allows paths such as + // "<file>/foo/../bar" but prevents paths such as "<file>/../base-sibling"; + // this component-wise approach also means the code works even on platforms + // which don't use "/" as the directory separator, such as Windows + var leafPath = path.substring(tmp.length + 1); + var comps = leafPath.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + + if (comp == "..") + file = file.parent; + else if (comp == "." || comp == "") + continue; + else + file.append(comp); + + if (!dirIsRoot && file.equals(parentFolder)) + throw HTTP_403; + } + + return file; + }, + + /** +* Writes the error page for the given HTTP error code over the given +* connection. +* +* @param errorCode : uint +* the HTTP error code to be used +* @param connection : Connection +* the connection on which the error occurred +*/ + handleError: function(errorCode, connection) + { + var response = new Response(connection); + + dumpn("*** error in request: " + errorCode); + + this._handleError(errorCode, new Request(connection.port), response); + }, + + /** +* Handles a request which generates the given error code, using the +* user-defined error handler if one has been set, gracefully falling back to +* the x00 status code if the code has no handler, and failing to status code +* 500 if all else fails. +* +* @param errorCode : uint +* the HTTP error which is to be returned +* @param metadata : Request +* metadata for the request, which will often be incomplete since this is an +* error +* @param response : Response +* an uninitialized Response should be initialized when this method +* completes with information which represents the desired error code in the +* ideal case or a fallback code in abnormal circumstances (i.e., 500 is a +* fallback for 505, per HTTP specs) +*/ + _handleError: function(errorCode, metadata, response) + { + if (!metadata) + throw Cr.NS_ERROR_NULL_POINTER; + + var errorX00 = errorCode - (errorCode % 100); + + try + { + if (!(errorCode in HTTP_ERROR_CODES)) + dumpn("*** WARNING: requested invalid error: " + errorCode); + + // RFC 2616 says that we should try to handle an error by its class if we + // can't otherwise handle it -- if that fails, we revert to handling it as + // a 500 internal server error, and if that fails we throw and shut down + // the server + + // actually handle the error + try + { + if (errorCode in this._overrideErrors) + this._overrideErrors[errorCode](metadata, response); + else + this._defaultErrors[errorCode](metadata, response); + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + // don't retry the handler that threw + if (errorX00 == errorCode) + throw HTTP_500; + + dumpn("*** error in handling for error code " + errorCode + ", " + + "falling back to " + errorX00 + "..."); + response = new Response(response._connection); + if (errorX00 in this._overrideErrors) + this._overrideErrors[errorX00](metadata, response); + else if (errorX00 in this._defaultErrors) + this._defaultErrors[errorX00](metadata, response); + else + throw HTTP_500; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(); + return; + } + + // we've tried everything possible for a meaningful error -- now try 500 + dumpn("*** error in handling for error code " + errorX00 + ", falling " + + "back to 500..."); + + try + { + response = new Response(response._connection); + if (500 in this._overrideErrors) + this._overrideErrors[500](metadata, response); + else + this._defaultErrors[500](metadata, response); + } + catch (e2) + { + dumpn("*** multiple errors in default error handlers!"); + dumpn("*** e == " + e + ", e2 == " + e2); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // FIELDS + + /** +* This object contains the default handlers for the various HTTP error codes. +*/ + _defaultErrors: + { + 400: function(metadata, response) + { + // none of the data in metadata is reliable, so hard-code everything here + response.setStatusLine("1.1", 400, "Bad Request"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Bad request\n"; + response.bodyOutputStream.write(body, body.length); + }, + 403: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 403, "Forbidden"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>403 Forbidden</title></head>\ +<body>\ +<h1>403 Forbidden</h1>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 404: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>404 Not Found</title></head>\ +<body>\ +<h1>404 Not Found</h1>\ +<p>\ +<span style='font-family: monospace;'>" + + htmlEscape(metadata.path) + + "</span> was not found.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 416: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 416, + "Requested Range Not Satisfiable"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head>\ +<title>416 Requested Range Not Satisfiable</title></head>\ +<body>\ +<h1>416 Requested Range Not Satisfiable</h1>\ +<p>The byte range was not valid for the\ +requested resource.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 500: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 500, + "Internal Server Error"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>500 Internal Server Error</title></head>\ +<body>\ +<h1>500 Internal Server Error</h1>\ +<p>Something's broken in this server and\ +needs to be fixed.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 501: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 501, "Not Implemented"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>501 Not Implemented</title></head>\ +<body>\ +<h1>501 Not Implemented</h1>\ +<p>This server is not (yet) Apache.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 505: function(metadata, response) + { + response.setStatusLine("1.1", 505, "HTTP Version Not Supported"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>505 HTTP Version Not Supported</title></head>\ +<body>\ +<h1>505 HTTP Version Not Supported</h1>\ +<p>This server only supports HTTP/1.0 and HTTP/1.1\ +connections.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + } + }, + + /** +* Contains handlers for the default set of URIs contained in this server. +*/ + _defaultPaths: + { + "/": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>httpd.js</title></head>\ +<body>\ +<h1>httpd.js</h1>\ +<p>If you're seeing this page, httpd.js is up and\ +serving requests! Now set a base path and serve some\ +files!</p>\ +</body>\ +</html>"; + + response.bodyOutputStream.write(body, body.length); + }, + + "/trace": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Request-URI: " + + metadata.scheme + "://" + metadata.host + ":" + metadata.port + + metadata.path + "\n\n"; + body += "Request (semantically equivalent, slightly reformatted):\n\n"; + body += metadata.method + " " + metadata.path; + + if (metadata.queryString) + body += "?" + metadata.queryString; + + body += " HTTP/" + metadata.httpVersion + "\r\n"; + + var headEnum = metadata.headers; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + body += fieldName + ": " + metadata.getHeader(fieldName) + "\r\n"; + } + + response.bodyOutputStream.write(body, body.length); + } + } +}; + + +/** +* Maps absolute paths to files on the local file system (as nsILocalFiles). +*/ +function FileMap() +{ + /** Hash which will map paths to nsILocalFiles. */ + this._map = {}; +} +FileMap.prototype = +{ + // PUBLIC API + + /** +* Maps key to a clone of the nsILocalFile value if value is non-null; +* otherwise, removes any extant mapping for key. +* +* @param key : string +* string to which a clone of value is mapped +* @param value : nsILocalFile +* the file to map to key, or null to remove a mapping +*/ + put: function(key, value) + { + if (value) + this._map[key] = value.clone(); + else + delete this._map[key]; + }, + + /** +* Returns a clone of the nsILocalFile mapped to key, or null if no such +* mapping exists. +* +* @param key : string +* key to which the returned file maps +* @returns nsILocalFile +* a clone of the mapped file, or null if no mapping exists +*/ + get: function(key) + { + var val = this._map[key]; + return val ? val.clone() : null; + } +}; + + +// Response CONSTANTS + +// token = *<any CHAR except CTLs or separators> +// CHAR = <any US-ASCII character (0-127)> +// CTL = <any US-ASCII control character (0-31) and DEL (127)> +// separators = "(" | ")" | "<" | ">" | "@" +// | "," | ";" | ":" | "\" | <"> +// | "/" | "[" | "]" | "?" | "=" +// | "{" | "}" | SP | HT +const IS_TOKEN_ARRAY = + [0, 0, 0, 0, 0, 0, 0, 0, // 0 + 0, 0, 0, 0, 0, 0, 0, 0, // 8 + 0, 0, 0, 0, 0, 0, 0, 0, // 16 + 0, 0, 0, 0, 0, 0, 0, 0, // 24 + + 0, 1, 0, 1, 1, 1, 1, 1, // 32 + 0, 0, 1, 1, 0, 1, 1, 0, // 40 + 1, 1, 1, 1, 1, 1, 1, 1, // 48 + 1, 1, 0, 0, 0, 0, 0, 0, // 56 + + 0, 1, 1, 1, 1, 1, 1, 1, // 64 + 1, 1, 1, 1, 1, 1, 1, 1, // 72 + 1, 1, 1, 1, 1, 1, 1, 1, // 80 + 1, 1, 1, 0, 0, 0, 1, 1, // 88 + + 1, 1, 1, 1, 1, 1, 1, 1, // 96 + 1, 1, 1, 1, 1, 1, 1, 1, // 104 + 1, 1, 1, 1, 1, 1, 1, 1, // 112 + 1, 1, 1, 0, 1, 0, 1]; // 120 + + +/** +* Determines whether the given character code is a CTL. +* +* @param code : uint +* the character code +* @returns boolean +* true if code is a CTL, false otherwise +*/ +function isCTL(code) +{ + return (code >= 0 && code <= 31) || (code == 127); +} + +/** +* Represents a response to an HTTP request, encapsulating all details of that +* response. This includes all headers, the HTTP version, status code and +* explanation, and the entity itself. +* +* @param connection : Connection +* the connection over which this response is to be written +*/ +function Response(connection) +{ + /** The connection over which this response will be written. */ + this._connection = connection; + + /** +* The HTTP version of this response; defaults to 1.1 if not set by the +* handler. +*/ + this._httpVersion = nsHttpVersion.HTTP_1_1; + + /** +* The HTTP code of this response; defaults to 200. +*/ + this._httpCode = 200; + + /** +* The description of the HTTP code in this response; defaults to "OK". +*/ + this._httpDescription = "OK"; + + /** +* An nsIHttpHeaders object in which the headers in this response should be +* stored. This property is null after the status line and headers have been +* written to the network, and it may be modified up until it is cleared, +* except if this._finished is set first (in which case headers are written +* asynchronously in response to a finish() call not preceded by +* flushHeaders()). +*/ + this._headers = new nsHttpHeaders(); + + /** +* Set to true when this response is ended (completely constructed if possible +* and the connection closed); further actions on this will then fail. +*/ + this._ended = false; + + /** +* A stream used to hold data written to the body of this response. +*/ + this._bodyOutputStream = null; + + /** +* A stream containing all data that has been written to the body of this +* response so far. (Async handlers make the data contained in this +* unreliable as a way of determining content length in general, but auxiliary +* saved information can sometimes be used to guarantee reliability.) +*/ + this._bodyInputStream = null; + + /** +* A stream copier which copies data to the network. It is initially null +* until replaced with a copier for response headers; when headers have been +* fully sent it is replaced with a copier for the response body, remaining +* so for the duration of response processing. +*/ + this._asyncCopier = null; + + /** +* True if this response has been designated as being processed +* asynchronously rather than for the duration of a single call to +* nsIHttpRequestHandler.handle. +*/ + this._processAsync = false; + + /** +* True iff finish() has been called on this, signaling that no more changes +* to this may be made. +*/ + this._finished = false; + + /** +* True iff powerSeized() has been called on this, signaling that this +* response is to be handled manually by the response handler (which may then +* send arbitrary data in response, even non-HTTP responses). +*/ + this._powerSeized = false; +} +Response.prototype = +{ + // PUBLIC CONSTRUCTION API + + // + // see nsIHttpResponse.bodyOutputStream + // + get bodyOutputStream() + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + if (!this._bodyOutputStream) + { + var pipe = new Pipe(true, false, Response.SEGMENT_SIZE, PR_UINT32_MAX, + null); + this._bodyOutputStream = pipe.outputStream; + this._bodyInputStream = pipe.inputStream; + if (this._processAsync || this._powerSeized) + this._startAsyncProcessor(); + } + + return this._bodyOutputStream; + }, + + // + // see nsIHttpResponse.write + // + write: function(data) + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + var dataAsString = String(data); + this.bodyOutputStream.write(dataAsString, dataAsString.length); + }, + + // + // see nsIHttpResponse.setStatusLine + // + setStatusLine: function(httpVersion, code, description) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + if (!(code >= 0 && code < 1000)) + throw Cr.NS_ERROR_INVALID_ARG; + + try + { + var httpVer; + // avoid version construction for the most common cases + if (!httpVersion || httpVersion == "1.1") + httpVer = nsHttpVersion.HTTP_1_1; + else if (httpVersion == "1.0") + httpVer = nsHttpVersion.HTTP_1_0; + else + httpVer = new nsHttpVersion(httpVersion); + } + catch (e) + { + throw Cr.NS_ERROR_INVALID_ARG; + } + + // Reason-Phrase = *<TEXT, excluding CR, LF> + // TEXT = <any OCTET except CTLs, but including LWS> + // + // XXX this ends up disallowing octets which aren't Unicode, I think -- not + // much to do if description is IDL'd as string + if (!description) + description = ""; + for (var i = 0; i < description.length; i++) + if (isCTL(description.charCodeAt(i)) && description.charAt(i) != "\t") + throw Cr.NS_ERROR_INVALID_ARG; + + // set the values only after validation to preserve atomicity + this._httpDescription = description; + this._httpCode = code; + this._httpVersion = httpVer; + }, + + // + // see nsIHttpResponse.setHeader + // + setHeader: function(name, value, merge) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + this._headers.setHeader(name, value, merge); + }, + + // + // see nsIHttpResponse.processAsync + // + processAsync: function() + { + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._processAsync) + return; + this._ensureAlive(); + + dumpn("*** processing connection " + this._connection.number + " async"); + this._processAsync = true; + + /* +* Either the bodyOutputStream getter or this method is responsible for +* starting the asynchronous processor and catching writes of data to the +* response body of async responses as they happen, for the purpose of +* forwarding those writes to the actual connection's output stream. +* If bodyOutputStream is accessed first, calling this method will create +* the processor (when it first is clear that body data is to be written +* immediately, not buffered). If this method is called first, accessing +* bodyOutputStream will create the processor. If only this method is +* called, we'll write nothing, neither headers nor the nonexistent body, +* until finish() is called. Since that delay is easily avoided by simply +* getting bodyOutputStream or calling write(""), we don't worry about it. +*/ + if (this._bodyOutputStream && !this._asyncCopier) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.seizePower + // + seizePower: function() + { + if (this._processAsync) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + return; + this._ensureAlive(); + + dumpn("*** forcefully seizing power over connection " + + this._connection.number + "..."); + + // Purge any already-written data without sending it. We could as easily + // swap out the streams entirely, but that makes it possible to acquire and + // unknowingly use a stale reference, so we require there only be one of + // each stream ever for any response to avoid this complication. + if (this._asyncCopier) + this._asyncCopier.cancel(Cr.NS_BINDING_ABORTED); + this._asyncCopier = null; + if (this._bodyOutputStream) + { + var input = new BinaryInputStream(this._bodyInputStream); + var avail; + while ((avail = input.available()) > 0) + input.readByteArray(avail); + } + + this._powerSeized = true; + if (this._bodyOutputStream) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.finish + // + finish: function() + { + if (!this._processAsync && !this._powerSeized) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._finished) + return; + + dumpn("*** finishing connection " + this._connection.number); + this._startAsyncProcessor(); // in case bodyOutputStream was never accessed + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + this._finished = true; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpResponse) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // POST-CONSTRUCTION API (not exposed externally) + + /** +* The HTTP version number of this, as a string (e.g. "1.1"). +*/ + get httpVersion() + { + this._ensureAlive(); + return this._httpVersion.toString(); + }, + + /** +* The HTTP status code of this response, as a string of three characters per +* RFC 2616. +*/ + get httpCode() + { + this._ensureAlive(); + + var codeString = (this._httpCode < 10 ? "0" : "") + + (this._httpCode < 100 ? "0" : "") + + this._httpCode; + return codeString; + }, + + /** +* The description of the HTTP status code of this response, or "" if none is +* set. +*/ + get httpDescription() + { + this._ensureAlive(); + + return this._httpDescription; + }, + + /** +* The headers in this response, as an nsHttpHeaders object. +*/ + get headers() + { + this._ensureAlive(); + + return this._headers; + }, + + // + // see nsHttpHeaders.getHeader + // + getHeader: function(name) + { + this._ensureAlive(); + + return this._headers.getHeader(name); + }, + + /** +* Determines whether this response may be abandoned in favor of a newly +* constructed response. A response may be abandoned only if it is not being +* sent asynchronously and if raw control over it has not been taken from the +* server. +* +* @returns boolean +* true iff no data has been written to the network +*/ + partiallySent: function() + { + dumpn("*** partiallySent()"); + return this._processAsync || this._powerSeized; + }, + + /** +* If necessary, kicks off the remaining request processing needed to be done +* after a request handler performs its initial work upon this response. +*/ + complete: function() + { + dumpn("*** complete()"); + if (this._processAsync || this._powerSeized) + { + NS_ASSERT(this._processAsync ^ this._powerSeized, + "can't both send async and relinquish power"); + return; + } + + NS_ASSERT(!this.partiallySent(), "completing a partially-sent response?"); + + this._startAsyncProcessor(); + + // Now make sure we finish processing this request! + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + }, + + /** +* Abruptly ends processing of this response, usually due to an error in an +* incoming request but potentially due to a bad error handler. Since we +* cannot handle the error in the usual way (giving an HTTP error page in +* response) because data may already have been sent (or because the response +* might be expected to have been generated asynchronously or completely from +* scratch by the handler), we stop processing this response and abruptly +* close the connection. +* +* @param e : Error +* the exception which precipitated this abort, or null if no such exception +* was generated +*/ + abort: function(e) + { + dumpn("*** abort(<" + e + ">)"); + + // This response will be ended by the processor if one was created. + var copier = this._asyncCopier; + if (copier) + { + // We dispatch asynchronously here so that any pending writes of data to + // the connection will be deterministically written. This makes it easier + // to specify exact behavior, and it makes observable behavior more + // predictable for clients. Note that the correctness of this depends on + // callbacks in response to _waitToReadData in WriteThroughCopier + // happening asynchronously with respect to the actual writing of data to + // bodyOutputStream, as they currently do; if they happened synchronously, + // an event which ran before this one could write more data to the + // response body before we get around to canceling the copier. We have + // tests for this in test_seizepower.js, however, and I can't think of a + // way to handle both cases without removing bodyOutputStream access and + // moving its effective write(data, length) method onto Response, which + // would be slower and require more code than this anyway. + gThreadManager.currentThread.dispatch({ + run: function() + { + dumpn("*** canceling copy asynchronously..."); + copier.cancel(Cr.NS_ERROR_UNEXPECTED); + } + }, Ci.nsIThread.DISPATCH_NORMAL); + } + else + { + this.end(); + } + }, + + /** +* Closes this response's network connection, marks the response as finished, +* and notifies the server handler that the request is done being processed. +*/ + end: function() + { + NS_ASSERT(!this._ended, "ending this response twice?!?!"); + + this._connection.close(); + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + + this._finished = true; + this._ended = true; + }, + + // PRIVATE IMPLEMENTATION + + /** +* Sends the status line and headers of this response if they haven't been +* sent and initiates the process of copying data written to this response's +* body to the network. +*/ + _startAsyncProcessor: function() + { + dumpn("*** _startAsyncProcessor()"); + + // Handle cases where we're being called a second time. The former case + // happens when this is triggered both by complete() and by processAsync(), + // while the latter happens when processAsync() in conjunction with sent + // data causes abort() to be called. + if (this._asyncCopier || this._ended) + { + dumpn("*** ignoring second call to _startAsyncProcessor"); + return; + } + + // Send headers if they haven't been sent already and should be sent, then + // asynchronously continue to send the body. + if (this._headers && !this._powerSeized) + { + this._sendHeaders(); + return; + } + + this._headers = null; + this._sendBody(); + }, + + /** +* Signals that all modifications to the response status line and headers are +* complete and then sends that data over the network to the client. Once +* this method completes, a different response to the request that resulted +* in this response cannot be sent -- the only possible action in case of +* error is to abort the response and close the connection. +*/ + _sendHeaders: function() + { + dumpn("*** _sendHeaders()"); + + NS_ASSERT(this._headers); + NS_ASSERT(!this._powerSeized); + + // request-line + var statusLine = "HTTP/" + this.httpVersion + " " + + this.httpCode + " " + + this.httpDescription + "\r\n"; + + // header post-processing + + var headers = this._headers; + headers.setHeader("Connection", "close", false); + headers.setHeader("Server", "httpd.js", false); + if (!headers.hasHeader("Date")) + headers.setHeader("Date", toDateString(Date.now()), false); + + // Any response not being processed asynchronously must have an associated + // Content-Length header for reasons of backwards compatibility with the + // initial server, which fully buffered every response before sending it. + // Beyond that, however, it's good to do this anyway because otherwise it's + // impossible to test behaviors that depend on the presence or absence of a + // Content-Length header. + if (!this._processAsync) + { + dumpn("*** non-async response, set Content-Length"); + + var bodyStream = this._bodyInputStream; + var avail = bodyStream ? bodyStream.available() : 0; + + // XXX assumes stream will always report the full amount of data available + headers.setHeader("Content-Length", "" + avail, false); + } + + + // construct and send response + dumpn("*** header post-processing completed, sending response head..."); + + // request-line + var preambleData = [statusLine]; + + // headers + var headEnum = headers.enumerator; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + var values = headers.getHeaderValues(fieldName); + for (var i = 0, sz = values.length; i < sz; i++) + preambleData.push(fieldName + ": " + values[i] + "\r\n"); + } + + // end request-line/headers + preambleData.push("\r\n"); + + var preamble = preambleData.join(""); + + var responseHeadPipe = new Pipe(true, false, 0, PR_UINT32_MAX, null); + responseHeadPipe.outputStream.write(preamble, preamble.length); + + var response = this; + var copyObserver = + { + onStartRequest: function(request, cx) + { + dumpn("*** preamble copying started"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** preamble copying complete " + + "[status=0x" + statusCode.toString(16) + "]"); + + if (!components.isSuccessCode(statusCode)) + { + dumpn("!!! header copying problems: non-success statusCode, " + + "ending response"); + + response.end(); + } + else + { + response._sendBody(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + var headerCopier = this._asyncCopier = + new WriteThroughCopier(responseHeadPipe.inputStream, + this._connection.output, + copyObserver, null); + + responseHeadPipe.outputStream.close(); + + // Forbid setting any more headers or modifying the request line. + this._headers = null; + }, + + /** +* Asynchronously writes the body of the response (or the entire response, if +* seizePower() has been called) to the network. +*/ + _sendBody: function() + { + dumpn("*** _sendBody"); + + NS_ASSERT(!this._headers, "still have headers around but sending body?"); + + // If no body data was written, we're done + if (!this._bodyInputStream) + { + dumpn("*** empty body, response finished"); + this.end(); + return; + } + + var response = this; + var copyObserver = + { + onStartRequest: function(request, context) + { + dumpn("*** onStartRequest"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** onStopRequest [status=0x" + statusCode.toString(16) + "]"); + + if (statusCode === Cr.NS_BINDING_ABORTED) + { + dumpn("*** terminating copy observer without ending the response"); + } + else + { + if (!components.isSuccessCode(statusCode)) + dumpn("*** WARNING: non-success statusCode in onStopRequest"); + + response.end(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + dumpn("*** starting async copier of body data..."); + this._asyncCopier = + new WriteThroughCopier(this._bodyInputStream, this._connection.output, + copyObserver, null); + }, + + /** Ensures that this hasn't been ended. */ + _ensureAlive: function() + { + NS_ASSERT(!this._ended, "not handling response lifetime correctly"); + } +}; + +/** +* Size of the segments in the buffer used in storing response data and writing +* it to the socket. +*/ +Response.SEGMENT_SIZE = 8192; + +/** Serves double duty in WriteThroughCopier implementation. */ +function notImplemented() +{ + throw Cr.NS_ERROR_NOT_IMPLEMENTED; +} + +/** Returns true iff the given exception represents stream closure. */ +function streamClosed(e) +{ + return e === Cr.NS_BASE_STREAM_CLOSED || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_CLOSED); +} + +/** Returns true iff the given exception represents a blocked stream. */ +function wouldBlock(e) +{ + return e === Cr.NS_BASE_STREAM_WOULD_BLOCK || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_WOULD_BLOCK); +} + +/** +* Copies data from source to sink as it becomes available, when that data can +* be written to sink without blocking. +* +* @param source : nsIAsyncInputStream +* the stream from which data is to be read +* @param sink : nsIAsyncOutputStream +* the stream to which data is to be copied +* @param observer : nsIRequestObserver +* an observer which will be notified when the copy starts and finishes +* @param context : nsISupports +* context passed to observer when notified of start/stop +* @throws NS_ERROR_NULL_POINTER +* if source, sink, or observer are null +*/ +function WriteThroughCopier(source, sink, observer, context) +{ + if (!source || !sink || !observer) + throw Cr.NS_ERROR_NULL_POINTER; + + /** Stream from which data is being read. */ + this._source = source; + + /** Stream to which data is being written. */ + this._sink = sink; + + /** Observer watching this copy. */ + this._observer = observer; + + /** Context for the observer watching this. */ + this._context = context; + + /** +* True iff this is currently being canceled (cancel has been called, the +* callback may not yet have been made). +*/ + this._canceled = false; + + /** +* False until all data has been read from input and written to output, at +* which point this copy is completed and cancel() is asynchronously called. +*/ + this._completed = false; + + /** Required by nsIRequest, meaningless. */ + this.loadFlags = 0; + /** Required by nsIRequest, meaningless. */ + this.loadGroup = null; + /** Required by nsIRequest, meaningless. */ + this.name = "response-body-copy"; + + /** Status of this request. */ + this.status = Cr.NS_OK; + + /** Arrays of byte strings waiting to be written to output. */ + this._pendingData = []; + + // start copying + try + { + observer.onStartRequest(this, context); + this._waitToReadData(); + this._waitForSinkClosure(); + } + catch (e) + { + dumpn("!!! error starting copy: " + e + + ("lineNumber" in e ? ", line " + e.lineNumber : "")); + dumpn(e.stack); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + } +} +WriteThroughCopier.prototype = +{ + /* nsISupports implementation */ + + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIInputStreamCallback) || + iid.equals(Ci.nsIOutputStreamCallback) || + iid.equals(Ci.nsIRequest) || + iid.equals(Ci.nsISupports)) + { + return this; + } + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NSIINPUTSTREAMCALLBACK + + /** +* Receives a more-data-in-input notification and writes the corresponding +* data to the output. +* +* @param input : nsIAsyncInputStream +* the input stream on whose data we have been waiting +*/ + onInputStreamReady: function(input) + { + if (this._source === null) + return; + + dumpn("*** onInputStreamReady"); + + // + // Ordinarily we'll read a non-zero amount of data from input, queue it up + // to be written and then wait for further callbacks. The complications in + // this method are the cases where we deviate from that behavior when errors + // occur or when copying is drawing to a finish. + // + // The edge cases when reading data are: + // + // Zero data is read + // If zero data was read, we're at the end of available data, so we can + // should stop reading and move on to writing out what we have (or, if + // we've already done that, onto notifying of completion). + // A stream-closed exception is thrown + // This is effectively a less kind version of zero data being read; the + // only difference is that we notify of completion with that result + // rather than with NS_OK. + // Some other exception is thrown + // This is the least kind result. We don't know what happened, so we + // act as though the stream closed except that we notify of completion + // with the result NS_ERROR_UNEXPECTED. + // + + var bytesWanted = 0, bytesConsumed = -1; + try + { + input = new BinaryInputStream(input); + + bytesWanted = Math.min(input.available(), Response.SEGMENT_SIZE); + dumpn("*** input wanted: " + bytesWanted); + + if (bytesWanted > 0) + { + var data = input.readByteArray(bytesWanted); + bytesConsumed = data.length; + this._pendingData.push(String.fromCharCode.apply(String, data)); + } + + dumpn("*** " + bytesConsumed + " bytes read"); + + // Handle the zero-data edge case in the same place as all other edge + // cases are handled. + if (bytesWanted === 0) + throw Cr.NS_BASE_STREAM_CLOSED; + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** input stream closed"); + e = bytesWanted === 0 ? Cr.NS_OK : Cr.NS_ERROR_UNEXPECTED; + } + else + { + dumpn("!!! unexpected error reading from input, canceling: " + e); + e = Cr.NS_ERROR_UNEXPECTED; + } + + this._doneReadingSource(e); + return; + } + + var pendingData = this._pendingData; + + NS_ASSERT(bytesConsumed > 0); + NS_ASSERT(pendingData.length > 0, "no pending data somehow?"); + NS_ASSERT(pendingData[pendingData.length - 1].length > 0, + "buffered zero bytes of data?"); + + NS_ASSERT(this._source !== null); + + // Reading has gone great, and we've gotten data to write now. What if we + // don't have a place to write that data, because output went away just + // before this read? Drop everything on the floor, including new data, and + // cancel at this point. + if (this._sink === null) + { + pendingData.length = 0; + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we've read the data, and we know we have a place to write it. We + // need to queue up the data to be written, but *only* if none is queued + // already -- if data's already queued, the code that actually writes the + // data will make sure to wait on unconsumed pending data. + try + { + if (pendingData.length === 1) + this._waitToWriteData(); + } + catch (e) + { + dumpn("!!! error waiting to write data just read, swallowing and " + + "writing only what we already have: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Whee! We successfully read some data, and it's successfully queued up to + // be written. All that remains now is to wait for more data to read. + try + { + this._waitToReadData(); + } + catch (e) + { + dumpn("!!! error waiting to read more data: " + e); + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + } + }, + + + // NSIOUTPUTSTREAMCALLBACK + + /** +* Callback when data may be written to the output stream without blocking, or +* when the output stream has been closed. +* +* @param output : nsIAsyncOutputStream +* the output stream on whose writability we've been waiting, also known as +* this._sink +*/ + onOutputStreamReady: function(output) + { + if (this._sink === null) + return; + + dumpn("*** onOutputStreamReady"); + + var pendingData = this._pendingData; + if (pendingData.length === 0) + { + // There's no pending data to write. The only way this can happen is if + // we're waiting on the output stream's closure, so we can respond to a + // copying failure as quickly as possible (rather than waiting for data to + // be available to read and then fail to be copied). Therefore, we must + // be done now -- don't bother to attempt to write anything and wrap + // things up. + dumpn("!!! output stream closed prematurely, ending copy"); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + + NS_ASSERT(pendingData[0].length > 0, "queued up an empty quantum?"); + + // + // Write out the first pending quantum of data. The possible errors here + // are: + // + // The write might fail because we can't write that much data + // Okay, we've written what we can now, so re-queue what's left and + // finish writing it out later. + // The write failed because the stream was closed + // Discard pending data that we can no longer write, stop reading, and + // signal that copying finished. + // Some other error occurred. + // Same as if the stream were closed, but notify with the status + // NS_ERROR_UNEXPECTED so the observer knows something was wonky. + // + + try + { + var quantum = pendingData[0]; + + // XXX |quantum| isn't guaranteed to be ASCII, so we're relying on + // undefined behavior! We're only using this because writeByteArray + // is unusably broken for asynchronous output streams; see bug 532834 + // for details. + var bytesWritten = output.write(quantum, quantum.length); + if (bytesWritten === quantum.length) + pendingData.shift(); + else + pendingData[0] = quantum.substring(bytesWritten); + + dumpn("*** wrote " + bytesWritten + " bytes of data"); + } + catch (e) + { + if (wouldBlock(e)) + { + NS_ASSERT(pendingData.length > 0, + "stream-blocking exception with no data to write?"); + NS_ASSERT(pendingData[0].length > 0, + "stream-blocking exception with empty quantum?"); + this._waitToWriteData(); + return; + } + + if (streamClosed(e)) + dumpn("!!! output stream prematurely closed, signaling error..."); + else + dumpn("!!! unknown error: " + e + ", quantum=" + quantum); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // The day is ours! Quantum written, now let's see if we have more data + // still to write. + try + { + if (pendingData.length > 0) + { + this._waitToWriteData(); + return; + } + } + catch (e) + { + dumpn("!!! unexpected error waiting to write pending data: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we have no more pending data to write -- but might we get more in + // the future? + if (this._source !== null) + { + /* +* If we might, then wait for the output stream to be closed. (We wait +* only for closure because we have no data to write -- and if we waited +* for a specific amount of data, we would get repeatedly notified for no +* reason if over time the output stream permitted more and more data to +* be written to it without blocking.) +*/ + this._waitForSinkClosure(); + } + else + { + /* +* On the other hand, if we can't have more data because the input +* stream's gone away, then it's time to notify of copy completion. +* Victory! +*/ + this._sink = null; + this._cancelOrDispatchCancelCallback(Cr.NS_OK); + } + }, + + + // NSIREQUEST + + /** Returns true if the cancel observer hasn't been notified yet. */ + isPending: function() + { + return !this._completed; + }, + + /** Not implemented, don't use! */ + suspend: notImplemented, + /** Not implemented, don't use! */ + resume: notImplemented, + + /** +* Cancels data reading from input, asynchronously writes out any pending +* data, and causes the observer to be notified with the given error code when +* all writing has finished. +* +* @param status : nsresult +* the status to pass to the observer when data copying has been canceled +*/ + cancel: function(status) + { + dumpn("*** cancel(" + status.toString(16) + ")"); + + if (this._canceled) + { + dumpn("*** suppressing a late cancel"); + return; + } + + this._canceled = true; + this.status = status; + + // We could be in the middle of absolutely anything at this point. Both + // input and output might still be around, we might have pending data to + // write, and in general we know nothing about the state of the world. We + // therefore must assume everything's in progress and take everything to its + // final steady state (or so far as it can go before we need to finish + // writing out remaining data). + + this._doneReadingSource(status); + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Stop reading input if we haven't already done so, passing e as the status +* when closing the stream, and kick off a copy-completion notice if no more +* data remains to be written. +* +* @param e : nsresult +* the status to be used when closing the input stream +*/ + _doneReadingSource: function(e) + { + dumpn("*** _doneReadingSource(0x" + e.toString(16) + ")"); + + this._finishSource(e); + if (this._pendingData.length === 0) + this._sink = null; + else + NS_ASSERT(this._sink !== null, "null output?"); + + // If we've written out all data read up to this point, then it's time to + // signal completion. + if (this._sink === null) + { + NS_ASSERT(this._pendingData.length === 0, "pending data still?"); + this._cancelOrDispatchCancelCallback(e); + } + }, + + /** +* Stop writing output if we haven't already done so, discard any data that +* remained to be sent, close off input if it wasn't already closed, and kick +* off a copy-completion notice. +* +* @param e : nsresult +* the status to be used when closing input if it wasn't already closed +*/ + _doneWritingToSink: function(e) + { + dumpn("*** _doneWritingToSink(0x" + e.toString(16) + ")"); + + this._pendingData.length = 0; + this._sink = null; + this._doneReadingSource(e); + }, + + /** +* Completes processing of this copy: either by canceling the copy if it +* hasn't already been canceled using the provided status, or by dispatching +* the cancel callback event (with the originally provided status, of course) +* if it already has been canceled. +* +* @param status : nsresult +* the status code to use to cancel this, if this hasn't already been +* canceled +*/ + _cancelOrDispatchCancelCallback: function(status) + { + dumpn("*** _cancelOrDispatchCancelCallback(" + status + ")"); + + NS_ASSERT(this._source === null, "should have finished input"); + NS_ASSERT(this._sink === null, "should have finished output"); + NS_ASSERT(this._pendingData.length === 0, "should have no pending data"); + + if (!this._canceled) + { + this.cancel(status); + return; + } + + var self = this; + var event = + { + run: function() + { + dumpn("*** onStopRequest async callback"); + + self._completed = true; + try + { + self._observer.onStopRequest(self, self._context, self.status); + } + catch (e) + { + NS_ASSERT(false, + "how are we throwing an exception here? we control " + + "all the callers! " + e); + } + } + }; + + gThreadManager.currentThread.dispatch(event, Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** +* Kicks off another wait for more data to be available from the input stream. +*/ + _waitToReadData: function() + { + dumpn("*** _waitToReadData"); + this._source.asyncWait(this, 0, Response.SEGMENT_SIZE, + gThreadManager.mainThread); + }, + + /** +* Kicks off another wait until data can be written to the output stream. +*/ + _waitToWriteData: function() + { + dumpn("*** _waitToWriteData"); + + var pendingData = this._pendingData; + NS_ASSERT(pendingData.length > 0, "no pending data to write?"); + NS_ASSERT(pendingData[0].length > 0, "buffered an empty write?"); + + this._sink.asyncWait(this, 0, pendingData[0].length, + gThreadManager.mainThread); + }, + + /** +* Kicks off a wait for the sink to which data is being copied to be closed. +* We wait for stream closure when we don't have any data to be copied, rather +* than waiting to write a specific amount of data. We can't wait to write +* data because the sink might be infinitely writable, and if no data appears +* in the source for a long time we might have to spin quite a bit waiting to +* write, waiting to write again, &c. Waiting on stream closure instead means +* we'll get just one notification if the sink dies. Note that when data +* starts arriving from the sink we'll resume waiting for data to be written, +* dropping this closure-only callback entirely. +*/ + _waitForSinkClosure: function() + { + dumpn("*** _waitForSinkClosure"); + + this._sink.asyncWait(this, Ci.nsIAsyncOutputStream.WAIT_CLOSURE_ONLY, 0, + gThreadManager.mainThread); + }, + + /** +* Closes input with the given status, if it hasn't already been closed; +* otherwise a no-op. +* +* @param status : nsresult +* status code use to close the source stream if necessary +*/ + _finishSource: function(status) + { + dumpn("*** _finishSource(" + status.toString(16) + ")"); + + if (this._source !== null) + { + this._source.closeWithStatus(status); + this._source = null; + } + } +}; + + +/** +* A container for utility functions used with HTTP headers. +*/ +const headerUtils = +{ + /** +* Normalizes fieldName (by converting it to lowercase) and ensures it is a +* valid header field name (although not necessarily one specified in RFC +* 2616). +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not match the field-name production in RFC 2616 +* @returns string +* fieldName converted to lowercase if it is a valid header, for characters +* where case conversion is possible +*/ + normalizeFieldName: function(fieldName) + { + if (fieldName == "") + throw Cr.NS_ERROR_INVALID_ARG; + + for (var i = 0, sz = fieldName.length; i < sz; i++) + { + if (!IS_TOKEN_ARRAY[fieldName.charCodeAt(i)]) + { + dumpn(fieldName + " is not a valid header field name!"); + throw Cr.NS_ERROR_INVALID_ARG; + } + } + + return fieldName.toLowerCase(); + }, + + /** +* Ensures that fieldValue is a valid header field value (although not +* necessarily as specified in RFC 2616 if the corresponding field name is +* part of the HTTP protocol), normalizes the value if it is, and +* returns the normalized value. +* +* @param fieldValue : string +* a value to be normalized as an HTTP header field value +* @throws NS_ERROR_INVALID_ARG +* if fieldValue does not match the field-value production in RFC 2616 +* @returns string +* fieldValue as a normalized HTTP header field value +*/ + normalizeFieldValue: function(fieldValue) + { + // field-value = *( field-content | LWS ) + // field-content = <the OCTETs making up the field-value + // and consisting of either *TEXT or combinations + // of token, separators, and quoted-string> + // TEXT = <any OCTET except CTLs, + // but including LWS> + // LWS = [CRLF] 1*( SP | HT ) + // + // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + // qdtext = <any TEXT except <">> + // quoted-pair = "\" CHAR + // CHAR = <any US-ASCII character (octets 0 - 127)> + + // Any LWS that occurs between field-content MAY be replaced with a single + // SP before interpreting the field value or forwarding the message + // downstream (section 4.2); we replace 1*LWS with a single SP + var val = fieldValue.replace(/(?:(?:\r\n)?[ \t]+)+/g, " "); + + // remove leading/trailing LWS (which has been converted to SP) + val = val.replace(/^ +/, "").replace(/ +$/, ""); + + // that should have taken care of all CTLs, so val should contain no CTLs + for (var i = 0, len = val.length; i < len; i++) + if (isCTL(val.charCodeAt(i))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX disallows quoted-pair where CHAR is a CTL -- will not invalidly + // normalize, however, so this can be construed as a tightening of the + // spec and not entirely as a bug + return val; + } +}; + + + +/** +* Converts the given string into a string which is safe for use in an HTML +* context. +* +* @param str : string +* the string to make HTML-safe +* @returns string +* an HTML-safe version of str +*/ +function htmlEscape(str) +{ + // this is naive, but it'll work + var s = ""; + for (var i = 0; i < str.length; i++) + s += "&#" + str.charCodeAt(i) + ";"; + return s; +} + + +/** +* Constructs an object representing an HTTP version (see section 3.1). +* +* @param versionString +* a string of the form "#.#", where # is an non-negative decimal integer with +* or without leading zeros +* @throws +* if versionString does not specify a valid HTTP version number +*/ +function nsHttpVersion(versionString) +{ + var matches = /^(\d+)\.(\d+)$/.exec(versionString); + if (!matches) + throw "Not a valid HTTP version!"; + + /** The major version number of this, as a number. */ + this.major = parseInt(matches[1], 10); + + /** The minor version number of this, as a number. */ + this.minor = parseInt(matches[2], 10); + + if (isNaN(this.major) || isNaN(this.minor) || + this.major < 0 || this.minor < 0) + throw "Not a valid HTTP version!"; +} +nsHttpVersion.prototype = +{ + /** +* Returns the standard string representation of the HTTP version represented +* by this (e.g., "1.1"). +*/ + toString: function () + { + return this.major + "." + this.minor; + }, + + /** +* Returns true if this represents the same HTTP version as otherVersion, +* false otherwise. +* +* @param otherVersion : nsHttpVersion +* the version to compare against this +*/ + equals: function (otherVersion) + { + return this.major == otherVersion.major && + this.minor == otherVersion.minor; + }, + + /** True if this >= otherVersion, false otherwise. */ + atLeast: function(otherVersion) + { + return this.major > otherVersion.major || + (this.major == otherVersion.major && + this.minor >= otherVersion.minor); + } +}; + +nsHttpVersion.HTTP_1_0 = new nsHttpVersion("1.0"); +nsHttpVersion.HTTP_1_1 = new nsHttpVersion("1.1"); + + +/** +* An object which stores HTTP headers for a request or response. +* +* Note that since headers are case-insensitive, this object converts headers to +* lowercase before storing them. This allows the getHeader and hasHeader +* methods to work correctly for any case of a header, but it means that the +* values returned by .enumerator may not be equal case-sensitively to the +* values passed to setHeader when adding headers to this. +*/ +function nsHttpHeaders() +{ + /** +* A hash of headers, with header field names as the keys and header field +* values as the values. Header field names are case-insensitive, but upon +* insertion here they are converted to lowercase. Header field values are +* normalized upon insertion to contain no leading or trailing whitespace. +* +* Note also that per RFC 2616, section 4.2, two headers with the same name in +* a message may be treated as one header with the same field name and a field +* value consisting of the separate field values joined together with a "," in +* their original order. This hash stores multiple headers with the same name +* in this manner. +*/ + this._headers = {}; +} +nsHttpHeaders.prototype = +{ + /** +* Sets the header represented by name and value in this. +* +* @param name : string +* the header name +* @param value : string +* the header value +* @throws NS_ERROR_INVALID_ARG +* if name or value is not a valid header component +*/ + setHeader: function(fieldName, fieldValue, merge) + { + var name = headerUtils.normalizeFieldName(fieldName); + var value = headerUtils.normalizeFieldValue(fieldValue); + + // The following three headers are stored as arrays because their real-world + // syntax prevents joining individual headers into a single header using + // ",". See also <http://hg.mozilla.org/mozilla-central/diff/9b2a99adc05e/netwerk/protocol/http/src/nsHttpHeaderArray.cpp#l77> + if (merge && name in this._headers) + { + if (name === "www-authenticate" || + name === "proxy-authenticate" || + name === "set-cookie") + { + this._headers[name].push(value); + } + else + { + this._headers[name][0] += "," + value; + NS_ASSERT(this._headers[name].length === 1, + "how'd a non-special header have multiple values?") + } + } + else + { + this._headers[name] = [value]; + } + }, + + /** +* Returns the value for the header specified by this. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns string +* the field value for the given header, possibly with non-semantic changes +* (i.e., leading/trailing whitespace stripped, whitespace runs replaced +* with spaces, etc.) at the option of the implementation; multiple +* instances of the header will be combined with a comma, except for +* the three headers noted in the description of getHeaderValues +*/ + getHeader: function(fieldName) + { + return this.getHeaderValues(fieldName).join("\n"); + }, + + /** +* Returns the value for the header specified by fieldName as an array. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns [string] +* an array of all the header values in this for the given +* header name. Header values will generally be collapsed +* into a single header by joining all header values together +* with commas, but certain headers (Proxy-Authenticate, +* WWW-Authenticate, and Set-Cookie) violate the HTTP spec +* and cannot be collapsed in this manner. For these headers +* only, the returned array may contain multiple elements if +* that header has been added more than once. +*/ + getHeaderValues: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + + if (name in this._headers) + return this._headers[name]; + else + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** +* Returns true if a header with the given field name exists in this, false +* otherwise. +* +* @param fieldName : string +* the field name whose existence is to be determined in this +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @returns boolean +* true if the header's present, false otherwise +*/ + hasHeader: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + return (name in this._headers); + }, + + /** +* Returns a new enumerator over the field names of the headers in this, as +* nsISupportsStrings. The names returned will be in lowercase, regardless of +* how they were input using setHeader (header names are case-insensitive per +* RFC 2616). +*/ + get enumerator() + { + var headers = []; + for (var i in this._headers) + { + var supports = new SupportsString(); + supports.data = i; + headers.push(supports); + } + + return new nsSimpleEnumerator(headers); + } +}; + + +/** +* Constructs an nsISimpleEnumerator for the given array of items. +* +* @param items : Array +* the items, which must all implement nsISupports +*/ +function nsSimpleEnumerator(items) +{ + this._items = items; + this._nextIndex = 0; +} +nsSimpleEnumerator.prototype = +{ + hasMoreElements: function() + { + return this._nextIndex < this._items.length; + }, + getNext: function() + { + if (!this.hasMoreElements()) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + return this._items[this._nextIndex++]; + }, + QueryInterface: function(aIID) + { + if (Ci.nsISimpleEnumerator.equals(aIID) || + Ci.nsISupports.equals(aIID)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +}; + + +/** +* A representation of the data in an HTTP request. +* +* @param port : uint +* the port on which the server receiving this request runs +*/ +function Request(port) +{ + /** Method of this request, e.g. GET or POST. */ + this._method = ""; + + /** Path of the requested resource; empty paths are converted to '/'. */ + this._path = ""; + + /** Query string, if any, associated with this request (not including '?'). */ + this._queryString = ""; + + /** Scheme of requested resource, usually http, always lowercase. */ + this._scheme = "http"; + + /** Hostname on which the requested resource resides. */ + this._host = undefined; + + /** Port number over which the request was received. */ + this._port = port; + + var bodyPipe = new Pipe(false, false, 0, PR_UINT32_MAX, null); + + /** Stream from which data in this request's body may be read. */ + this._bodyInputStream = bodyPipe.inputStream; + + /** Stream to which data in this request's body is written. */ + this._bodyOutputStream = bodyPipe.outputStream; + + /** +* The headers in this request. +*/ + this._headers = new nsHttpHeaders(); + + /** +* For the addition of ad-hoc properties and new functionality without having +* to change nsIHttpRequest every time; currently lazily created, as its only +* use is in directory listings. +*/ + this._bag = null; +} +Request.prototype = +{ + // SERVER METADATA + + // + // see nsIHttpRequest.scheme + // + get scheme() + { + return this._scheme; + }, + + // + // see nsIHttpRequest.host + // + get host() + { + return this._host; + }, + + // + // see nsIHttpRequest.port + // + get port() + { + return this._port; + }, + + // REQUEST LINE + + // + // see nsIHttpRequest.method + // + get method() + { + return this._method; + }, + + // + // see nsIHttpRequest.httpVersion + // + get httpVersion() + { + return this._httpVersion.toString(); + }, + + // + // see nsIHttpRequest.path + // + get path() + { + return this._path; + }, + + // + // see nsIHttpRequest.queryString + // + get queryString() + { + return this._queryString; + }, + + // HEADERS + + // + // see nsIHttpRequest.getHeader + // + getHeader: function(name) + { + return this._headers.getHeader(name); + }, + + // + // see nsIHttpRequest.hasHeader + // + hasHeader: function(name) + { + return this._headers.hasHeader(name); + }, + + // + // see nsIHttpRequest.headers + // + get headers() + { + return this._headers.enumerator; + }, + + // + // see nsIPropertyBag.enumerator + // + get enumerator() + { + this._ensurePropertyBag(); + return this._bag.enumerator; + }, + + // + // see nsIHttpRequest.headers + // + get bodyInputStream() + { + return this._bodyInputStream; + }, + + // + // see nsIPropertyBag.getProperty + // + getProperty: function(name) + { + this._ensurePropertyBag(); + return this._bag.getProperty(name); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpRequest) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** Ensures a property bag has been created for ad-hoc behaviors. */ + _ensurePropertyBag: function() + { + if (!this._bag) + this._bag = new WritablePropertyBag(); + } +}; + + +// XPCOM trappings +if ("XPCOMUtils" in this && // Firefox 3.6 doesn't load XPCOMUtils in this scope for some reason... + "generateNSGetFactory" in XPCOMUtils) { + var NSGetFactory = XPCOMUtils.generateNSGetFactory([nsHttpServer]); +} + + + +/** +* Creates a new HTTP server listening for loopback traffic on the given port, +* starts it, and runs the server until the server processes a shutdown request, +* spinning an event loop so that events posted by the server's socket are +* processed. +* +* This method is primarily intended for use in running this script from within +* xpcshell and running a functional HTTP server without having to deal with +* non-essential details. +* +* Note that running multiple servers using variants of this method probably +* doesn't work, simply due to how the internal event loop is spun and stopped. +* +* @note +* This method only works with Mozilla 1.9 (i.e., Firefox 3 or trunk code); +* you should use this server as a component in Mozilla 1.8. +* @param port +* the port on which the server will run, or -1 if there exists no preference +* for a specific port; note that attempting to use some values for this +* parameter (particularly those below 1024) may cause this method to throw or +* may result in the server being prematurely shut down +* @param basePath +* a local directory from which requests will be served (i.e., if this is +* "/home/jwalden/" then a request to /index.html will load +* /home/jwalden/index.html); if this is omitted, only the default URLs in +* this server implementation will be functional +*/ +function server(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + // if you're running this, you probably want to see debugging info + DEBUG = true; + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", SJS_TYPE); + srv.start(port); + + var thread = gThreadManager.currentThread; + while (!srv.isStopped()) + thread.processNextEvent(true); + + // get rid of any pending requests + while (thread.hasPendingEvents()) + thread.processNextEvent(true); + + DEBUG = false; +} + +function startServerAsync(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", "sjs"); + srv.start(port); + return srv; +} + +exports.nsHttpServer = nsHttpServer; +exports.ScriptableInputStream = ScriptableInputStream; +exports.server = server; +exports.startServerAsync = startServerAsync; diff --git a/addon-sdk/source/test/addons/content-script-messages-latency/main.js b/addon-sdk/source/test/addons/content-script-messages-latency/main.js new file mode 100644 index 000000000..39bd7b64b --- /dev/null +++ b/addon-sdk/source/test/addons/content-script-messages-latency/main.js @@ -0,0 +1,90 @@ +/* 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/. */ +"use strict"; + +const { PageMod } = require("sdk/page-mod"); +const tabs = require("sdk/tabs"); +const { startServerAsync } = require("./httpd"); +const { setTimeout } = require("sdk/timers"); + +const serverPort = 8099; + +exports.testContentScriptLatencyRegression = function*(assert) { + let server = startServerAsync(serverPort); + server.registerPathHandler("/", function handle(request, response) { + response.write(`<html> + <head> + <link rel="stylesheet" href="/slow.css"> + </head> + <body> + slow loading page... + </body> + </html>`); + }); + + server.registerPathHandler("/slow.css", function handle(request, response) { + response.processAsync(); + response.setHeader('Content-Type', 'text/css', false); + setTimeout(_ => { + response.write("body { background: red; }"); + response.finish(); + }, 2000); + }); + + + let pageMod; + + let worker = yield new Promise((resolve) => { + pageMod = PageMod({ + include: "http://localhost:8099/", + attachTo: "top", + contentScriptWhen: "start", + contentScript: "new " + function ContentScriptScope() { + self.port.on("a-port-message", function () { + self.port.emit("document-ready-state", document.readyState); + }); + }, + onAttach: function(w) { + resolve(w); + } + }); + + tabs.open({ + url: "http://localhost:8099/", + inBackground: true + }); + }); + + worker.port.emit("a-port-message"); + + let waitForPortMessage = new Promise((resolve) => { + worker.port.once("document-ready-state", (msg) => { + resolve(msg); + }); + }); + + let documentReadyState = yield waitForPortMessage; + + assert.notEqual( + "complete", documentReadyState, + "content script received the port message when the page was still loading" + ); + + assert.notEqual( + "uninitialized", documentReadyState, + "content script should be frozen if document.readyState is still uninitialized" + ); + + assert.ok( + ["loading", "interactive"].includes(documentReadyState), + "content script message received with document.readyState was interactive or loading" + ); + + // Cleanup. + pageMod.destroy(); + yield new Promise((resolve) => worker.tab.close(resolve)); + yield new Promise((resolve) => server.stop(resolve)); +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/content-script-messages-latency/package.json b/addon-sdk/source/test/addons/content-script-messages-latency/package.json new file mode 100644 index 000000000..8280fe18b --- /dev/null +++ b/addon-sdk/source/test/addons/content-script-messages-latency/package.json @@ -0,0 +1,6 @@ + +{ + "id": "content-script-messages-latency@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/contributors/main.js b/addon-sdk/source/test/addons/contributors/main.js new file mode 100644 index 000000000..3827f277b --- /dev/null +++ b/addon-sdk/source/test/addons/contributors/main.js @@ -0,0 +1,19 @@ +/* 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/. */ +'use strict'; + +const { id } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testContributors = function*(assert) { + let addon = yield getAddonByID(id); + let count = 0; + addon.contributors.forEach(({ name }) => { + assert.equal(name, ++count == 1 ? 'A' : 'B', 'The contributors keys are correct'); + }); + assert.equal(count, 2, 'The key count is correct'); + assert.equal(addon.contributors.length, 2, 'The key length is correct'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/contributors/package.json b/addon-sdk/source/test/addons/contributors/package.json new file mode 100644 index 000000000..b6f1798d3 --- /dev/null +++ b/addon-sdk/source/test/addons/contributors/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-contributors@jetpack", + "contributors": [ "A", "B" ], + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/curly-id/lib/main.js b/addon-sdk/source/test/addons/curly-id/lib/main.js new file mode 100644 index 000000000..8b3f25645 --- /dev/null +++ b/addon-sdk/source/test/addons/curly-id/lib/main.js @@ -0,0 +1,29 @@ +/* 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/. */ +'use strict'; + +const simple = require('sdk/simple-prefs'); +const service = require('sdk/preferences/service'); +const { id, preferencesBranch } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testCurlyID = function(assert) { + assert.equal(id, '{34a1eae1-c20a-464f-9b0e-000000000000}', 'curly ID is curly'); + assert.equal(simple.prefs.test13, 26, 'test13 is 26'); + + simple.prefs.test14 = '15'; + assert.equal(service.get('extensions.{34a1eae1-c20a-464f-9b0e-000000000000}.test14'), '15', 'test14 is 15'); + assert.equal(service.get('extensions.{34a1eae1-c20a-464f-9b0e-000000000000}.test14'), simple.prefs.test14, 'simple test14 also 15'); +} + +// from `/test/test-self.js`, adapted to `sdk/test/assert` API +exports.testSelfID = function*(assert) { + assert.equal(typeof(id), 'string', 'self.id is a string'); + assert.ok(id.length > 0, 'self.id not empty'); + + let addon = yield getAddonByID(id); + assert.ok(addon, 'found addon with self.id'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/curly-id/package.json b/addon-sdk/source/test/addons/curly-id/package.json new file mode 100644 index 000000000..213844662 --- /dev/null +++ b/addon-sdk/source/test/addons/curly-id/package.json @@ -0,0 +1,13 @@ +{ + "id": "{34a1eae1-c20a-464f-9b0e-000000000000}", + "fullName": "curly ID test", + "author": "Tomislav Jovanovic", + "preferences": [{ + "name": "test13", + "type": "integer", + "title": "test13", + "value": 26 + }], + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/developers/main.js b/addon-sdk/source/test/addons/developers/main.js new file mode 100644 index 000000000..d42faf643 --- /dev/null +++ b/addon-sdk/source/test/addons/developers/main.js @@ -0,0 +1,19 @@ +/* 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/. */ +'use strict'; + +const { id } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testDevelopers = function*(assert) { + let addon = yield getAddonByID(id); + let count = 0; + addon.developers.forEach(({ name }) => { + assert.equal(name, ++count == 1 ? 'A' : 'B', 'The developers keys are correct'); + }); + assert.equal(count, 2, 'The key count is correct'); + assert.equal(addon.developers.length, 2, 'The key length is correct'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/developers/package.json b/addon-sdk/source/test/addons/developers/package.json new file mode 100644 index 000000000..1d2a189ec --- /dev/null +++ b/addon-sdk/source/test/addons/developers/package.json @@ -0,0 +1,8 @@ +{ + "id": "test-developers@jetpack", + "title": "Test developers package key", + "author": "Erik Vold", + "developers": [ "A", "B" ], + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/e10s-content/data/test-contentScriptFile.js b/addon-sdk/source/test/addons/e10s-content/data/test-contentScriptFile.js new file mode 100644 index 000000000..7dc0e3f24 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/data/test-contentScriptFile.js @@ -0,0 +1,5 @@ +/* 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/. */ + +self.postMessage("msg from contentScriptFile"); diff --git a/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.html b/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.html new file mode 100644 index 000000000..85264034a --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<html> +<head> + <meta charset="UTF-8"> + <title>Page Worker test</title> +</head> +<body> + <p id="paragraph">Lorem ipsum dolor sit amet.</p> +</body> +</html> diff --git a/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.js b/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.js new file mode 100644 index 000000000..5114fe4e0 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/data/test-page-worker.js @@ -0,0 +1,29 @@ +/* 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/. */ + + +// get title directly +self.postMessage(["equal", document.title, "Page Worker test", + "Correct page title accessed directly"]); + +// get <p> directly +var p = document.getElementById("paragraph"); +self.postMessage(["ok", !!p, "<p> can be accessed directly"]); +self.postMessage(["equal", p.firstChild.nodeValue, + "Lorem ipsum dolor sit amet.", + "Correct text node expected"]); + +// Modify page +var div = document.createElement("div"); +div.setAttribute("id", "block"); +div.appendChild(document.createTextNode("Test text created")); +document.body.appendChild(div); + +// Check back the modification +div = document.getElementById("block"); +self.postMessage(["ok", !!div, "<div> can be accessed directly"]); +self.postMessage(["equal", div.firstChild.nodeValue, + "Test text created", "Correct text node expected"]); +self.postMessage(["done"]); + diff --git a/addon-sdk/source/test/addons/e10s-content/data/test.html b/addon-sdk/source/test/addons/e10s-content/data/test.html new file mode 100644 index 000000000..181e85f9b --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/data/test.html @@ -0,0 +1,13 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>foo</title> + </head> + <body> + <p>bar</p> + </body> +</html> diff --git a/addon-sdk/source/test/addons/e10s-content/lib/fixtures.js b/addon-sdk/source/test/addons/e10s-content/lib/fixtures.js new file mode 100644 index 000000000..d3bd49300 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/fixtures.js @@ -0,0 +1,8 @@ +/* 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/. */ +"use strict"; + +const { data } = require('sdk/self'); + +exports.url = data.url; diff --git a/addon-sdk/source/test/addons/e10s-content/lib/httpd.js b/addon-sdk/source/test/addons/e10s-content/lib/httpd.js new file mode 100644 index 000000000..e46ca96a0 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/httpd.js @@ -0,0 +1,5212 @@ +/* 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/. */ + +/* +* An implementation of an HTTP server both as a loadable script and as an XPCOM +* component. See the accompanying README file for user documentation on +* httpd.js. +*/ + +module.metadata = { + "stability": "experimental" +}; + +const { components, CC, Cc, Ci, Cr, Cu } = require("chrome"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + +const PR_UINT32_MAX = Math.pow(2, 32) - 1; + +/** True if debugging output is enabled, false otherwise. */ +var DEBUG = false; // non-const *only* so tweakable in server tests + +/** True if debugging output should be timestamped. */ +var DEBUG_TIMESTAMP = false; // non-const so tweakable in server tests + +var gGlobalObject = Cc["@mozilla.org/systemprincipal;1"].createInstance(); + +/** +* Asserts that the given condition holds. If it doesn't, the given message is +* dumped, a stack trace is printed, and an exception is thrown to attempt to +* stop execution (which unfortunately must rely upon the exception not being +* accidentally swallowed by the code that uses it). +*/ +function NS_ASSERT(cond, msg) +{ + if (DEBUG && !cond) + { + dumpn("###!!!"); + dumpn("###!!! ASSERTION" + (msg ? ": " + msg : "!")); + dumpn("###!!! Stack follows:"); + + var stack = new Error().stack.split(/\n/); + dumpn(stack.map(function(val) { return "###!!! " + val; }).join("\n")); + + throw Cr.NS_ERROR_ABORT; + } +} + +/** Constructs an HTTP error object. */ +function HttpError(code, description) +{ + this.code = code; + this.description = description; +} +HttpError.prototype = +{ + toString: function() + { + return this.code + " " + this.description; + } +}; + +/** +* Errors thrown to trigger specific HTTP server responses. +*/ +const HTTP_400 = new HttpError(400, "Bad Request"); +const HTTP_401 = new HttpError(401, "Unauthorized"); +const HTTP_402 = new HttpError(402, "Payment Required"); +const HTTP_403 = new HttpError(403, "Forbidden"); +const HTTP_404 = new HttpError(404, "Not Found"); +const HTTP_405 = new HttpError(405, "Method Not Allowed"); +const HTTP_406 = new HttpError(406, "Not Acceptable"); +const HTTP_407 = new HttpError(407, "Proxy Authentication Required"); +const HTTP_408 = new HttpError(408, "Request Timeout"); +const HTTP_409 = new HttpError(409, "Conflict"); +const HTTP_410 = new HttpError(410, "Gone"); +const HTTP_411 = new HttpError(411, "Length Required"); +const HTTP_412 = new HttpError(412, "Precondition Failed"); +const HTTP_413 = new HttpError(413, "Request Entity Too Large"); +const HTTP_414 = new HttpError(414, "Request-URI Too Long"); +const HTTP_415 = new HttpError(415, "Unsupported Media Type"); +const HTTP_417 = new HttpError(417, "Expectation Failed"); + +const HTTP_500 = new HttpError(500, "Internal Server Error"); +const HTTP_501 = new HttpError(501, "Not Implemented"); +const HTTP_502 = new HttpError(502, "Bad Gateway"); +const HTTP_503 = new HttpError(503, "Service Unavailable"); +const HTTP_504 = new HttpError(504, "Gateway Timeout"); +const HTTP_505 = new HttpError(505, "HTTP Version Not Supported"); + +/** Creates a hash with fields corresponding to the values in arr. */ +function array2obj(arr) +{ + var obj = {}; + for (var i = 0; i < arr.length; i++) + obj[arr[i]] = arr[i]; + return obj; +} + +/** Returns an array of the integers x through y, inclusive. */ +function range(x, y) +{ + var arr = []; + for (var i = x; i <= y; i++) + arr.push(i); + return arr; +} + +/** An object (hash) whose fields are the numbers of all HTTP error codes. */ +const HTTP_ERROR_CODES = array2obj(range(400, 417).concat(range(500, 505))); + + +/** +* The character used to distinguish hidden files from non-hidden files, a la +* the leading dot in Apache. Since that mechanism also hides files from +* easy display in LXR, ls output, etc. however, we choose instead to use a +* suffix character. If a requested file ends with it, we append another +* when getting the file on the server. If it doesn't, we just look up that +* file. Therefore, any file whose name ends with exactly one of the character +* is "hidden" and available for use by the server. +*/ +const HIDDEN_CHAR = "^"; + +/** +* The file name suffix indicating the file containing overridden headers for +* a requested file. +*/ +const HEADERS_SUFFIX = HIDDEN_CHAR + "headers" + HIDDEN_CHAR; + +/** Type used to denote SJS scripts for CGI-like functionality. */ +const SJS_TYPE = "sjs"; + +/** Base for relative timestamps produced by dumpn(). */ +var firstStamp = 0; + +/** dump(str) with a trailing "\n" -- only outputs if DEBUG. */ +function dumpn(str) +{ + if (DEBUG) + { + var prefix = "HTTPD-INFO | "; + if (DEBUG_TIMESTAMP) + { + if (firstStamp === 0) + firstStamp = Date.now(); + + var elapsed = Date.now() - firstStamp; // milliseconds + var min = Math.floor(elapsed / 60000); + var sec = (elapsed % 60000) / 1000; + + if (sec < 10) + prefix += min + ":0" + sec.toFixed(3) + " | "; + else + prefix += min + ":" + sec.toFixed(3) + " | "; + } + + dump(prefix + str + "\n"); + } +} + +/** Dumps the current JS stack if DEBUG. */ +function dumpStack() +{ + // peel off the frames for dumpStack() and Error() + var stack = new Error().stack.split(/\n/).slice(2); + stack.forEach(dumpn); +} + + +/** The XPCOM thread manager. */ +var gThreadManager = null; + +/** The XPCOM prefs service. */ +var gRootPrefBranch = null; +function getRootPrefBranch() +{ + if (!gRootPrefBranch) + { + gRootPrefBranch = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefBranch); + } + return gRootPrefBranch; +} + +/** +* JavaScript constructors for commonly-used classes; precreating these is a +* speedup over doing the same from base principles. See the docs at +* http://developer.mozilla.org/en/docs/components.Constructor for details. +*/ +const ServerSocket = CC("@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init"); +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init"); +const Pipe = CC("@mozilla.org/pipe;1", + "nsIPipe", + "init"); +const FileInputStream = CC("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init"); +const ConverterInputStream = CC("@mozilla.org/intl/converter-input-stream;1", + "nsIConverterInputStream", + "init"); +const WritablePropertyBag = CC("@mozilla.org/hash-property-bag;1", + "nsIWritablePropertyBag2"); +const SupportsString = CC("@mozilla.org/supports-string;1", + "nsISupportsString"); + +/* These two are non-const only so a test can overwrite them. */ +var BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream"); +var BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream"); + +/** +* Returns the RFC 822/1123 representation of a date. +* +* @param date : Number +* the date, in milliseconds from midnight (00:00:00), January 1, 1970 GMT +* @returns string +* the representation of the given date +*/ +function toDateString(date) +{ + // + // rfc1123-date = wkday "," SP date1 SP time SP "GMT" + // date1 = 2DIGIT SP month SP 4DIGIT + // ; day month year (e.g., 02 Jun 1982) + // time = 2DIGIT ":" 2DIGIT ":" 2DIGIT + // ; 00:00:00 - 23:59:59 + // wkday = "Mon" | "Tue" | "Wed" + // | "Thu" | "Fri" | "Sat" | "Sun" + // month = "Jan" | "Feb" | "Mar" | "Apr" + // | "May" | "Jun" | "Jul" | "Aug" + // | "Sep" | "Oct" | "Nov" | "Dec" + // + + const wkdayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + /** +* Processes a date and returns the encoded UTC time as a string according to +* the format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toTime(date) + { + var hrs = date.getUTCHours(); + var rv = (hrs < 10) ? "0" + hrs : hrs; + + var mins = date.getUTCMinutes(); + rv += ":"; + rv += (mins < 10) ? "0" + mins : mins; + + var secs = date.getUTCSeconds(); + rv += ":"; + rv += (secs < 10) ? "0" + secs : secs; + + return rv; + } + + /** +* Processes a date and returns the encoded UTC date as a string according to +* the date1 format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toDate1(date) + { + var day = date.getUTCDate(); + var month = date.getUTCMonth(); + var year = date.getUTCFullYear(); + + var rv = (day < 10) ? "0" + day : day; + rv += " " + monthStrings[month]; + rv += " " + year; + + return rv; + } + + date = new Date(date); + + const fmtString = "%wkday%, %date1% %time% GMT"; + var rv = fmtString.replace("%wkday%", wkdayStrings[date.getUTCDay()]); + rv = rv.replace("%time%", toTime(date)); + return rv.replace("%date1%", toDate1(date)); +} + +/** +* Prints out a human-readable representation of the object o and its fields, +* omitting those whose names begin with "_" if showMembers != true (to ignore +* "private" properties exposed via getters/setters). +*/ +function printObj(o, showMembers) +{ + var s = "******************************\n"; + s += "o = {\n"; + for (var i in o) + { + if (typeof(i) != "string" || + (showMembers || (i.length > 0 && i[0] != "_"))) + s+= " " + i + ": " + o[i] + ",\n"; + } + s += " };\n"; + s += "******************************"; + dumpn(s); +} + +/** +* Instantiates a new HTTP server. +*/ +function nsHttpServer() +{ + if (!gThreadManager) + gThreadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + + /** The port on which this server listens. */ + this._port = undefined; + + /** The socket associated with this. */ + this._socket = null; + + /** The handler used to process requests to this server. */ + this._handler = new ServerHandler(this); + + /** Naming information for this server. */ + this._identity = new ServerIdentity(); + + /** +* Indicates when the server is to be shut down at the end of the request. +*/ + this._doQuit = false; + + /** +* True if the socket in this is closed (and closure notifications have been +* sent and processed if the socket was ever opened), false otherwise. +*/ + this._socketClosed = true; + + /** +* Used for tracking existing connections and ensuring that all connections +* are properly cleaned up before server shutdown; increases by 1 for every +* new incoming connection. +*/ + this._connectionGen = 0; + + /** +* Hash of all open connections, indexed by connection number at time of +* creation. +*/ + this._connections = {}; +} +nsHttpServer.prototype = +{ + classID: components.ID("{54ef6f81-30af-4b1d-ac55-8ba811293e41}"), + + // NSISERVERSOCKETLISTENER + + /** +* Processes an incoming request coming in on the given socket and contained +* in the given transport. +* +* @param socket : nsIServerSocket +* the socket through which the request was served +* @param trans : nsISocketTransport +* the transport for the request/response +* @see nsIServerSocketListener.onSocketAccepted +*/ + onSocketAccepted: function(socket, trans) + { + dumpn("*** onSocketAccepted(socket=" + socket + ", trans=" + trans + ")"); + + dumpn(">>> new connection on " + trans.host + ":" + trans.port); + + const SEGMENT_SIZE = 8192; + const SEGMENT_COUNT = 1024; + try + { + var input = trans.openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT) + .QueryInterface(Ci.nsIAsyncInputStream); + var output = trans.openOutputStream(0, 0, 0); + } + catch (e) + { + dumpn("*** error opening transport streams: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + var connectionNumber = ++this._connectionGen; + + try + { + var conn = new Connection(input, output, this, socket.port, trans.port, + connectionNumber); + var reader = new RequestReader(conn); + + // XXX add request timeout functionality here! + + // Note: must use main thread here, or we might get a GC that will cause + // threadsafety assertions. We really need to fix XPConnect so that + // you can actually do things in multi-threaded JS. :-( + input.asyncWait(reader, 0, 0, gThreadManager.mainThread); + } + catch (e) + { + // Assume this connection can't be salvaged and bail on it completely; + // don't attempt to close it so that we can assert that any connection + // being closed is in this._connections. + dumpn("*** error in initial request-processing stages: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + this._connections[connectionNumber] = conn; + dumpn("*** starting connection " + connectionNumber); + }, + + /** +* Called when the socket associated with this is closed. +* +* @param socket : nsIServerSocket +* the socket being closed +* @param status : nsresult +* the reason the socket stopped listening (NS_BINDING_ABORTED if the server +* was stopped using nsIHttpServer.stop) +* @see nsIServerSocketListener.onStopListening +*/ + onStopListening: function(socket, status) + { + dumpn(">>> shutting down server on port " + socket.port); + this._socketClosed = true; + if (!this._hasOpenConnections()) + { + dumpn("*** no open connections, notifying async from onStopListening"); + + // Notify asynchronously so that any pending teardown in stop() has a + // chance to run first. + var self = this; + var stopEvent = + { + run: function() + { + dumpn("*** _notifyStopped async callback"); + self._notifyStopped(); + } + }; + gThreadManager.currentThread + .dispatch(stopEvent, Ci.nsIThread.DISPATCH_NORMAL); + } + }, + + // NSIHTTPSERVER + + // + // see nsIHttpServer.start + // + start: function(port) + { + this._start(port, "localhost") + }, + + _start: function(port, host) + { + if (this._socket) + throw Cr.NS_ERROR_ALREADY_INITIALIZED; + + this._port = port; + this._doQuit = this._socketClosed = false; + + this._host = host; + + // The listen queue needs to be long enough to handle + // network.http.max-persistent-connections-per-server concurrent connections, + // plus a safety margin in case some other process is talking to + // the server as well. + var prefs = getRootPrefBranch(); + var maxConnections; + try { + // Bug 776860: The original pref was removed in favor of this new one: + maxConnections = prefs.getIntPref("network.http.max-persistent-connections-per-server") + 5; + } + catch(e) { + maxConnections = prefs.getIntPref("network.http.max-connections-per-server") + 5; + } + + try + { + var loopback = true; + if (this._host != "127.0.0.1" && this._host != "localhost") { + var loopback = false; + } + + var socket = new ServerSocket(this._port, + loopback, // true = localhost, false = everybody + maxConnections); + dumpn(">>> listening on port " + socket.port + ", " + maxConnections + + " pending connections"); + socket.asyncListen(this); + this._identity._initialize(socket.port, host, true); + this._socket = socket; + } + catch (e) + { + dumpn("!!! could not start server on port " + port + ": " + e); + throw Cr.NS_ERROR_NOT_AVAILABLE; + } + }, + + // + // see nsIHttpServer.stop + // + stop: function(callback) + { + if (!callback) + throw Cr.NS_ERROR_NULL_POINTER; + if (!this._socket) + throw Cr.NS_ERROR_UNEXPECTED; + + this._stopCallback = typeof callback === "function" + ? callback + : function() { callback.onStopped(); }; + + dumpn(">>> stopping listening on port " + this._socket.port); + this._socket.close(); + this._socket = null; + + // We can't have this identity any more, and the port on which we're running + // this server now could be meaningless the next time around. + this._identity._teardown(); + + this._doQuit = false; + + // socket-close notification and pending request completion happen async + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (file && (!file.exists() || file.isDirectory())) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handler.registerFile(path, file); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // XXX true path validation! + if (path.charAt(0) != "/" || + path.charAt(path.length - 1) != "/" || + (directory && + (!directory.exists() || !directory.isDirectory()))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX determine behavior of nonexistent /foo/bar when a /foo/bar/ mapping + // exists! + + this._handler.registerDirectory(path, directory); + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + this._handler.registerPathHandler(path, handler); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + this._handler.registerPrefixHandler(prefix, handler); + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(code, handler) + { + this._handler.registerErrorHandler(code, handler); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + this._handler.setIndexHandler(handler); + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + this._handler.registerContentType(ext, type); + }, + + // + // see nsIHttpServer.serverIdentity + // + get identity() + { + return this._identity; + }, + + // + // see nsIHttpServer.getState + // + getState: function(path, k) + { + return this._handler._getState(path, k); + }, + + // + // see nsIHttpServer.setState + // + setState: function(path, k, v) + { + return this._handler._setState(path, k, v); + }, + + // + // see nsIHttpServer.getSharedState + // + getSharedState: function(k) + { + return this._handler._getSharedState(k); + }, + + // + // see nsIHttpServer.setSharedState + // + setSharedState: function(k, v) + { + return this._handler._setSharedState(k, v); + }, + + // + // see nsIHttpServer.getObjectState + // + getObjectState: function(k) + { + return this._handler._getObjectState(k); + }, + + // + // see nsIHttpServer.setObjectState + // + setObjectState: function(k, v) + { + return this._handler._setObjectState(k, v); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIServerSocketListener) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NON-XPCOM PUBLIC API + + /** +* Returns true iff this server is not running (and is not in the process of +* serving any requests still to be processed when the server was last +* stopped after being run). +*/ + isStopped: function() + { + return this._socketClosed && !this._hasOpenConnections(); + }, + + // PRIVATE IMPLEMENTATION + + /** True if this server has any open connections to it, false otherwise. */ + _hasOpenConnections: function() + { + // + // If we have any open connections, they're tracked as numeric properties on + // |this._connections|. The non-standard __count__ property could be used + // to check whether there are any properties, but standard-wise, even + // looking forward to ES5, there's no less ugly yet still O(1) way to do + // this. + // + for (var n in this._connections) + return true; + return false; + }, + + /** Calls the server-stopped callback provided when stop() was called. */ + _notifyStopped: function() + { + NS_ASSERT(this._stopCallback !== null, "double-notifying?"); + NS_ASSERT(!this._hasOpenConnections(), "should be done serving by now"); + + // + // NB: We have to grab this now, null out the member, *then* call the + // callback here, or otherwise the callback could (indirectly) futz with + // this._stopCallback by starting and immediately stopping this, at + // which point we'd be nulling out a field we no longer have a right to + // modify. + // + var callback = this._stopCallback; + this._stopCallback = null; + try + { + callback(); + } + catch (e) + { + // not throwing because this is specified as being usually (but not + // always) asynchronous + dump("!!! error running onStopped callback: " + e + "\n"); + } + }, + + /** +* Notifies this server that the given connection has been closed. +* +* @param connection : Connection +* the connection that was closed +*/ + _connectionClosed: function(connection) + { + NS_ASSERT(connection.number in this._connections, + "closing a connection " + this + " that we never added to the " + + "set of open connections?"); + NS_ASSERT(this._connections[connection.number] === connection, + "connection number mismatch? " + + this._connections[connection.number]); + delete this._connections[connection.number]; + + // Fire a pending server-stopped notification if it's our responsibility. + if (!this._hasOpenConnections() && this._socketClosed) + this._notifyStopped(); + }, + + /** +* Requests that the server be shut down when possible. +*/ + _requestQuit: function() + { + dumpn(">>> requesting a quit"); + dumpStack(); + this._doQuit = true; + } +}; + + +// +// RFC 2396 section 3.2.2: +// +// host = hostname | IPv4address +// hostname = *( domainlabel "." ) toplabel [ "." ] +// domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum +// toplabel = alpha | alpha *( alphanum | "-" ) alphanum +// IPv4address = 1*digit "." 1*digit "." 1*digit "." 1*digit +// + +const HOST_REGEX = + new RegExp("^(?:" + + // *( domainlabel "." ) + "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*" + + // toplabel + "[a-z](?:[a-z0-9-]*[a-z0-9])?" + + "|" + + // IPv4 address + "\\d+\\.\\d+\\.\\d+\\.\\d+" + + ")$", + "i"); + + +/** +* Represents the identity of a server. An identity consists of a set of +* (scheme, host, port) tuples denoted as locations (allowing a single server to +* serve multiple sites or to be used behind both HTTP and HTTPS proxies for any +* host/port). Any incoming request must be to one of these locations, or it +* will be rejected with an HTTP 400 error. One location, denoted as the +* primary location, is the location assigned in contexts where a location +* cannot otherwise be endogenously derived, such as for HTTP/1.0 requests. +* +* A single identity may contain at most one location per unique host/port pair; +* other than that, no restrictions are placed upon what locations may +* constitute an identity. +*/ +function ServerIdentity() +{ + /** The scheme of the primary location. */ + this._primaryScheme = "http"; + + /** The hostname of the primary location. */ + this._primaryHost = "127.0.0.1" + + /** The port number of the primary location. */ + this._primaryPort = -1; + + /** +* The current port number for the corresponding server, stored so that a new +* primary location can always be set if the current one is removed. +*/ + this._defaultPort = -1; + + /** +* Maps hosts to maps of ports to schemes, e.g. the following would represent +* https://example.com:789/ and http://example.org/: +* +* { +* "xexample.com": { 789: "https" }, +* "xexample.org": { 80: "http" } +* } +* +* Note the "x" prefix on hostnames, which prevents collisions with special +* JS names like "prototype". +*/ + this._locations = { "xlocalhost": {} }; +} +ServerIdentity.prototype = +{ + // NSIHTTPSERVERIDENTITY + + // + // see nsIHttpServerIdentity.primaryScheme + // + get primaryScheme() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryScheme; + }, + + // + // see nsIHttpServerIdentity.primaryHost + // + get primaryHost() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryHost; + }, + + // + // see nsIHttpServerIdentity.primaryPort + // + get primaryPort() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryPort; + }, + + // + // see nsIHttpServerIdentity.add + // + add: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + this._locations["x" + host] = entry = {}; + + entry[port] = scheme; + }, + + // + // see nsIHttpServerIdentity.remove + // + remove: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return false; + + var present = port in entry; + delete entry[port]; + + if (this._primaryScheme == scheme && + this._primaryHost == host && + this._primaryPort == port && + this._defaultPort !== -1) + { + // Always keep at least one identity in existence at any time, unless + // we're in the process of shutting down (the last condition above). + this._primaryPort = -1; + this._initialize(this._defaultPort, host, false); + } + + return present; + }, + + // + // see nsIHttpServerIdentity.has + // + has: function(scheme, host, port) + { + this._validate(scheme, host, port); + + return "x" + host in this._locations && + scheme === this._locations["x" + host][port]; + }, + + // + // see nsIHttpServerIdentity.has + // + getScheme: function(host, port) + { + this._validate("http", host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return ""; + + return entry[port] || ""; + }, + + // + // see nsIHttpServerIdentity.setPrimary + // + setPrimary: function(scheme, host, port) + { + this._validate(scheme, host, port); + + this.add(scheme, host, port); + + this._primaryScheme = scheme; + this._primaryHost = host; + this._primaryPort = port; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpServerIdentity) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Initializes the primary name for the corresponding server, based on the +* provided port number. +*/ + _initialize: function(port, host, addSecondaryDefault) + { + this._host = host; + if (this._primaryPort !== -1) + this.add("http", host, port); + else + this.setPrimary("http", "localhost", port); + this._defaultPort = port; + + // Only add this if we're being called at server startup + if (addSecondaryDefault && host != "127.0.0.1") + this.add("http", "127.0.0.1", port); + }, + + /** +* Called at server shutdown time, unsets the primary location only if it was +* the default-assigned location and removes the default location from the +* set of locations used. +*/ + _teardown: function() + { + if (this._host != "127.0.0.1") { + // Not the default primary location, nothing special to do here + this.remove("http", "127.0.0.1", this._defaultPort); + } + + // This is a *very* tricky bit of reasoning here; make absolutely sure the + // tests for this code pass before you commit changes to it. + if (this._primaryScheme == "http" && + this._primaryHost == this._host && + this._primaryPort == this._defaultPort) + { + // Make sure we don't trigger the readding logic in .remove(), then remove + // the default location. + var port = this._defaultPort; + this._defaultPort = -1; + this.remove("http", this._host, port); + + // Ensure a server start triggers the setPrimary() path in ._initialize() + this._primaryPort = -1; + } + else + { + // No reason not to remove directly as it's not our primary location + this.remove("http", this._host, this._defaultPort); + } + }, + + /** +* Ensures scheme, host, and port are all valid with respect to RFC 2396. +* +* @throws NS_ERROR_ILLEGAL_VALUE +* if any argument doesn't match the corresponding production +*/ + _validate: function(scheme, host, port) + { + if (scheme !== "http" && scheme !== "https") + { + dumpn("*** server only supports http/https schemes: '" + scheme + "'"); + dumpStack(); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (!HOST_REGEX.test(host)) + { + dumpn("*** unexpected host: '" + host + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (port < 0 || port > 65535) + { + dumpn("*** unexpected port: '" + port + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + } +}; + + +/** +* Represents a connection to the server (and possibly in the future the thread +* on which the connection is processed). +* +* @param input : nsIInputStream +* stream from which incoming data on the connection is read +* @param output : nsIOutputStream +* stream to write data out the connection +* @param server : nsHttpServer +* the server handling the connection +* @param port : int +* the port on which the server is running +* @param outgoingPort : int +* the outgoing port used by this connection +* @param number : uint +* a serial number used to uniquely identify this connection +*/ +function Connection(input, output, server, port, outgoingPort, number) +{ + dumpn("*** opening new connection " + number + " on port " + outgoingPort); + + /** Stream of incoming data. */ + this.input = input; + + /** Stream for outgoing data. */ + this.output = output; + + /** The server associated with this request. */ + this.server = server; + + /** The port on which the server is running. */ + this.port = port; + + /** The outgoing poort used by this connection. */ + this._outgoingPort = outgoingPort; + + /** The serial number of this connection. */ + this.number = number; + + /** +* The request for which a response is being generated, null if the +* incoming request has not been fully received or if it had errors. +*/ + this.request = null; + + /** State variables for debugging. */ + this._closed = this._processed = false; +} +Connection.prototype = +{ + /** Closes this connection's input/output streams. */ + close: function() + { + dumpn("*** closing connection " + this.number + + " on port " + this._outgoingPort); + + this.input.close(); + this.output.close(); + this._closed = true; + + var server = this.server; + server._connectionClosed(this); + + // If an error triggered a server shutdown, act on it now + if (server._doQuit) + server.stop(function() { /* not like we can do anything better */ }); + }, + + /** +* Initiates processing of this connection, using the data in the given +* request. +* +* @param request : Request +* the request which should be processed +*/ + process: function(request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + + this.request = request; + this.server._handler.handleResponse(this); + }, + + /** +* Initiates processing of this connection, generating a response with the +* given HTTP error code. +* +* @param code : uint +* an HTTP code, so in the range [0, 1000) +* @param request : Request +* incomplete data about the incoming request (since there were errors +* during its processing +*/ + processError: function(code, request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + this.request = request; + this.server._handler.handleError(code, this); + }, + + /** Converts this to a string for debugging purposes. */ + toString: function() + { + return "<Connection(" + this.number + + (this.request ? ", " + this.request.path : "") +"): " + + (this._closed ? "closed" : "open") + ">"; + } +}; + + + +/** Returns an array of count bytes from the given input stream. */ +function readBytes(inputStream, count) +{ + return new BinaryInputStream(inputStream).readByteArray(count); +} + + + +/** Request reader processing states; see RequestReader for details. */ +const READER_IN_REQUEST_LINE = 0; +const READER_IN_HEADERS = 1; +const READER_IN_BODY = 2; +const READER_FINISHED = 3; + + +/** +* Reads incoming request data asynchronously, does any necessary preprocessing, +* and forwards it to the request handler. Processing occurs in three states: +* +* READER_IN_REQUEST_LINE Reading the request's status line +* READER_IN_HEADERS Reading headers in the request +* READER_IN_BODY Reading the body of the request +* READER_FINISHED Entire request has been read and processed +* +* During the first two stages, initial metadata about the request is gathered +* into a Request object. Once the status line and headers have been processed, +* we start processing the body of the request into the Request. Finally, when +* the entire body has been read, we create a Response and hand it off to the +* ServerHandler to be given to the appropriate request handler. +* +* @param connection : Connection +* the connection for the request being read +*/ +function RequestReader(connection) +{ + /** Connection metadata for this request. */ + this._connection = connection; + + /** +* A container providing line-by-line access to the raw bytes that make up the +* data which has been read from the connection but has not yet been acted +* upon (by passing it to the request handler or by extracting request +* metadata from it). +*/ + this._data = new LineData(); + + /** +* The amount of data remaining to be read from the body of this request. +* After all headers in the request have been read this is the value in the +* Content-Length header, but as the body is read its value decreases to zero. +*/ + this._contentLength = 0; + + /** The current state of parsing the incoming request. */ + this._state = READER_IN_REQUEST_LINE; + + /** Metadata constructed from the incoming request for the request handler. */ + this._metadata = new Request(connection.port); + + /** +* Used to preserve state if we run out of line data midway through a +* multi-line header. _lastHeaderName stores the name of the header, while +* _lastHeaderValue stores the value we've seen so far for the header. +* +* These fields are always either both undefined or both strings. +*/ + this._lastHeaderName = this._lastHeaderValue = undefined; +} +RequestReader.prototype = +{ + // NSIINPUTSTREAMCALLBACK + + /** +* Called when more data from the incoming request is available. This method +* then reads the available data from input and deals with that data as +* necessary, depending upon the syntax of already-downloaded data. +* +* @param input : nsIAsyncInputStream +* the stream of incoming data from the connection +*/ + onInputStreamReady: function(input) + { + dumpn("*** onInputStreamReady(input=" + input + ") on thread " + + gThreadManager.currentThread + " (main is " + + gThreadManager.mainThread + ")"); + dumpn("*** this._state == " + this._state); + + // Handle cases where we get more data after a request error has been + // discovered but *before* we can close the connection. + var data = this._data; + if (!data) + return; + + try + { + data.appendBytes(readBytes(input, input.available())); + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** WARNING: unexpected error when reading from socket; will " + + "be treated as if the input stream had been closed"); + dumpn("*** WARNING: actual error was: " + e); + } + + // We've lost a race -- input has been closed, but we're still expecting + // to read more data. available() will throw in this case, and since + // we're dead in the water now, destroy the connection. + dumpn("*** onInputStreamReady called on a closed input, destroying " + + "connection"); + this._connection.close(); + return; + } + + switch (this._state) + { + default: + NS_ASSERT(false, "invalid state: " + this._state); + break; + + case READER_IN_REQUEST_LINE: + if (!this._processRequestLine()) + break; + /* fall through */ + + case READER_IN_HEADERS: + if (!this._processHeaders()) + break; + /* fall through */ + + case READER_IN_BODY: + this._processBody(); + } + + if (this._state != READER_FINISHED) + input.asyncWait(this, 0, 0, gThreadManager.currentThread); + }, + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIInputStreamCallback) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE API + + /** +* Processes unprocessed, downloaded data as a request line. +* +* @returns boolean +* true iff the request line has been fully processed +*/ + _processRequestLine: function() + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + // Servers SHOULD ignore any empty line(s) received where a Request-Line + // is expected (section 4.1). + var data = this._data; + var line = {}; + var readSuccess; + while ((readSuccess = data.readLine(line)) && line.value == "") + dumpn("*** ignoring beginning blank line..."); + + // if we don't have a full line, wait until we do + if (!readSuccess) + return false; + + // we have the first non-blank line + try + { + this._parseRequestLine(line.value); + this._state = READER_IN_HEADERS; + return true; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing request headers. +* +* @returns boolean +* true iff header data in the request has been fully processed +*/ + _processHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + // XXX things to fix here: + // + // - need to support RFC 2047-encoded non-US-ASCII characters + + try + { + var done = this._parseHeaders(); + if (done) + { + var request = this._metadata; + + // XXX this is wrong for requests with transfer-encodings applied to + // them, particularly chunked (which by its nature can have no + // meaningful Content-Length header)! + this._contentLength = request.hasHeader("Content-Length") + ? parseInt(request.getHeader("Content-Length"), 10) + : 0; + dumpn("_processHeaders, Content-length=" + this._contentLength); + + this._state = READER_IN_BODY; + } + return done; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing the request body. +* +* @returns boolean +* true iff the request body has been fully processed +*/ + _processBody: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + // XXX handle chunked transfer-coding request bodies! + + try + { + if (this._contentLength > 0) + { + var data = this._data.purge(); + var count = Math.min(data.length, this._contentLength); + dumpn("*** loading data=" + data + " len=" + data.length + + " excess=" + (data.length - count)); + + var bos = new BinaryOutputStream(this._metadata._bodyOutputStream); + bos.writeByteArray(data, count); + this._contentLength -= count; + } + + dumpn("*** remaining body data len=" + this._contentLength); + if (this._contentLength == 0) + { + this._validateRequest(); + this._state = READER_FINISHED; + this._handleResponse(); + return true; + } + + return false; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Does various post-header checks on the data in this request. +* +* @throws : HttpError +* if the request was malformed in some way +*/ + _validateRequest: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + dumpn("*** _validateRequest"); + + var metadata = this._metadata; + var headers = metadata._headers; + + // 19.6.1.1 -- servers MUST report 400 to HTTP/1.1 requests w/o Host header + var identity = this._connection.server.identity; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + { + if (!headers.hasHeader("Host")) + { + dumpn("*** malformed HTTP/1.1 or greater request with no Host header!"); + throw HTTP_400; + } + + // If the Request-URI wasn't absolute, then we need to determine our host. + // We have to determine what scheme was used to access us based on the + // server identity data at this point, because the request just doesn't + // contain enough data on its own to do this, sadly. + if (!metadata._host) + { + var host, port; + var hostPort = headers.getHeader("Host"); + var colon = hostPort.indexOf(":"); + if (colon < 0) + { + host = hostPort; + port = ""; + } + else + { + host = hostPort.substring(0, colon); + port = hostPort.substring(colon + 1); + } + + // NB: We allow an empty port here because, oddly, a colon may be + // present even without a port number, e.g. "example.com:"; in this + // case the default port applies. + if (!HOST_REGEX.test(host) || !/^\d*$/.test(port)) + { + dumpn("*** malformed hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + // If we're not given a port, we're stuck, because we don't know what + // scheme to use to look up the correct port here, in general. Since + // the HTTPS case requires a tunnel/proxy and thus requires that the + // requested URI be absolute (and thus contain the necessary + // information), let's assume HTTP will prevail and use that. + port = +port || 80; + + var scheme = identity.getScheme(host, port); + if (!scheme) + { + dumpn("*** unrecognized hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + } + } + else + { + NS_ASSERT(metadata._host === undefined, + "HTTP/1.0 doesn't allow absolute paths in the request line!"); + + metadata._scheme = identity.primaryScheme; + metadata._host = identity.primaryHost; + metadata._port = identity.primaryPort; + } + + NS_ASSERT(identity.has(metadata._scheme, metadata._host, metadata._port), + "must have a location we recognize by now!"); + }, + + /** +* Handles responses in case of error, either in the server or in the request. +* +* @param e +* the specific error encountered, which is an HttpError in the case where +* the request is in some way invalid or cannot be fulfilled; if this isn't +* an HttpError we're going to be paranoid and shut down, because that +* shouldn't happen, ever +*/ + _handleError: function(e) + { + // Don't fall back into normal processing! + this._state = READER_FINISHED; + + var server = this._connection.server; + if (e instanceof HttpError) + { + var code = e.code; + } + else + { + dumpn("!!! UNEXPECTED ERROR: " + e + + (e.lineNumber ? ", line " + e.lineNumber : "")); + + // no idea what happened -- be paranoid and shut down + code = 500; + server._requestQuit(); + } + + // make attempted reuse of data an error + this._data = null; + + this._connection.processError(code, this._metadata); + }, + + /** +* Now that we've read the request line and headers, we can actually hand off +* the request to be handled. +* +* This method is called once per request, after the request line and all +* headers and the body, if any, have been received. +*/ + _handleResponse: function() + { + NS_ASSERT(this._state == READER_FINISHED); + + // We don't need the line-based data any more, so make attempted reuse an + // error. + this._data = null; + + this._connection.process(this._metadata); + }, + + + // PARSING + + /** +* Parses the request line for the HTTP request associated with this. +* +* @param line : string +* the request line +*/ + _parseRequestLine: function(line) + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + dumpn("*** _parseRequestLine('" + line + "')"); + + var metadata = this._metadata; + + // clients and servers SHOULD accept any amount of SP or HT characters + // between fields, even though only a single SP is required (section 19.3) + var request = line.split(/[ \t]+/); + if (!request || request.length != 3) + throw HTTP_400; + + metadata._method = request[0]; + + // get the HTTP version + var ver = request[2]; + var match = ver.match(/^HTTP\/(\d+\.\d+)$/); + if (!match) + throw HTTP_400; + + // determine HTTP version + try + { + metadata._httpVersion = new nsHttpVersion(match[1]); + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_0)) + throw "unsupported HTTP version"; + } + catch (e) + { + // we support HTTP/1.0 and HTTP/1.1 only + throw HTTP_501; + } + + + var fullPath = request[1]; + var serverIdentity = this._connection.server.identity; + + var scheme, host, port; + + if (fullPath.charAt(0) != "/") + { + // No absolute paths in the request line in HTTP prior to 1.1 + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + throw HTTP_400; + + try + { + var uri = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService) + .newURI(fullPath, null, null); + fullPath = uri.path; + scheme = uri.scheme; + host = metadata._host = uri.asciiHost; + port = uri.port; + if (port === -1) + { + if (scheme === "http") + port = 80; + else if (scheme === "https") + port = 443; + else + throw HTTP_400; + } + } + catch (e) + { + // If the host is not a valid host on the server, the response MUST be a + // 400 (Bad Request) error message (section 5.2). Alternately, the URI + // is malformed. + throw HTTP_400; + } + + if (!serverIdentity.has(scheme, host, port) || fullPath.charAt(0) != "/") + throw HTTP_400; + } + + var splitter = fullPath.indexOf("?"); + if (splitter < 0) + { + // _queryString already set in ctor + metadata._path = fullPath; + } + else + { + metadata._path = fullPath.substring(0, splitter); + metadata._queryString = fullPath.substring(splitter + 1); + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + }, + + /** +* Parses all available HTTP headers in this until the header-ending CRLFCRLF, +* adding them to the store of headers in the request. +* +* @throws +* HTTP_400 if the headers are malformed +* @returns boolean +* true if all headers have now been processed, false otherwise +*/ + _parseHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + dumpn("*** _parseHeaders"); + + var data = this._data; + + var headers = this._metadata._headers; + var lastName = this._lastHeaderName; + var lastVal = this._lastHeaderValue; + + var line = {}; + while (true) + { + NS_ASSERT(!((lastVal === undefined) ^ (lastName === undefined)), + lastName === undefined ? + "lastVal without lastName? lastVal: '" + lastVal + "'" : + "lastName without lastVal? lastName: '" + lastName + "'"); + + if (!data.readLine(line)) + { + // save any data we have from the header we might still be processing + this._lastHeaderName = lastName; + this._lastHeaderValue = lastVal; + return false; + } + + var lineText = line.value; + var firstChar = lineText.charAt(0); + + // blank line means end of headers + if (lineText == "") + { + // we're finished with the previous header + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + else + { + // no headers in request -- valid for HTTP/1.0 requests + } + + // either way, we're done processing headers + this._state = READER_IN_BODY; + return true; + } + else if (firstChar == " " || firstChar == "\t") + { + // multi-line header if we've already seen a header line + if (!lastName) + { + // we don't have a header to continue! + throw HTTP_400; + } + + // append this line's text to the value; starts with SP/HT, so no need + // for separating whitespace + lastVal += lineText; + } + else + { + // we have a new header, so set the old one (if one existed) + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + + var colon = lineText.indexOf(":"); // first colon must be splitter + if (colon < 1) + { + // no colon or missing header field-name + throw HTTP_400; + } + + // set header name, value (to be set in the next loop, usually) + lastName = lineText.substring(0, colon); + lastVal = lineText.substring(colon + 1); + } // empty, continuation, start of header + } // while (true) + } +}; + + +/** The character codes for CR and LF. */ +const CR = 0x0D, LF = 0x0A; + +/** +* Calculates the number of characters before the first CRLF pair in array, or +* -1 if the array contains no CRLF pair. +* +* @param array : Array +* an array of numbers in the range [0, 256), each representing a single +* character; the first CRLF is the lowest index i where +* |array[i] == "\r".charCodeAt(0)| and |array[i+1] == "\n".charCodeAt(0)|, +* if such an |i| exists, and -1 otherwise +* @returns int +* the index of the first CRLF if any were present, -1 otherwise +*/ +function findCRLF(array) +{ + for (var i = array.indexOf(CR); i >= 0; i = array.indexOf(CR, i + 1)) + { + if (array[i + 1] == LF) + return i; + } + return -1; +} + + +/** +* A container which provides line-by-line access to the arrays of bytes with +* which it is seeded. +*/ +function LineData() +{ + /** An array of queued bytes from which to get line-based characters. */ + this._data = []; +} +LineData.prototype = +{ + /** +* Appends the bytes in the given array to the internal data cache maintained +* by this. +*/ + appendBytes: function(bytes) + { + Array.prototype.push.apply(this._data, bytes); + }, + + /** +* Removes and returns a line of data, delimited by CRLF, from this. +* +* @param out +* an object whose "value" property will be set to the first line of text +* present in this, sans CRLF, if this contains a full CRLF-delimited line +* of text; if this doesn't contain enough data, the value of the property +* is undefined +* @returns boolean +* true if a full line of data could be read from the data in this, false +* otherwise +*/ + readLine: function(out) + { + var data = this._data; + var length = findCRLF(data); + if (length < 0) + return false; + + // + // We have the index of the CR, so remove all the characters, including + // CRLF, from the array with splice, and convert the removed array into the + // corresponding string, from which we then strip the trailing CRLF. + // + // Getting the line in this matter acknowledges that substring is an O(1) + // operation in SpiderMonkey because strings are immutable, whereas two + // splices, both from the beginning of the data, are less likely to be as + // cheap as a single splice plus two extra character conversions. + // + var line = String.fromCharCode.apply(null, data.splice(0, length + 2)); + out.value = line.substring(0, length); + + return true; + }, + + /** +* Removes the bytes currently within this and returns them in an array. +* +* @returns Array +* the bytes within this when this method is called +*/ + purge: function() + { + var data = this._data; + this._data = []; + return data; + } +}; + + + +/** +* Creates a request-handling function for an nsIHttpRequestHandler object. +*/ +function createHandlerFunc(handler) +{ + return function(metadata, response) { handler.handle(metadata, response); }; +} + + +/** +* The default handler for directories; writes an HTML response containing a +* slightly-formatted directory listing. +*/ +function defaultIndexHandler(metadata, response) +{ + response.setHeader("Content-Type", "text/html", false); + + var path = htmlEscape(decodeURI(metadata.path)); + + // + // Just do a very basic bit of directory listings -- no need for too much + // fanciness, especially since we don't have a style sheet in which we can + // stick rules (don't want to pollute the default path-space). + // + + var body = '<html>\ +<head>\ +<title>' + path + '</title>\ +</head>\ +<body>\ +<h1>' + path + '</h1>\ +<ol style="list-style-type: none">'; + + var directory = metadata.getProperty("directory").QueryInterface(Ci.nsILocalFile); + NS_ASSERT(directory && directory.isDirectory()); + + var fileList = []; + var files = directory.directoryEntries; + while (files.hasMoreElements()) + { + var f = files.getNext().QueryInterface(Ci.nsIFile); + var name = f.leafName; + if (!f.isHidden() && + (name.charAt(name.length - 1) != HIDDEN_CHAR || + name.charAt(name.length - 2) == HIDDEN_CHAR)) + fileList.push(f); + } + + fileList.sort(fileSort); + + for (var i = 0; i < fileList.length; i++) + { + var file = fileList[i]; + try + { + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + var sep = file.isDirectory() ? "/" : ""; + + // Note: using " to delimit the attribute here because encodeURIComponent + // passes through '. + var item = '<li><a href="' + encodeURIComponent(name) + sep + '">' + + htmlEscape(name) + sep + + '</a></li>'; + + body += item; + } + catch (e) { /* some file system error, ignore the file */ } + } + + body += ' </ol>\ +</body>\ +</html>'; + + response.bodyOutputStream.write(body, body.length); +} + +/** +* Sorts a and b (nsIFile objects) into an aesthetically pleasing order. +*/ +function fileSort(a, b) +{ + var dira = a.isDirectory(), dirb = b.isDirectory(); + + if (dira && !dirb) + return -1; + if (dirb && !dira) + return 1; + + var namea = a.leafName.toLowerCase(), nameb = b.leafName.toLowerCase(); + return nameb > namea ? -1 : 1; +} + + +/** +* Converts an externally-provided path into an internal path for use in +* determining file mappings. +* +* @param path +* the path to convert +* @param encoded +* true if the given path should be passed through decodeURI prior to +* conversion +* @throws URIError +* if path is incorrectly encoded +*/ +function toInternalPath(path, encoded) +{ + if (encoded) + path = decodeURI(path); + + var comps = path.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + if (comp.charAt(comp.length - 1) == HIDDEN_CHAR) + comps[i] = comp + HIDDEN_CHAR; + } + return comps.join("/"); +} + + +/** +* Adds custom-specified headers for the given file to the given response, if +* any such headers are specified. +* +* @param file +* the file on the disk which is to be written +* @param metadata +* metadata about the incoming request +* @param response +* the Response to which any specified headers/data should be written +* @throws HTTP_500 +* if an error occurred while processing custom-specified headers +*/ +function maybeAddHeaders(file, metadata, response) +{ + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + + var headerFile = file.parent; + headerFile.append(name + HEADERS_SUFFIX); + + if (!headerFile.exists()) + return; + + const PR_RDONLY = 0x01; + var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0); + lis.QueryInterface(Ci.nsIUnicharLineInputStream); + + var line = {value: ""}; + var more = lis.readLine(line); + + if (!more && line.value == "") + return; + + + // request line + + var status = line.value; + if (status.indexOf("HTTP ") == 0) + { + status = status.substring(5); + var space = status.indexOf(" "); + var code, description; + if (space < 0) + { + code = status; + description = ""; + } + else + { + code = status.substring(0, space); + description = status.substring(space + 1, status.length); + } + + response.setStatusLine(metadata.httpVersion, parseInt(code, 10), description); + + line.value = ""; + more = lis.readLine(line); + } + + // headers + while (more || line.value != "") + { + var header = line.value; + var colon = header.indexOf(":"); + + response.setHeader(header.substring(0, colon), + header.substring(colon + 1, header.length), + false); // allow overriding server-set headers + + line.value = ""; + more = lis.readLine(line); + } + } + catch (e) + { + dumpn("WARNING: error in headers for " + metadata.path + ": " + e); + throw HTTP_500; + } + finally + { + fis.close(); + } +} + + +/** +* An object which handles requests for a server, executing default and +* overridden behaviors as instructed by the code which uses and manipulates it. +* Default behavior includes the paths / and /trace (diagnostics), with some +* support for HTTP error pages for various codes and fallback to HTTP 500 if +* those codes fail for any reason. +* +* @param server : nsHttpServer +* the server in which this handler is being used +*/ +function ServerHandler(server) +{ + // FIELDS + + /** +* The nsHttpServer instance associated with this handler. +*/ + this._server = server; + + /** +* A FileMap object containing the set of path->nsILocalFile mappings for +* all directory mappings set in the server (e.g., "/" for /var/www/html/, +* "/foo/bar/" for /local/path/, and "/foo/bar/baz/" for /local/path2). +* +* Note carefully: the leading and trailing "/" in each path (not file) are +* removed before insertion to simplify the code which uses this. You have +* been warned! +*/ + this._pathDirectoryMap = new FileMap(); + + /** +* Custom request handlers for the server in which this resides. Path-handler +* pairs are stored as property-value pairs in this property. +* +* @see ServerHandler.prototype._defaultPaths +*/ + this._overridePaths = {}; + + /** +* Custom request handlers for the server in which this resides. Prefix-handler +* pairs are stored as property-value pairs in this property. +*/ + this._overridePrefixes = {}; + + /** +* Custom request handlers for the error handlers in the server in which this +* resides. Path-handler pairs are stored as property-value pairs in this +* property. +* +* @see ServerHandler.prototype._defaultErrors +*/ + this._overrideErrors = {}; + + /** +* Maps file extensions to their MIME types in the server, overriding any +* mapping that might or might not exist in the MIME service. +*/ + this._mimeMappings = {}; + + /** +* The default handler for requests for directories, used to serve directories +* when no index file is present. +*/ + this._indexHandler = defaultIndexHandler; + + /** Per-path state storage for the server. */ + this._state = {}; + + /** Entire-server state storage. */ + this._sharedState = {}; + + /** Entire-server state storage for nsISupports values. */ + this._objectState = {}; +} +ServerHandler.prototype = +{ + // PUBLIC API + + /** +* Handles a request to this server, responding to the request appropriately +* and initiating server shutdown if necessary. +* +* This method never throws an exception. +* +* @param connection : Connection +* the connection for this request +*/ + handleResponse: function(connection) + { + var request = connection.request; + var response = new Response(connection); + + var path = request.path; + dumpn("*** path == " + path); + + try + { + try + { + if (path in this._overridePaths) + { + // explicit paths first, then files based on existing directory mappings, + // then (if the file doesn't exist) built-in server default paths + dumpn("calling override for " + path); + this._overridePaths[path](request, response); + } + else + { + let longestPrefix = ""; + for (let prefix in this._overridePrefixes) + { + if (prefix.length > longestPrefix.length && path.startsWith(prefix)) + { + longestPrefix = prefix; + } + } + if (longestPrefix.length > 0) + { + dumpn("calling prefix override for " + longestPrefix); + this._overridePrefixes[longestPrefix](request, response); + } + else + { + this._handleDefault(request, response); + } + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + if (!(e instanceof HttpError)) + { + dumpn("*** unexpected error: e == " + e); + throw HTTP_500; + } + if (e.code !== 404) + throw e; + + dumpn("*** default: " + (path in this._defaultPaths)); + + response = new Response(connection); + if (path in this._defaultPaths) + this._defaultPaths[path](request, response); + else + throw HTTP_404; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + var errorCode = "internal"; + + try + { + if (!(e instanceof HttpError)) + throw e; + + errorCode = e.code; + dumpn("*** errorCode == " + errorCode); + + response = new Response(connection); + if (e.customErrorHandling) + e.customErrorHandling(response); + this._handleError(errorCode, request, response); + return; + } + catch (e2) + { + dumpn("*** error handling " + errorCode + " error: " + + "e2 == " + e2 + ", shutting down server"); + + connection.server._requestQuit(); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (!file) + { + dumpn("*** unregistering '" + path + "' mapping"); + delete this._overridePaths[path]; + return; + } + + dumpn("*** registering '" + path + "' as mapping to " + file.path); + file = file.clone(); + + var self = this; + this._overridePaths[path] = + function(request, response) + { + if (!file.exists()) + throw HTTP_404; + + response.setStatusLine(request.httpVersion, 200, "OK"); + self._writeFileResponse(request, file, response, 0, file.fileSize); + }; + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + // XXX true path validation! + if (path.charAt(0) != "/") + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePaths, path); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + // XXX true prefix validation! + if (!(prefix.startsWith("/") && prefix.endsWith("/"))) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePrefixes, prefix); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // strip off leading and trailing '/' so that we can use lastIndexOf when + // determining exactly how a path maps onto a mapped directory -- + // conditional is required here to deal with "/".substring(1, 0) being + // converted to "/".substring(0, 1) per the JS specification + var key = path.length == 1 ? "" : path.substring(1, path.length - 1); + + // the path-to-directory mapping code requires that the first character not + // be "/", or it will go into an infinite loop + if (key.charAt(0) == "/") + throw Cr.NS_ERROR_INVALID_ARG; + + key = toInternalPath(key, false); + + if (directory) + { + dumpn("*** mapping '" + path + "' to the location " + directory.path); + this._pathDirectoryMap.put(key, directory); + } + else + { + dumpn("*** removing mapping for '" + path + "'"); + this._pathDirectoryMap.put(key, null); + } + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(err, handler) + { + if (!(err in HTTP_ERROR_CODES)) + dumpn("*** WARNING: registering non-HTTP/1.1 error code " + + "(" + err + ") handler -- was this intentional?"); + + this._handlerToField(handler, this._overrideErrors, err); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + if (!handler) + handler = defaultIndexHandler; + else if (typeof(handler) != "function") + handler = createHandlerFunc(handler); + + this._indexHandler = handler; + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + if (!type) + delete this._mimeMappings[ext]; + else + this._mimeMappings[ext] = headerUtils.normalizeFieldValue(type); + }, + + // PRIVATE API + + /** +* Sets or remove (if handler is null) a handler in an object with a key. +* +* @param handler +* a handler, either function or an nsIHttpRequestHandler +* @param dict +* The object to attach the handler to. +* @param key +* The field name of the handler. +*/ + _handlerToField: function(handler, dict, key) + { + // for convenience, handler can be a function if this is run from xpcshell + if (typeof(handler) == "function") + dict[key] = handler; + else if (handler) + dict[key] = createHandlerFunc(handler); + else + delete dict[key]; + }, + + /** +* Handles a request which maps to a file in the local filesystem (if a base +* path has already been set; otherwise the 404 error is thrown). +* +* @param metadata : Request +* metadata for the incoming request +* @param response : Response +* an uninitialized Response to the given request, to be initialized by a +* request handler +* @throws HTTP_### +* if an HTTP error occurred (usually HTTP_404); note that in this case the +* calling code must handle post-processing of the response +*/ + _handleDefault: function(metadata, response) + { + dumpn("*** _handleDefault()"); + + response.setStatusLine(metadata.httpVersion, 200, "OK"); + + var path = metadata.path; + NS_ASSERT(path.charAt(0) == "/", "invalid path: <" + path + ">"); + + // determine the actual on-disk file; this requires finding the deepest + // path-to-directory mapping in the requested URL + var file = this._getFileForPath(path); + + // the "file" might be a directory, in which case we either serve the + // contained index.html or make the index handler write the response + if (file.exists() && file.isDirectory()) + { + file.append("index.html"); // make configurable? + if (!file.exists() || file.isDirectory()) + { + metadata._ensurePropertyBag(); + metadata._bag.setPropertyAsInterface("directory", file.parent); + this._indexHandler(metadata, response); + return; + } + } + + // alternately, the file might not exist + if (!file.exists()) + throw HTTP_404; + + var start, end; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1) && + metadata.hasHeader("Range") && + this._getTypeFromFile(file) !== SJS_TYPE) + { + var rangeMatch = metadata.getHeader("Range").match(/^bytes=(\d+)?-(\d+)?$/); + if (!rangeMatch) + throw HTTP_400; + + if (rangeMatch[1] !== undefined) + start = parseInt(rangeMatch[1], 10); + + if (rangeMatch[2] !== undefined) + end = parseInt(rangeMatch[2], 10); + + if (start === undefined && end === undefined) + throw HTTP_400; + + // No start given, so the end is really the count of bytes from the + // end of the file. + if (start === undefined) + { + start = Math.max(0, file.fileSize - end); + end = file.fileSize - 1; + } + + // start and end are inclusive + if (end === undefined || end >= file.fileSize) + end = file.fileSize - 1; + + if (start !== undefined && start >= file.fileSize) { + var HTTP_416 = new HttpError(416, "Requested Range Not Satisfiable"); + HTTP_416.customErrorHandling = function(errorResponse) + { + maybeAddHeaders(file, metadata, errorResponse); + }; + throw HTTP_416; + } + + if (end < start) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + start = 0; + end = file.fileSize - 1; + } + else + { + response.setStatusLine(metadata.httpVersion, 206, "Partial Content"); + var contentRange = "bytes " + start + "-" + end + "/" + file.fileSize; + response.setHeader("Content-Range", contentRange); + } + } + else + { + start = 0; + end = file.fileSize - 1; + } + + // finally... + dumpn("*** handling '" + path + "' as mapping to " + file.path + " from " + + start + " to " + end + " inclusive"); + this._writeFileResponse(metadata, file, response, start, end - start + 1); + }, + + /** +* Writes an HTTP response for the given file, including setting headers for +* file metadata. +* +* @param metadata : Request +* the Request for which a response is being generated +* @param file : nsILocalFile +* the file which is to be sent in the response +* @param response : Response +* the response to which the file should be written +* @param offset: uint +* the byte offset to skip to when writing +* @param count: uint +* the number of bytes to write +*/ + _writeFileResponse: function(metadata, file, response, offset, count) + { + const PR_RDONLY = 0x01; + + var type = this._getTypeFromFile(file); + if (type === SJS_TYPE) + { + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var sis = new ScriptableInputStream(fis); + var s = Cu.Sandbox(gGlobalObject); + s.importFunction(dump, "dump"); + + // Define a basic key-value state-preservation API across requests, with + // keys initially corresponding to the empty string. + var self = this; + var path = metadata.path; + s.importFunction(function getState(k) + { + return self._getState(path, k); + }); + s.importFunction(function setState(k, v) + { + self._setState(path, k, v); + }); + s.importFunction(function getSharedState(k) + { + return self._getSharedState(k); + }); + s.importFunction(function setSharedState(k, v) + { + self._setSharedState(k, v); + }); + s.importFunction(function getObjectState(k, callback) + { + callback(self._getObjectState(k)); + }); + s.importFunction(function setObjectState(k, v) + { + self._setObjectState(k, v); + }); + s.importFunction(function registerPathHandler(p, h) + { + self.registerPathHandler(p, h); + }); + + // Make it possible for sjs files to access their location + this._setState(path, "__LOCATION__", file.path); + + try + { + // Alas, the line number in errors dumped to console when calling the + // request handler is simply an offset from where we load the SJS file. + // Work around this in a reasonably non-fragile way by dynamically + // getting the line number where we evaluate the SJS file. Don't + // separate these two lines! + var line = new Error().lineNumber; + Cu.evalInSandbox(sis.read(file.fileSize), s); + } + catch (e) + { + dumpn("*** syntax error in SJS at " + file.path + ": " + e); + throw HTTP_500; + } + + try + { + s.handleRequest(metadata, response); + } + catch (e) + { + dump("*** error running SJS at " + file.path + ": " + + e + " on line " + + (e instanceof Error + ? e.lineNumber + " in httpd.js" + : (e.lineNumber - line)) + "\n"); + throw HTTP_500; + } + } + finally + { + fis.close(); + } + } + else + { + try + { + response.setHeader("Last-Modified", + toDateString(file.lastModifiedTime), + false); + } + catch (e) { /* lastModifiedTime threw, ignore */ } + + response.setHeader("Content-Type", type, false); + maybeAddHeaders(file, metadata, response); + response.setHeader("Content-Length", "" + count, false); + + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + offset = offset || 0; + count = count || file.fileSize; + NS_ASSERT(offset === 0 || offset < file.fileSize, "bad offset"); + NS_ASSERT(count >= 0, "bad count"); + NS_ASSERT(offset + count <= file.fileSize, "bad total data size"); + + try + { + if (offset !== 0) + { + // Seek (or read, if seeking isn't supported) to the correct offset so + // the data sent to the client matches the requested range. + if (fis instanceof Ci.nsISeekableStream) + fis.seek(Ci.nsISeekableStream.NS_SEEK_SET, offset); + else + new ScriptableInputStream(fis).read(offset); + } + } + catch (e) + { + fis.close(); + throw e; + } + + let writeMore = function writeMore() + { + gThreadManager.currentThread + .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL); + } + + var input = new BinaryInputStream(fis); + var output = new BinaryOutputStream(response.bodyOutputStream); + var writeData = + { + run: function() + { + var chunkSize = Math.min(65536, count); + count -= chunkSize; + NS_ASSERT(count >= 0, "underflow"); + + try + { + var data = input.readByteArray(chunkSize); + NS_ASSERT(data.length === chunkSize, + "incorrect data returned? got " + data.length + + ", expected " + chunkSize); + output.writeByteArray(data, data.length); + if (count === 0) + { + fis.close(); + response.finish(); + } + else + { + writeMore(); + } + } + catch (e) + { + try + { + fis.close(); + } + finally + { + response.finish(); + } + throw e; + } + } + }; + + writeMore(); + + // Now that we know copying will start, flag the response as async. + response.processAsync(); + } + }, + + /** +* Get the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getState: function(path, k) + { + var state = this._state; + if (path in state && k in state[path]) + return state[path][k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setState: function(path, k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + var state = this._state; + if (!(path in state)) + state[path] = {}; + state[path][k] = v; + }, + + /** +* Get the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getSharedState: function(k) + { + var state = this._sharedState; + if (k in state) + return state[k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setSharedState: function(k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + this._sharedState[k] = v; + }, + + /** +* Returns the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be returned +* @returns nsISupports +* the corresponding object, or null if none was present +*/ + _getObjectState: function(k) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + return this._objectState[k] || null; + }, + + /** +* Sets the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be set +* @param v : nsISupports +* the object to be associated with the given key; may be null +*/ + _setObjectState: function(k, v) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + if (typeof v !== "object") + throw new Error("non-object value passed"); + if (v && !("QueryInterface" in v)) + { + throw new Error("must pass an nsISupports; use wrappedJSObject to ease " + + "pain when using the server from JS"); + } + + this._objectState[k] = v; + }, + + /** +* Gets a content-type for the given file, first by checking for any custom +* MIME-types registered with this handler for the file's extension, second by +* asking the global MIME service for a content-type, and finally by failing +* over to application/octet-stream. +* +* @param file : nsIFile +* the nsIFile for which to get a file type +* @returns string +* the best content-type which can be determined for the file +*/ + _getTypeFromFile: function(file) + { + try + { + var name = file.leafName; + var dot = name.lastIndexOf("."); + if (dot > 0) + { + var ext = name.slice(dot + 1); + if (ext in this._mimeMappings) + return this._mimeMappings[ext]; + } + return Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(file); + } + catch (e) + { + return "application/octet-stream"; + } + }, + + /** +* Returns the nsILocalFile which corresponds to the path, as determined using +* all registered path->directory mappings and any paths which are explicitly +* overridden. +* +* @param path : string +* the server path for which a file should be retrieved, e.g. "/foo/bar" +* @throws HttpError +* when the correct action is the corresponding HTTP error (i.e., because no +* mapping was found for a directory in path, the referenced file doesn't +* exist, etc.) +* @returns nsILocalFile +* the file to be sent as the response to a request for the path +*/ + _getFileForPath: function(path) + { + // decode and add underscores as necessary + try + { + path = toInternalPath(path, true); + } + catch (e) + { + throw HTTP_400; // malformed path + } + + // next, get the directory which contains this path + var pathMap = this._pathDirectoryMap; + + // An example progression of tmp for a path "/foo/bar/baz/" might be: + // "foo/bar/baz/", "foo/bar/baz", "foo/bar", "foo", "" + var tmp = path.substring(1); + while (true) + { + // do we have a match for current head of the path? + var file = pathMap.get(tmp); + if (file) + { + // XXX hack; basically disable showing mapping for /foo/bar/ when the + // requested path was /foo/bar, because relative links on the page + // will all be incorrect -- we really need the ability to easily + // redirect here instead + if (tmp == path.substring(1) && + tmp.length != 0 && + tmp.charAt(tmp.length - 1) != "/") + file = null; + else + break; + } + + // if we've finished trying all prefixes, exit + if (tmp == "") + break; + + tmp = tmp.substring(0, tmp.lastIndexOf("/")); + } + + // no mapping applies, so 404 + if (!file) + throw HTTP_404; + + + // last, get the file for the path within the determined directory + var parentFolder = file.parent; + var dirIsRoot = (parentFolder == null); + + // Strategy here is to append components individually, making sure we + // never move above the given directory; this allows paths such as + // "<file>/foo/../bar" but prevents paths such as "<file>/../base-sibling"; + // this component-wise approach also means the code works even on platforms + // which don't use "/" as the directory separator, such as Windows + var leafPath = path.substring(tmp.length + 1); + var comps = leafPath.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + + if (comp == "..") + file = file.parent; + else if (comp == "." || comp == "") + continue; + else + file.append(comp); + + if (!dirIsRoot && file.equals(parentFolder)) + throw HTTP_403; + } + + return file; + }, + + /** +* Writes the error page for the given HTTP error code over the given +* connection. +* +* @param errorCode : uint +* the HTTP error code to be used +* @param connection : Connection +* the connection on which the error occurred +*/ + handleError: function(errorCode, connection) + { + var response = new Response(connection); + + dumpn("*** error in request: " + errorCode); + + this._handleError(errorCode, new Request(connection.port), response); + }, + + /** +* Handles a request which generates the given error code, using the +* user-defined error handler if one has been set, gracefully falling back to +* the x00 status code if the code has no handler, and failing to status code +* 500 if all else fails. +* +* @param errorCode : uint +* the HTTP error which is to be returned +* @param metadata : Request +* metadata for the request, which will often be incomplete since this is an +* error +* @param response : Response +* an uninitialized Response should be initialized when this method +* completes with information which represents the desired error code in the +* ideal case or a fallback code in abnormal circumstances (i.e., 500 is a +* fallback for 505, per HTTP specs) +*/ + _handleError: function(errorCode, metadata, response) + { + if (!metadata) + throw Cr.NS_ERROR_NULL_POINTER; + + var errorX00 = errorCode - (errorCode % 100); + + try + { + if (!(errorCode in HTTP_ERROR_CODES)) + dumpn("*** WARNING: requested invalid error: " + errorCode); + + // RFC 2616 says that we should try to handle an error by its class if we + // can't otherwise handle it -- if that fails, we revert to handling it as + // a 500 internal server error, and if that fails we throw and shut down + // the server + + // actually handle the error + try + { + if (errorCode in this._overrideErrors) + this._overrideErrors[errorCode](metadata, response); + else + this._defaultErrors[errorCode](metadata, response); + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + // don't retry the handler that threw + if (errorX00 == errorCode) + throw HTTP_500; + + dumpn("*** error in handling for error code " + errorCode + ", " + + "falling back to " + errorX00 + "..."); + response = new Response(response._connection); + if (errorX00 in this._overrideErrors) + this._overrideErrors[errorX00](metadata, response); + else if (errorX00 in this._defaultErrors) + this._defaultErrors[errorX00](metadata, response); + else + throw HTTP_500; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(); + return; + } + + // we've tried everything possible for a meaningful error -- now try 500 + dumpn("*** error in handling for error code " + errorX00 + ", falling " + + "back to 500..."); + + try + { + response = new Response(response._connection); + if (500 in this._overrideErrors) + this._overrideErrors[500](metadata, response); + else + this._defaultErrors[500](metadata, response); + } + catch (e2) + { + dumpn("*** multiple errors in default error handlers!"); + dumpn("*** e == " + e + ", e2 == " + e2); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // FIELDS + + /** +* This object contains the default handlers for the various HTTP error codes. +*/ + _defaultErrors: + { + 400: function(metadata, response) + { + // none of the data in metadata is reliable, so hard-code everything here + response.setStatusLine("1.1", 400, "Bad Request"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Bad request\n"; + response.bodyOutputStream.write(body, body.length); + }, + 403: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 403, "Forbidden"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>403 Forbidden</title></head>\ +<body>\ +<h1>403 Forbidden</h1>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 404: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>404 Not Found</title></head>\ +<body>\ +<h1>404 Not Found</h1>\ +<p>\ +<span style='font-family: monospace;'>" + + htmlEscape(metadata.path) + + "</span> was not found.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 416: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 416, + "Requested Range Not Satisfiable"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head>\ +<title>416 Requested Range Not Satisfiable</title></head>\ +<body>\ +<h1>416 Requested Range Not Satisfiable</h1>\ +<p>The byte range was not valid for the\ +requested resource.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 500: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 500, + "Internal Server Error"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>500 Internal Server Error</title></head>\ +<body>\ +<h1>500 Internal Server Error</h1>\ +<p>Something's broken in this server and\ +needs to be fixed.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 501: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 501, "Not Implemented"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>501 Not Implemented</title></head>\ +<body>\ +<h1>501 Not Implemented</h1>\ +<p>This server is not (yet) Apache.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 505: function(metadata, response) + { + response.setStatusLine("1.1", 505, "HTTP Version Not Supported"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>505 HTTP Version Not Supported</title></head>\ +<body>\ +<h1>505 HTTP Version Not Supported</h1>\ +<p>This server only supports HTTP/1.0 and HTTP/1.1\ +connections.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + } + }, + + /** +* Contains handlers for the default set of URIs contained in this server. +*/ + _defaultPaths: + { + "/": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>httpd.js</title></head>\ +<body>\ +<h1>httpd.js</h1>\ +<p>If you're seeing this page, httpd.js is up and\ +serving requests! Now set a base path and serve some\ +files!</p>\ +</body>\ +</html>"; + + response.bodyOutputStream.write(body, body.length); + }, + + "/trace": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Request-URI: " + + metadata.scheme + "://" + metadata.host + ":" + metadata.port + + metadata.path + "\n\n"; + body += "Request (semantically equivalent, slightly reformatted):\n\n"; + body += metadata.method + " " + metadata.path; + + if (metadata.queryString) + body += "?" + metadata.queryString; + + body += " HTTP/" + metadata.httpVersion + "\r\n"; + + var headEnum = metadata.headers; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + body += fieldName + ": " + metadata.getHeader(fieldName) + "\r\n"; + } + + response.bodyOutputStream.write(body, body.length); + } + } +}; + + +/** +* Maps absolute paths to files on the local file system (as nsILocalFiles). +*/ +function FileMap() +{ + /** Hash which will map paths to nsILocalFiles. */ + this._map = {}; +} +FileMap.prototype = +{ + // PUBLIC API + + /** +* Maps key to a clone of the nsILocalFile value if value is non-null; +* otherwise, removes any extant mapping for key. +* +* @param key : string +* string to which a clone of value is mapped +* @param value : nsILocalFile +* the file to map to key, or null to remove a mapping +*/ + put: function(key, value) + { + if (value) + this._map[key] = value.clone(); + else + delete this._map[key]; + }, + + /** +* Returns a clone of the nsILocalFile mapped to key, or null if no such +* mapping exists. +* +* @param key : string +* key to which the returned file maps +* @returns nsILocalFile +* a clone of the mapped file, or null if no mapping exists +*/ + get: function(key) + { + var val = this._map[key]; + return val ? val.clone() : null; + } +}; + + +// Response CONSTANTS + +// token = *<any CHAR except CTLs or separators> +// CHAR = <any US-ASCII character (0-127)> +// CTL = <any US-ASCII control character (0-31) and DEL (127)> +// separators = "(" | ")" | "<" | ">" | "@" +// | "," | ";" | ":" | "\" | <"> +// | "/" | "[" | "]" | "?" | "=" +// | "{" | "}" | SP | HT +const IS_TOKEN_ARRAY = + [0, 0, 0, 0, 0, 0, 0, 0, // 0 + 0, 0, 0, 0, 0, 0, 0, 0, // 8 + 0, 0, 0, 0, 0, 0, 0, 0, // 16 + 0, 0, 0, 0, 0, 0, 0, 0, // 24 + + 0, 1, 0, 1, 1, 1, 1, 1, // 32 + 0, 0, 1, 1, 0, 1, 1, 0, // 40 + 1, 1, 1, 1, 1, 1, 1, 1, // 48 + 1, 1, 0, 0, 0, 0, 0, 0, // 56 + + 0, 1, 1, 1, 1, 1, 1, 1, // 64 + 1, 1, 1, 1, 1, 1, 1, 1, // 72 + 1, 1, 1, 1, 1, 1, 1, 1, // 80 + 1, 1, 1, 0, 0, 0, 1, 1, // 88 + + 1, 1, 1, 1, 1, 1, 1, 1, // 96 + 1, 1, 1, 1, 1, 1, 1, 1, // 104 + 1, 1, 1, 1, 1, 1, 1, 1, // 112 + 1, 1, 1, 0, 1, 0, 1]; // 120 + + +/** +* Determines whether the given character code is a CTL. +* +* @param code : uint +* the character code +* @returns boolean +* true if code is a CTL, false otherwise +*/ +function isCTL(code) +{ + return (code >= 0 && code <= 31) || (code == 127); +} + +/** +* Represents a response to an HTTP request, encapsulating all details of that +* response. This includes all headers, the HTTP version, status code and +* explanation, and the entity itself. +* +* @param connection : Connection +* the connection over which this response is to be written +*/ +function Response(connection) +{ + /** The connection over which this response will be written. */ + this._connection = connection; + + /** +* The HTTP version of this response; defaults to 1.1 if not set by the +* handler. +*/ + this._httpVersion = nsHttpVersion.HTTP_1_1; + + /** +* The HTTP code of this response; defaults to 200. +*/ + this._httpCode = 200; + + /** +* The description of the HTTP code in this response; defaults to "OK". +*/ + this._httpDescription = "OK"; + + /** +* An nsIHttpHeaders object in which the headers in this response should be +* stored. This property is null after the status line and headers have been +* written to the network, and it may be modified up until it is cleared, +* except if this._finished is set first (in which case headers are written +* asynchronously in response to a finish() call not preceded by +* flushHeaders()). +*/ + this._headers = new nsHttpHeaders(); + + /** +* Set to true when this response is ended (completely constructed if possible +* and the connection closed); further actions on this will then fail. +*/ + this._ended = false; + + /** +* A stream used to hold data written to the body of this response. +*/ + this._bodyOutputStream = null; + + /** +* A stream containing all data that has been written to the body of this +* response so far. (Async handlers make the data contained in this +* unreliable as a way of determining content length in general, but auxiliary +* saved information can sometimes be used to guarantee reliability.) +*/ + this._bodyInputStream = null; + + /** +* A stream copier which copies data to the network. It is initially null +* until replaced with a copier for response headers; when headers have been +* fully sent it is replaced with a copier for the response body, remaining +* so for the duration of response processing. +*/ + this._asyncCopier = null; + + /** +* True if this response has been designated as being processed +* asynchronously rather than for the duration of a single call to +* nsIHttpRequestHandler.handle. +*/ + this._processAsync = false; + + /** +* True iff finish() has been called on this, signaling that no more changes +* to this may be made. +*/ + this._finished = false; + + /** +* True iff powerSeized() has been called on this, signaling that this +* response is to be handled manually by the response handler (which may then +* send arbitrary data in response, even non-HTTP responses). +*/ + this._powerSeized = false; +} +Response.prototype = +{ + // PUBLIC CONSTRUCTION API + + // + // see nsIHttpResponse.bodyOutputStream + // + get bodyOutputStream() + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + if (!this._bodyOutputStream) + { + var pipe = new Pipe(true, false, Response.SEGMENT_SIZE, PR_UINT32_MAX, + null); + this._bodyOutputStream = pipe.outputStream; + this._bodyInputStream = pipe.inputStream; + if (this._processAsync || this._powerSeized) + this._startAsyncProcessor(); + } + + return this._bodyOutputStream; + }, + + // + // see nsIHttpResponse.write + // + write: function(data) + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + var dataAsString = String(data); + this.bodyOutputStream.write(dataAsString, dataAsString.length); + }, + + // + // see nsIHttpResponse.setStatusLine + // + setStatusLine: function(httpVersion, code, description) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + if (!(code >= 0 && code < 1000)) + throw Cr.NS_ERROR_INVALID_ARG; + + try + { + var httpVer; + // avoid version construction for the most common cases + if (!httpVersion || httpVersion == "1.1") + httpVer = nsHttpVersion.HTTP_1_1; + else if (httpVersion == "1.0") + httpVer = nsHttpVersion.HTTP_1_0; + else + httpVer = new nsHttpVersion(httpVersion); + } + catch (e) + { + throw Cr.NS_ERROR_INVALID_ARG; + } + + // Reason-Phrase = *<TEXT, excluding CR, LF> + // TEXT = <any OCTET except CTLs, but including LWS> + // + // XXX this ends up disallowing octets which aren't Unicode, I think -- not + // much to do if description is IDL'd as string + if (!description) + description = ""; + for (var i = 0; i < description.length; i++) + if (isCTL(description.charCodeAt(i)) && description.charAt(i) != "\t") + throw Cr.NS_ERROR_INVALID_ARG; + + // set the values only after validation to preserve atomicity + this._httpDescription = description; + this._httpCode = code; + this._httpVersion = httpVer; + }, + + // + // see nsIHttpResponse.setHeader + // + setHeader: function(name, value, merge) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + this._headers.setHeader(name, value, merge); + }, + + // + // see nsIHttpResponse.processAsync + // + processAsync: function() + { + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._processAsync) + return; + this._ensureAlive(); + + dumpn("*** processing connection " + this._connection.number + " async"); + this._processAsync = true; + + /* +* Either the bodyOutputStream getter or this method is responsible for +* starting the asynchronous processor and catching writes of data to the +* response body of async responses as they happen, for the purpose of +* forwarding those writes to the actual connection's output stream. +* If bodyOutputStream is accessed first, calling this method will create +* the processor (when it first is clear that body data is to be written +* immediately, not buffered). If this method is called first, accessing +* bodyOutputStream will create the processor. If only this method is +* called, we'll write nothing, neither headers nor the nonexistent body, +* until finish() is called. Since that delay is easily avoided by simply +* getting bodyOutputStream or calling write(""), we don't worry about it. +*/ + if (this._bodyOutputStream && !this._asyncCopier) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.seizePower + // + seizePower: function() + { + if (this._processAsync) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + return; + this._ensureAlive(); + + dumpn("*** forcefully seizing power over connection " + + this._connection.number + "..."); + + // Purge any already-written data without sending it. We could as easily + // swap out the streams entirely, but that makes it possible to acquire and + // unknowingly use a stale reference, so we require there only be one of + // each stream ever for any response to avoid this complication. + if (this._asyncCopier) + this._asyncCopier.cancel(Cr.NS_BINDING_ABORTED); + this._asyncCopier = null; + if (this._bodyOutputStream) + { + var input = new BinaryInputStream(this._bodyInputStream); + var avail; + while ((avail = input.available()) > 0) + input.readByteArray(avail); + } + + this._powerSeized = true; + if (this._bodyOutputStream) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.finish + // + finish: function() + { + if (!this._processAsync && !this._powerSeized) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._finished) + return; + + dumpn("*** finishing connection " + this._connection.number); + this._startAsyncProcessor(); // in case bodyOutputStream was never accessed + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + this._finished = true; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpResponse) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // POST-CONSTRUCTION API (not exposed externally) + + /** +* The HTTP version number of this, as a string (e.g. "1.1"). +*/ + get httpVersion() + { + this._ensureAlive(); + return this._httpVersion.toString(); + }, + + /** +* The HTTP status code of this response, as a string of three characters per +* RFC 2616. +*/ + get httpCode() + { + this._ensureAlive(); + + var codeString = (this._httpCode < 10 ? "0" : "") + + (this._httpCode < 100 ? "0" : "") + + this._httpCode; + return codeString; + }, + + /** +* The description of the HTTP status code of this response, or "" if none is +* set. +*/ + get httpDescription() + { + this._ensureAlive(); + + return this._httpDescription; + }, + + /** +* The headers in this response, as an nsHttpHeaders object. +*/ + get headers() + { + this._ensureAlive(); + + return this._headers; + }, + + // + // see nsHttpHeaders.getHeader + // + getHeader: function(name) + { + this._ensureAlive(); + + return this._headers.getHeader(name); + }, + + /** +* Determines whether this response may be abandoned in favor of a newly +* constructed response. A response may be abandoned only if it is not being +* sent asynchronously and if raw control over it has not been taken from the +* server. +* +* @returns boolean +* true iff no data has been written to the network +*/ + partiallySent: function() + { + dumpn("*** partiallySent()"); + return this._processAsync || this._powerSeized; + }, + + /** +* If necessary, kicks off the remaining request processing needed to be done +* after a request handler performs its initial work upon this response. +*/ + complete: function() + { + dumpn("*** complete()"); + if (this._processAsync || this._powerSeized) + { + NS_ASSERT(this._processAsync ^ this._powerSeized, + "can't both send async and relinquish power"); + return; + } + + NS_ASSERT(!this.partiallySent(), "completing a partially-sent response?"); + + this._startAsyncProcessor(); + + // Now make sure we finish processing this request! + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + }, + + /** +* Abruptly ends processing of this response, usually due to an error in an +* incoming request but potentially due to a bad error handler. Since we +* cannot handle the error in the usual way (giving an HTTP error page in +* response) because data may already have been sent (or because the response +* might be expected to have been generated asynchronously or completely from +* scratch by the handler), we stop processing this response and abruptly +* close the connection. +* +* @param e : Error +* the exception which precipitated this abort, or null if no such exception +* was generated +*/ + abort: function(e) + { + dumpn("*** abort(<" + e + ">)"); + + // This response will be ended by the processor if one was created. + var copier = this._asyncCopier; + if (copier) + { + // We dispatch asynchronously here so that any pending writes of data to + // the connection will be deterministically written. This makes it easier + // to specify exact behavior, and it makes observable behavior more + // predictable for clients. Note that the correctness of this depends on + // callbacks in response to _waitToReadData in WriteThroughCopier + // happening asynchronously with respect to the actual writing of data to + // bodyOutputStream, as they currently do; if they happened synchronously, + // an event which ran before this one could write more data to the + // response body before we get around to canceling the copier. We have + // tests for this in test_seizepower.js, however, and I can't think of a + // way to handle both cases without removing bodyOutputStream access and + // moving its effective write(data, length) method onto Response, which + // would be slower and require more code than this anyway. + gThreadManager.currentThread.dispatch({ + run: function() + { + dumpn("*** canceling copy asynchronously..."); + copier.cancel(Cr.NS_ERROR_UNEXPECTED); + } + }, Ci.nsIThread.DISPATCH_NORMAL); + } + else + { + this.end(); + } + }, + + /** +* Closes this response's network connection, marks the response as finished, +* and notifies the server handler that the request is done being processed. +*/ + end: function() + { + NS_ASSERT(!this._ended, "ending this response twice?!?!"); + + this._connection.close(); + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + + this._finished = true; + this._ended = true; + }, + + // PRIVATE IMPLEMENTATION + + /** +* Sends the status line and headers of this response if they haven't been +* sent and initiates the process of copying data written to this response's +* body to the network. +*/ + _startAsyncProcessor: function() + { + dumpn("*** _startAsyncProcessor()"); + + // Handle cases where we're being called a second time. The former case + // happens when this is triggered both by complete() and by processAsync(), + // while the latter happens when processAsync() in conjunction with sent + // data causes abort() to be called. + if (this._asyncCopier || this._ended) + { + dumpn("*** ignoring second call to _startAsyncProcessor"); + return; + } + + // Send headers if they haven't been sent already and should be sent, then + // asynchronously continue to send the body. + if (this._headers && !this._powerSeized) + { + this._sendHeaders(); + return; + } + + this._headers = null; + this._sendBody(); + }, + + /** +* Signals that all modifications to the response status line and headers are +* complete and then sends that data over the network to the client. Once +* this method completes, a different response to the request that resulted +* in this response cannot be sent -- the only possible action in case of +* error is to abort the response and close the connection. +*/ + _sendHeaders: function() + { + dumpn("*** _sendHeaders()"); + + NS_ASSERT(this._headers); + NS_ASSERT(!this._powerSeized); + + // request-line + var statusLine = "HTTP/" + this.httpVersion + " " + + this.httpCode + " " + + this.httpDescription + "\r\n"; + + // header post-processing + + var headers = this._headers; + headers.setHeader("Connection", "close", false); + headers.setHeader("Server", "httpd.js", false); + if (!headers.hasHeader("Date")) + headers.setHeader("Date", toDateString(Date.now()), false); + + // Any response not being processed asynchronously must have an associated + // Content-Length header for reasons of backwards compatibility with the + // initial server, which fully buffered every response before sending it. + // Beyond that, however, it's good to do this anyway because otherwise it's + // impossible to test behaviors that depend on the presence or absence of a + // Content-Length header. + if (!this._processAsync) + { + dumpn("*** non-async response, set Content-Length"); + + var bodyStream = this._bodyInputStream; + var avail = bodyStream ? bodyStream.available() : 0; + + // XXX assumes stream will always report the full amount of data available + headers.setHeader("Content-Length", "" + avail, false); + } + + + // construct and send response + dumpn("*** header post-processing completed, sending response head..."); + + // request-line + var preambleData = [statusLine]; + + // headers + var headEnum = headers.enumerator; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + var values = headers.getHeaderValues(fieldName); + for (var i = 0, sz = values.length; i < sz; i++) + preambleData.push(fieldName + ": " + values[i] + "\r\n"); + } + + // end request-line/headers + preambleData.push("\r\n"); + + var preamble = preambleData.join(""); + + var responseHeadPipe = new Pipe(true, false, 0, PR_UINT32_MAX, null); + responseHeadPipe.outputStream.write(preamble, preamble.length); + + var response = this; + var copyObserver = + { + onStartRequest: function(request, cx) + { + dumpn("*** preamble copying started"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** preamble copying complete " + + "[status=0x" + statusCode.toString(16) + "]"); + + if (!components.isSuccessCode(statusCode)) + { + dumpn("!!! header copying problems: non-success statusCode, " + + "ending response"); + + response.end(); + } + else + { + response._sendBody(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + var headerCopier = this._asyncCopier = + new WriteThroughCopier(responseHeadPipe.inputStream, + this._connection.output, + copyObserver, null); + + responseHeadPipe.outputStream.close(); + + // Forbid setting any more headers or modifying the request line. + this._headers = null; + }, + + /** +* Asynchronously writes the body of the response (or the entire response, if +* seizePower() has been called) to the network. +*/ + _sendBody: function() + { + dumpn("*** _sendBody"); + + NS_ASSERT(!this._headers, "still have headers around but sending body?"); + + // If no body data was written, we're done + if (!this._bodyInputStream) + { + dumpn("*** empty body, response finished"); + this.end(); + return; + } + + var response = this; + var copyObserver = + { + onStartRequest: function(request, context) + { + dumpn("*** onStartRequest"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** onStopRequest [status=0x" + statusCode.toString(16) + "]"); + + if (statusCode === Cr.NS_BINDING_ABORTED) + { + dumpn("*** terminating copy observer without ending the response"); + } + else + { + if (!components.isSuccessCode(statusCode)) + dumpn("*** WARNING: non-success statusCode in onStopRequest"); + + response.end(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + dumpn("*** starting async copier of body data..."); + this._asyncCopier = + new WriteThroughCopier(this._bodyInputStream, this._connection.output, + copyObserver, null); + }, + + /** Ensures that this hasn't been ended. */ + _ensureAlive: function() + { + NS_ASSERT(!this._ended, "not handling response lifetime correctly"); + } +}; + +/** +* Size of the segments in the buffer used in storing response data and writing +* it to the socket. +*/ +Response.SEGMENT_SIZE = 8192; + +/** Serves double duty in WriteThroughCopier implementation. */ +function notImplemented() +{ + throw Cr.NS_ERROR_NOT_IMPLEMENTED; +} + +/** Returns true iff the given exception represents stream closure. */ +function streamClosed(e) +{ + return e === Cr.NS_BASE_STREAM_CLOSED || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_CLOSED); +} + +/** Returns true iff the given exception represents a blocked stream. */ +function wouldBlock(e) +{ + return e === Cr.NS_BASE_STREAM_WOULD_BLOCK || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_WOULD_BLOCK); +} + +/** +* Copies data from source to sink as it becomes available, when that data can +* be written to sink without blocking. +* +* @param source : nsIAsyncInputStream +* the stream from which data is to be read +* @param sink : nsIAsyncOutputStream +* the stream to which data is to be copied +* @param observer : nsIRequestObserver +* an observer which will be notified when the copy starts and finishes +* @param context : nsISupports +* context passed to observer when notified of start/stop +* @throws NS_ERROR_NULL_POINTER +* if source, sink, or observer are null +*/ +function WriteThroughCopier(source, sink, observer, context) +{ + if (!source || !sink || !observer) + throw Cr.NS_ERROR_NULL_POINTER; + + /** Stream from which data is being read. */ + this._source = source; + + /** Stream to which data is being written. */ + this._sink = sink; + + /** Observer watching this copy. */ + this._observer = observer; + + /** Context for the observer watching this. */ + this._context = context; + + /** +* True iff this is currently being canceled (cancel has been called, the +* callback may not yet have been made). +*/ + this._canceled = false; + + /** +* False until all data has been read from input and written to output, at +* which point this copy is completed and cancel() is asynchronously called. +*/ + this._completed = false; + + /** Required by nsIRequest, meaningless. */ + this.loadFlags = 0; + /** Required by nsIRequest, meaningless. */ + this.loadGroup = null; + /** Required by nsIRequest, meaningless. */ + this.name = "response-body-copy"; + + /** Status of this request. */ + this.status = Cr.NS_OK; + + /** Arrays of byte strings waiting to be written to output. */ + this._pendingData = []; + + // start copying + try + { + observer.onStartRequest(this, context); + this._waitToReadData(); + this._waitForSinkClosure(); + } + catch (e) + { + dumpn("!!! error starting copy: " + e + + ("lineNumber" in e ? ", line " + e.lineNumber : "")); + dumpn(e.stack); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + } +} +WriteThroughCopier.prototype = +{ + /* nsISupports implementation */ + + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIInputStreamCallback) || + iid.equals(Ci.nsIOutputStreamCallback) || + iid.equals(Ci.nsIRequest) || + iid.equals(Ci.nsISupports)) + { + return this; + } + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NSIINPUTSTREAMCALLBACK + + /** +* Receives a more-data-in-input notification and writes the corresponding +* data to the output. +* +* @param input : nsIAsyncInputStream +* the input stream on whose data we have been waiting +*/ + onInputStreamReady: function(input) + { + if (this._source === null) + return; + + dumpn("*** onInputStreamReady"); + + // + // Ordinarily we'll read a non-zero amount of data from input, queue it up + // to be written and then wait for further callbacks. The complications in + // this method are the cases where we deviate from that behavior when errors + // occur or when copying is drawing to a finish. + // + // The edge cases when reading data are: + // + // Zero data is read + // If zero data was read, we're at the end of available data, so we can + // should stop reading and move on to writing out what we have (or, if + // we've already done that, onto notifying of completion). + // A stream-closed exception is thrown + // This is effectively a less kind version of zero data being read; the + // only difference is that we notify of completion with that result + // rather than with NS_OK. + // Some other exception is thrown + // This is the least kind result. We don't know what happened, so we + // act as though the stream closed except that we notify of completion + // with the result NS_ERROR_UNEXPECTED. + // + + var bytesWanted = 0, bytesConsumed = -1; + try + { + input = new BinaryInputStream(input); + + bytesWanted = Math.min(input.available(), Response.SEGMENT_SIZE); + dumpn("*** input wanted: " + bytesWanted); + + if (bytesWanted > 0) + { + var data = input.readByteArray(bytesWanted); + bytesConsumed = data.length; + this._pendingData.push(String.fromCharCode.apply(String, data)); + } + + dumpn("*** " + bytesConsumed + " bytes read"); + + // Handle the zero-data edge case in the same place as all other edge + // cases are handled. + if (bytesWanted === 0) + throw Cr.NS_BASE_STREAM_CLOSED; + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** input stream closed"); + e = bytesWanted === 0 ? Cr.NS_OK : Cr.NS_ERROR_UNEXPECTED; + } + else + { + dumpn("!!! unexpected error reading from input, canceling: " + e); + e = Cr.NS_ERROR_UNEXPECTED; + } + + this._doneReadingSource(e); + return; + } + + var pendingData = this._pendingData; + + NS_ASSERT(bytesConsumed > 0); + NS_ASSERT(pendingData.length > 0, "no pending data somehow?"); + NS_ASSERT(pendingData[pendingData.length - 1].length > 0, + "buffered zero bytes of data?"); + + NS_ASSERT(this._source !== null); + + // Reading has gone great, and we've gotten data to write now. What if we + // don't have a place to write that data, because output went away just + // before this read? Drop everything on the floor, including new data, and + // cancel at this point. + if (this._sink === null) + { + pendingData.length = 0; + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we've read the data, and we know we have a place to write it. We + // need to queue up the data to be written, but *only* if none is queued + // already -- if data's already queued, the code that actually writes the + // data will make sure to wait on unconsumed pending data. + try + { + if (pendingData.length === 1) + this._waitToWriteData(); + } + catch (e) + { + dumpn("!!! error waiting to write data just read, swallowing and " + + "writing only what we already have: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Whee! We successfully read some data, and it's successfully queued up to + // be written. All that remains now is to wait for more data to read. + try + { + this._waitToReadData(); + } + catch (e) + { + dumpn("!!! error waiting to read more data: " + e); + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + } + }, + + + // NSIOUTPUTSTREAMCALLBACK + + /** +* Callback when data may be written to the output stream without blocking, or +* when the output stream has been closed. +* +* @param output : nsIAsyncOutputStream +* the output stream on whose writability we've been waiting, also known as +* this._sink +*/ + onOutputStreamReady: function(output) + { + if (this._sink === null) + return; + + dumpn("*** onOutputStreamReady"); + + var pendingData = this._pendingData; + if (pendingData.length === 0) + { + // There's no pending data to write. The only way this can happen is if + // we're waiting on the output stream's closure, so we can respond to a + // copying failure as quickly as possible (rather than waiting for data to + // be available to read and then fail to be copied). Therefore, we must + // be done now -- don't bother to attempt to write anything and wrap + // things up. + dumpn("!!! output stream closed prematurely, ending copy"); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + + NS_ASSERT(pendingData[0].length > 0, "queued up an empty quantum?"); + + // + // Write out the first pending quantum of data. The possible errors here + // are: + // + // The write might fail because we can't write that much data + // Okay, we've written what we can now, so re-queue what's left and + // finish writing it out later. + // The write failed because the stream was closed + // Discard pending data that we can no longer write, stop reading, and + // signal that copying finished. + // Some other error occurred. + // Same as if the stream were closed, but notify with the status + // NS_ERROR_UNEXPECTED so the observer knows something was wonky. + // + + try + { + var quantum = pendingData[0]; + + // XXX |quantum| isn't guaranteed to be ASCII, so we're relying on + // undefined behavior! We're only using this because writeByteArray + // is unusably broken for asynchronous output streams; see bug 532834 + // for details. + var bytesWritten = output.write(quantum, quantum.length); + if (bytesWritten === quantum.length) + pendingData.shift(); + else + pendingData[0] = quantum.substring(bytesWritten); + + dumpn("*** wrote " + bytesWritten + " bytes of data"); + } + catch (e) + { + if (wouldBlock(e)) + { + NS_ASSERT(pendingData.length > 0, + "stream-blocking exception with no data to write?"); + NS_ASSERT(pendingData[0].length > 0, + "stream-blocking exception with empty quantum?"); + this._waitToWriteData(); + return; + } + + if (streamClosed(e)) + dumpn("!!! output stream prematurely closed, signaling error..."); + else + dumpn("!!! unknown error: " + e + ", quantum=" + quantum); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // The day is ours! Quantum written, now let's see if we have more data + // still to write. + try + { + if (pendingData.length > 0) + { + this._waitToWriteData(); + return; + } + } + catch (e) + { + dumpn("!!! unexpected error waiting to write pending data: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we have no more pending data to write -- but might we get more in + // the future? + if (this._source !== null) + { + /* +* If we might, then wait for the output stream to be closed. (We wait +* only for closure because we have no data to write -- and if we waited +* for a specific amount of data, we would get repeatedly notified for no +* reason if over time the output stream permitted more and more data to +* be written to it without blocking.) +*/ + this._waitForSinkClosure(); + } + else + { + /* +* On the other hand, if we can't have more data because the input +* stream's gone away, then it's time to notify of copy completion. +* Victory! +*/ + this._sink = null; + this._cancelOrDispatchCancelCallback(Cr.NS_OK); + } + }, + + + // NSIREQUEST + + /** Returns true if the cancel observer hasn't been notified yet. */ + isPending: function() + { + return !this._completed; + }, + + /** Not implemented, don't use! */ + suspend: notImplemented, + /** Not implemented, don't use! */ + resume: notImplemented, + + /** +* Cancels data reading from input, asynchronously writes out any pending +* data, and causes the observer to be notified with the given error code when +* all writing has finished. +* +* @param status : nsresult +* the status to pass to the observer when data copying has been canceled +*/ + cancel: function(status) + { + dumpn("*** cancel(" + status.toString(16) + ")"); + + if (this._canceled) + { + dumpn("*** suppressing a late cancel"); + return; + } + + this._canceled = true; + this.status = status; + + // We could be in the middle of absolutely anything at this point. Both + // input and output might still be around, we might have pending data to + // write, and in general we know nothing about the state of the world. We + // therefore must assume everything's in progress and take everything to its + // final steady state (or so far as it can go before we need to finish + // writing out remaining data). + + this._doneReadingSource(status); + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Stop reading input if we haven't already done so, passing e as the status +* when closing the stream, and kick off a copy-completion notice if no more +* data remains to be written. +* +* @param e : nsresult +* the status to be used when closing the input stream +*/ + _doneReadingSource: function(e) + { + dumpn("*** _doneReadingSource(0x" + e.toString(16) + ")"); + + this._finishSource(e); + if (this._pendingData.length === 0) + this._sink = null; + else + NS_ASSERT(this._sink !== null, "null output?"); + + // If we've written out all data read up to this point, then it's time to + // signal completion. + if (this._sink === null) + { + NS_ASSERT(this._pendingData.length === 0, "pending data still?"); + this._cancelOrDispatchCancelCallback(e); + } + }, + + /** +* Stop writing output if we haven't already done so, discard any data that +* remained to be sent, close off input if it wasn't already closed, and kick +* off a copy-completion notice. +* +* @param e : nsresult +* the status to be used when closing input if it wasn't already closed +*/ + _doneWritingToSink: function(e) + { + dumpn("*** _doneWritingToSink(0x" + e.toString(16) + ")"); + + this._pendingData.length = 0; + this._sink = null; + this._doneReadingSource(e); + }, + + /** +* Completes processing of this copy: either by canceling the copy if it +* hasn't already been canceled using the provided status, or by dispatching +* the cancel callback event (with the originally provided status, of course) +* if it already has been canceled. +* +* @param status : nsresult +* the status code to use to cancel this, if this hasn't already been +* canceled +*/ + _cancelOrDispatchCancelCallback: function(status) + { + dumpn("*** _cancelOrDispatchCancelCallback(" + status + ")"); + + NS_ASSERT(this._source === null, "should have finished input"); + NS_ASSERT(this._sink === null, "should have finished output"); + NS_ASSERT(this._pendingData.length === 0, "should have no pending data"); + + if (!this._canceled) + { + this.cancel(status); + return; + } + + var self = this; + var event = + { + run: function() + { + dumpn("*** onStopRequest async callback"); + + self._completed = true; + try + { + self._observer.onStopRequest(self, self._context, self.status); + } + catch (e) + { + NS_ASSERT(false, + "how are we throwing an exception here? we control " + + "all the callers! " + e); + } + } + }; + + gThreadManager.currentThread.dispatch(event, Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** +* Kicks off another wait for more data to be available from the input stream. +*/ + _waitToReadData: function() + { + dumpn("*** _waitToReadData"); + this._source.asyncWait(this, 0, Response.SEGMENT_SIZE, + gThreadManager.mainThread); + }, + + /** +* Kicks off another wait until data can be written to the output stream. +*/ + _waitToWriteData: function() + { + dumpn("*** _waitToWriteData"); + + var pendingData = this._pendingData; + NS_ASSERT(pendingData.length > 0, "no pending data to write?"); + NS_ASSERT(pendingData[0].length > 0, "buffered an empty write?"); + + this._sink.asyncWait(this, 0, pendingData[0].length, + gThreadManager.mainThread); + }, + + /** +* Kicks off a wait for the sink to which data is being copied to be closed. +* We wait for stream closure when we don't have any data to be copied, rather +* than waiting to write a specific amount of data. We can't wait to write +* data because the sink might be infinitely writable, and if no data appears +* in the source for a long time we might have to spin quite a bit waiting to +* write, waiting to write again, &c. Waiting on stream closure instead means +* we'll get just one notification if the sink dies. Note that when data +* starts arriving from the sink we'll resume waiting for data to be written, +* dropping this closure-only callback entirely. +*/ + _waitForSinkClosure: function() + { + dumpn("*** _waitForSinkClosure"); + + this._sink.asyncWait(this, Ci.nsIAsyncOutputStream.WAIT_CLOSURE_ONLY, 0, + gThreadManager.mainThread); + }, + + /** +* Closes input with the given status, if it hasn't already been closed; +* otherwise a no-op. +* +* @param status : nsresult +* status code use to close the source stream if necessary +*/ + _finishSource: function(status) + { + dumpn("*** _finishSource(" + status.toString(16) + ")"); + + if (this._source !== null) + { + this._source.closeWithStatus(status); + this._source = null; + } + } +}; + + +/** +* A container for utility functions used with HTTP headers. +*/ +const headerUtils = +{ + /** +* Normalizes fieldName (by converting it to lowercase) and ensures it is a +* valid header field name (although not necessarily one specified in RFC +* 2616). +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not match the field-name production in RFC 2616 +* @returns string +* fieldName converted to lowercase if it is a valid header, for characters +* where case conversion is possible +*/ + normalizeFieldName: function(fieldName) + { + if (fieldName == "") + throw Cr.NS_ERROR_INVALID_ARG; + + for (var i = 0, sz = fieldName.length; i < sz; i++) + { + if (!IS_TOKEN_ARRAY[fieldName.charCodeAt(i)]) + { + dumpn(fieldName + " is not a valid header field name!"); + throw Cr.NS_ERROR_INVALID_ARG; + } + } + + return fieldName.toLowerCase(); + }, + + /** +* Ensures that fieldValue is a valid header field value (although not +* necessarily as specified in RFC 2616 if the corresponding field name is +* part of the HTTP protocol), normalizes the value if it is, and +* returns the normalized value. +* +* @param fieldValue : string +* a value to be normalized as an HTTP header field value +* @throws NS_ERROR_INVALID_ARG +* if fieldValue does not match the field-value production in RFC 2616 +* @returns string +* fieldValue as a normalized HTTP header field value +*/ + normalizeFieldValue: function(fieldValue) + { + // field-value = *( field-content | LWS ) + // field-content = <the OCTETs making up the field-value + // and consisting of either *TEXT or combinations + // of token, separators, and quoted-string> + // TEXT = <any OCTET except CTLs, + // but including LWS> + // LWS = [CRLF] 1*( SP | HT ) + // + // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + // qdtext = <any TEXT except <">> + // quoted-pair = "\" CHAR + // CHAR = <any US-ASCII character (octets 0 - 127)> + + // Any LWS that occurs between field-content MAY be replaced with a single + // SP before interpreting the field value or forwarding the message + // downstream (section 4.2); we replace 1*LWS with a single SP + var val = fieldValue.replace(/(?:(?:\r\n)?[ \t]+)+/g, " "); + + // remove leading/trailing LWS (which has been converted to SP) + val = val.replace(/^ +/, "").replace(/ +$/, ""); + + // that should have taken care of all CTLs, so val should contain no CTLs + for (var i = 0, len = val.length; i < len; i++) + if (isCTL(val.charCodeAt(i))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX disallows quoted-pair where CHAR is a CTL -- will not invalidly + // normalize, however, so this can be construed as a tightening of the + // spec and not entirely as a bug + return val; + } +}; + + + +/** +* Converts the given string into a string which is safe for use in an HTML +* context. +* +* @param str : string +* the string to make HTML-safe +* @returns string +* an HTML-safe version of str +*/ +function htmlEscape(str) +{ + // this is naive, but it'll work + var s = ""; + for (var i = 0; i < str.length; i++) + s += "&#" + str.charCodeAt(i) + ";"; + return s; +} + + +/** +* Constructs an object representing an HTTP version (see section 3.1). +* +* @param versionString +* a string of the form "#.#", where # is an non-negative decimal integer with +* or without leading zeros +* @throws +* if versionString does not specify a valid HTTP version number +*/ +function nsHttpVersion(versionString) +{ + var matches = /^(\d+)\.(\d+)$/.exec(versionString); + if (!matches) + throw "Not a valid HTTP version!"; + + /** The major version number of this, as a number. */ + this.major = parseInt(matches[1], 10); + + /** The minor version number of this, as a number. */ + this.minor = parseInt(matches[2], 10); + + if (isNaN(this.major) || isNaN(this.minor) || + this.major < 0 || this.minor < 0) + throw "Not a valid HTTP version!"; +} +nsHttpVersion.prototype = +{ + /** +* Returns the standard string representation of the HTTP version represented +* by this (e.g., "1.1"). +*/ + toString: function () + { + return this.major + "." + this.minor; + }, + + /** +* Returns true if this represents the same HTTP version as otherVersion, +* false otherwise. +* +* @param otherVersion : nsHttpVersion +* the version to compare against this +*/ + equals: function (otherVersion) + { + return this.major == otherVersion.major && + this.minor == otherVersion.minor; + }, + + /** True if this >= otherVersion, false otherwise. */ + atLeast: function(otherVersion) + { + return this.major > otherVersion.major || + (this.major == otherVersion.major && + this.minor >= otherVersion.minor); + } +}; + +nsHttpVersion.HTTP_1_0 = new nsHttpVersion("1.0"); +nsHttpVersion.HTTP_1_1 = new nsHttpVersion("1.1"); + + +/** +* An object which stores HTTP headers for a request or response. +* +* Note that since headers are case-insensitive, this object converts headers to +* lowercase before storing them. This allows the getHeader and hasHeader +* methods to work correctly for any case of a header, but it means that the +* values returned by .enumerator may not be equal case-sensitively to the +* values passed to setHeader when adding headers to this. +*/ +function nsHttpHeaders() +{ + /** +* A hash of headers, with header field names as the keys and header field +* values as the values. Header field names are case-insensitive, but upon +* insertion here they are converted to lowercase. Header field values are +* normalized upon insertion to contain no leading or trailing whitespace. +* +* Note also that per RFC 2616, section 4.2, two headers with the same name in +* a message may be treated as one header with the same field name and a field +* value consisting of the separate field values joined together with a "," in +* their original order. This hash stores multiple headers with the same name +* in this manner. +*/ + this._headers = {}; +} +nsHttpHeaders.prototype = +{ + /** +* Sets the header represented by name and value in this. +* +* @param name : string +* the header name +* @param value : string +* the header value +* @throws NS_ERROR_INVALID_ARG +* if name or value is not a valid header component +*/ + setHeader: function(fieldName, fieldValue, merge) + { + var name = headerUtils.normalizeFieldName(fieldName); + var value = headerUtils.normalizeFieldValue(fieldValue); + + // The following three headers are stored as arrays because their real-world + // syntax prevents joining individual headers into a single header using + // ",". See also <http://hg.mozilla.org/mozilla-central/diff/9b2a99adc05e/netwerk/protocol/http/src/nsHttpHeaderArray.cpp#l77> + if (merge && name in this._headers) + { + if (name === "www-authenticate" || + name === "proxy-authenticate" || + name === "set-cookie") + { + this._headers[name].push(value); + } + else + { + this._headers[name][0] += "," + value; + NS_ASSERT(this._headers[name].length === 1, + "how'd a non-special header have multiple values?") + } + } + else + { + this._headers[name] = [value]; + } + }, + + /** +* Returns the value for the header specified by this. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns string +* the field value for the given header, possibly with non-semantic changes +* (i.e., leading/trailing whitespace stripped, whitespace runs replaced +* with spaces, etc.) at the option of the implementation; multiple +* instances of the header will be combined with a comma, except for +* the three headers noted in the description of getHeaderValues +*/ + getHeader: function(fieldName) + { + return this.getHeaderValues(fieldName).join("\n"); + }, + + /** +* Returns the value for the header specified by fieldName as an array. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns [string] +* an array of all the header values in this for the given +* header name. Header values will generally be collapsed +* into a single header by joining all header values together +* with commas, but certain headers (Proxy-Authenticate, +* WWW-Authenticate, and Set-Cookie) violate the HTTP spec +* and cannot be collapsed in this manner. For these headers +* only, the returned array may contain multiple elements if +* that header has been added more than once. +*/ + getHeaderValues: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + + if (name in this._headers) + return this._headers[name]; + else + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** +* Returns true if a header with the given field name exists in this, false +* otherwise. +* +* @param fieldName : string +* the field name whose existence is to be determined in this +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @returns boolean +* true if the header's present, false otherwise +*/ + hasHeader: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + return (name in this._headers); + }, + + /** +* Returns a new enumerator over the field names of the headers in this, as +* nsISupportsStrings. The names returned will be in lowercase, regardless of +* how they were input using setHeader (header names are case-insensitive per +* RFC 2616). +*/ + get enumerator() + { + var headers = []; + for (var i in this._headers) + { + var supports = new SupportsString(); + supports.data = i; + headers.push(supports); + } + + return new nsSimpleEnumerator(headers); + } +}; + + +/** +* Constructs an nsISimpleEnumerator for the given array of items. +* +* @param items : Array +* the items, which must all implement nsISupports +*/ +function nsSimpleEnumerator(items) +{ + this._items = items; + this._nextIndex = 0; +} +nsSimpleEnumerator.prototype = +{ + hasMoreElements: function() + { + return this._nextIndex < this._items.length; + }, + getNext: function() + { + if (!this.hasMoreElements()) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + return this._items[this._nextIndex++]; + }, + QueryInterface: function(aIID) + { + if (Ci.nsISimpleEnumerator.equals(aIID) || + Ci.nsISupports.equals(aIID)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +}; + + +/** +* A representation of the data in an HTTP request. +* +* @param port : uint +* the port on which the server receiving this request runs +*/ +function Request(port) +{ + /** Method of this request, e.g. GET or POST. */ + this._method = ""; + + /** Path of the requested resource; empty paths are converted to '/'. */ + this._path = ""; + + /** Query string, if any, associated with this request (not including '?'). */ + this._queryString = ""; + + /** Scheme of requested resource, usually http, always lowercase. */ + this._scheme = "http"; + + /** Hostname on which the requested resource resides. */ + this._host = undefined; + + /** Port number over which the request was received. */ + this._port = port; + + var bodyPipe = new Pipe(false, false, 0, PR_UINT32_MAX, null); + + /** Stream from which data in this request's body may be read. */ + this._bodyInputStream = bodyPipe.inputStream; + + /** Stream to which data in this request's body is written. */ + this._bodyOutputStream = bodyPipe.outputStream; + + /** +* The headers in this request. +*/ + this._headers = new nsHttpHeaders(); + + /** +* For the addition of ad-hoc properties and new functionality without having +* to change nsIHttpRequest every time; currently lazily created, as its only +* use is in directory listings. +*/ + this._bag = null; +} +Request.prototype = +{ + // SERVER METADATA + + // + // see nsIHttpRequest.scheme + // + get scheme() + { + return this._scheme; + }, + + // + // see nsIHttpRequest.host + // + get host() + { + return this._host; + }, + + // + // see nsIHttpRequest.port + // + get port() + { + return this._port; + }, + + // REQUEST LINE + + // + // see nsIHttpRequest.method + // + get method() + { + return this._method; + }, + + // + // see nsIHttpRequest.httpVersion + // + get httpVersion() + { + return this._httpVersion.toString(); + }, + + // + // see nsIHttpRequest.path + // + get path() + { + return this._path; + }, + + // + // see nsIHttpRequest.queryString + // + get queryString() + { + return this._queryString; + }, + + // HEADERS + + // + // see nsIHttpRequest.getHeader + // + getHeader: function(name) + { + return this._headers.getHeader(name); + }, + + // + // see nsIHttpRequest.hasHeader + // + hasHeader: function(name) + { + return this._headers.hasHeader(name); + }, + + // + // see nsIHttpRequest.headers + // + get headers() + { + return this._headers.enumerator; + }, + + // + // see nsIPropertyBag.enumerator + // + get enumerator() + { + this._ensurePropertyBag(); + return this._bag.enumerator; + }, + + // + // see nsIHttpRequest.headers + // + get bodyInputStream() + { + return this._bodyInputStream; + }, + + // + // see nsIPropertyBag.getProperty + // + getProperty: function(name) + { + this._ensurePropertyBag(); + return this._bag.getProperty(name); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpRequest) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** Ensures a property bag has been created for ad-hoc behaviors. */ + _ensurePropertyBag: function() + { + if (!this._bag) + this._bag = new WritablePropertyBag(); + } +}; + + +// XPCOM trappings +if ("XPCOMUtils" in this && // Firefox 3.6 doesn't load XPCOMUtils in this scope for some reason... + "generateNSGetFactory" in XPCOMUtils) { + var NSGetFactory = XPCOMUtils.generateNSGetFactory([nsHttpServer]); +} + + + +/** +* Creates a new HTTP server listening for loopback traffic on the given port, +* starts it, and runs the server until the server processes a shutdown request, +* spinning an event loop so that events posted by the server's socket are +* processed. +* +* This method is primarily intended for use in running this script from within +* xpcshell and running a functional HTTP server without having to deal with +* non-essential details. +* +* Note that running multiple servers using variants of this method probably +* doesn't work, simply due to how the internal event loop is spun and stopped. +* +* @note +* This method only works with Mozilla 1.9 (i.e., Firefox 3 or trunk code); +* you should use this server as a component in Mozilla 1.8. +* @param port +* the port on which the server will run, or -1 if there exists no preference +* for a specific port; note that attempting to use some values for this +* parameter (particularly those below 1024) may cause this method to throw or +* may result in the server being prematurely shut down +* @param basePath +* a local directory from which requests will be served (i.e., if this is +* "/home/jwalden/" then a request to /index.html will load +* /home/jwalden/index.html); if this is omitted, only the default URLs in +* this server implementation will be functional +*/ +function server(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + // if you're running this, you probably want to see debugging info + DEBUG = true; + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", SJS_TYPE); + srv.start(port); + + var thread = gThreadManager.currentThread; + while (!srv.isStopped()) + thread.processNextEvent(true); + + // get rid of any pending requests + while (thread.hasPendingEvents()) + thread.processNextEvent(true); + + DEBUG = false; +} + +function startServerAsync(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", "sjs"); + srv.start(port); + return srv; +} + +exports.nsHttpServer = nsHttpServer; +exports.ScriptableInputStream = ScriptableInputStream; +exports.server = server; +exports.startServerAsync = startServerAsync; diff --git a/addon-sdk/source/test/addons/e10s-content/lib/main.js b/addon-sdk/source/test/addons/e10s-content/lib/main.js new file mode 100644 index 000000000..6ca7dc48c --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/main.js @@ -0,0 +1,22 @@ +/* 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/. */ +'use strict'; + +const { merge } = require('sdk/util/object'); +const { version } = require('sdk/system'); + +const SKIPPING_TESTS = { + "test skip": (assert) => assert.pass("nothing to test here") +}; + +merge(module.exports, require('./test-content-script')); +merge(module.exports, require('./test-content-worker')); +merge(module.exports, require('./test-page-worker')); + +// run e10s tests only on builds from trunk, fx-team, Nightly.. +if (!version.endsWith('a1')) { + module.exports = SKIPPING_TESTS; +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/e10s-content/lib/test-content-script.js b/addon-sdk/source/test/addons/e10s-content/lib/test-content-script.js new file mode 100644 index 000000000..c24f841b2 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/test-content-script.js @@ -0,0 +1,845 @@ +/* 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/. */ + +const hiddenFrames = require("sdk/frame/hidden-frame"); +const { create: makeFrame } = require("sdk/frame/utils"); +const { window } = require("sdk/addon/window"); +const { Loader } = require('sdk/test/loader'); +const { URL } = require("sdk/url"); +const testURI = require("./fixtures").url("test.html"); +const testHost = URL(testURI).scheme + '://' + URL(testURI).host; + +/* + * Utility function that allow to easily run a proxy test with a clean + * new HTML document. See first unit test for usage. + */ +function createProxyTest(html, callback) { + return function (assert, done) { + let url = 'data:text/html;charset=utf-8,' + encodeURIComponent(html); + let principalLoaded = false; + + let element = makeFrame(window.document, { + nodeName: "iframe", + type: "content", + allowJavascript: true, + allowPlugins: true, + allowAuth: true, + uri: testURI + }); + + element.addEventListener("DOMContentLoaded", onDOMReady, false); + + function onDOMReady() { + // Reload frame after getting principal from `testURI` + if (!principalLoaded) { + element.setAttribute("src", url); + principalLoaded = true; + return; + } + + assert.equal(element.getAttribute("src"), url, "correct URL loaded"); + element.removeEventListener("DOMContentLoaded", onDOMReady, + false); + let xrayWindow = element.contentWindow; + let rawWindow = xrayWindow.wrappedJSObject; + + let isDone = false; + let helper = { + xrayWindow: xrayWindow, + rawWindow: rawWindow, + createWorker: function (contentScript) { + return createWorker(assert, xrayWindow, contentScript, helper.done); + }, + done: function () { + if (isDone) + return; + isDone = true; + element.parentNode.removeChild(element); + done(); + } + }; + callback(helper, assert); + } + }; +} + +function createWorker(assert, xrayWindow, contentScript, done) { + let loader = Loader(module); + let Worker = loader.require("sdk/content/worker").Worker; + let worker = Worker({ + window: xrayWindow, + contentScript: [ + 'let assert, done; new ' + function () { + assert = function assert(v, msg) { + self.port.emit("assert", {assertion:v, msg:msg}); + } + done = function done() { + self.port.emit("done"); + } + }, + contentScript + ] + }); + + worker.port.on("done", done); + worker.port.on("assert", function (data) { + assert.ok(data.assertion, data.msg); + }); + + return worker; +} + +/* Examples for the `createProxyTest` uses */ + +var html = "<script>var documentGlobal = true</script>"; + +exports["test Create Proxy Test"] = createProxyTest(html, function (helper, assert) { + // You can get access to regular `test` object in second argument of + // `createProxyTest` method: + assert.ok(helper.rawWindow.documentGlobal, + "You have access to a raw window reference via `helper.rawWindow`"); + assert.ok(!("documentGlobal" in helper.xrayWindow), + "You have access to an XrayWrapper reference via `helper.xrayWindow`"); + + // If you do not create a Worker, you have to call helper.done(), + // in order to say when your test is finished + helper.done(); +}); + +exports["test Create Proxy Test With Worker"] = createProxyTest("", function (helper) { + + helper.createWorker( + "new " + function WorkerScope() { + assert(true, "You can do assertions in your content script"); + // And if you create a worker, you either have to call `done` + // from content script or helper.done() + done(); + } + ); + +}); + +exports["test Create Proxy Test With Events"] = createProxyTest("", function (helper, assert) { + + let worker = helper.createWorker( + "new " + function WorkerScope() { + self.port.emit("foo"); + } + ); + + worker.port.on("foo", function () { + assert.pass("You can use events"); + // And terminate your test with helper.done: + helper.done(); + }); + +}); + +/* Disabled due to bug 1038432 +// Bug 714778: There was some issue around `toString` functions +// that ended up being shared between content scripts +exports["test Shared To String Proxies"] = createProxyTest("", function(helper) { + + let worker = helper.createWorker( + 'new ' + function ContentScriptScope() { + // We ensure that `toString` can't be modified so that nothing could + // leak to/from the document and between content scripts + // It only applies to JS proxies, there isn't any such issue with xrays. + //document.location.toString = function foo() {}; + document.location.toString.foo = "bar"; + assert("foo" in document.location.toString, "document.location.toString can be modified"); + assert(document.location.toString() == "data:text/html;charset=utf-8,", + "First document.location.toString()"); + self.postMessage("next"); + } + ); + worker.on("message", function () { + helper.createWorker( + 'new ' + function ContentScriptScope2() { + assert(!("foo" in document.location.toString), + "document.location.toString is different for each content script"); + assert(document.location.toString() == "data:text/html;charset=utf-8,", + "Second document.location.toString()"); + done(); + } + ); + }); +}); +*/ + +// Ensure that postMessage is working correctly across documents with an iframe +var html = '<iframe id="iframe" name="test" src="data:text/html;charset=utf-8," />'; +exports["test postMessage"] = createProxyTest(html, function (helper, assert) { + let ifWindow = helper.xrayWindow.document.getElementById("iframe").contentWindow; + // Listen without proxies, to check that it will work in regular case + // simulate listening from a web document. + ifWindow.addEventListener("message", function listener(event) { + ifWindow.removeEventListener("message", listener, false); + // As we are in system principal, event is an XrayWrapper + // xrays use current compartments when calling postMessage method. + // Whereas js proxies was using postMessage method compartment, + // not the caller one. + assert.strictEqual(event.source, helper.xrayWindow, + "event.source is the top window"); + assert.equal(event.origin, testHost, "origin matches testHost"); + + assert.equal(event.data, "{\"foo\":\"bar\\n \\\"escaped\\\".\"}", + "message data is correct"); + + helper.done(); + }, false); + + helper.createWorker( + 'new ' + function ContentScriptScope() { + var json = JSON.stringify({foo : "bar\n \"escaped\"."}); + + document.getElementById("iframe").contentWindow.postMessage(json, "*"); + } + ); +}); + +var html = '<input id="input2" type="checkbox" />'; +exports["test Object Listener"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Test objects being given as event listener + let input = document.getElementById("input2"); + let myClickListener = { + called: false, + handleEvent: function(event) { + assert(this === myClickListener, "`this` is the original object"); + assert(!this.called, "called only once"); + this.called = true; + assert(event.target, input, "event.target is the wrapped window"); + done(); + } + }; + + window.addEventListener("click", myClickListener, true); + input.click(); + window.removeEventListener("click", myClickListener, true); + } + ); + +}); + +exports["test Object Listener 2"] = createProxyTest("", function (helper) { + + helper.createWorker( + ('new ' + function ContentScriptScope() { + // variable replaced with `testHost` + let testHost = "TOKEN"; + // Verify object as DOM event listener + let myMessageListener = { + called: false, + handleEvent: function(event) { + window.removeEventListener("message", myMessageListener, true); + + assert(this == myMessageListener, "`this` is the original object"); + assert(!this.called, "called only once"); + this.called = true; + assert(event.target == document.defaultView, "event.target is the wrapped window"); + assert(event.source == document.defaultView, "event.source is the wrapped window"); + assert(event.origin == testHost, "origin matches testHost"); + assert(event.data == "ok", "message data is correct"); + done(); + } + }; + + window.addEventListener("message", myMessageListener, true); + document.defaultView.postMessage("ok", '*'); + } + ).replace("TOKEN", testHost)); + +}); + +var html = '<input id="input" type="text" /><input id="input3" type="checkbox" />' + + '<input id="input2" type="checkbox" />'; + +exports.testStringOverload = createProxyTest(html, function (helper, assert) { + helper.createWorker( + 'new ' + function ContentScriptScope() { + // RightJS is hacking around String.prototype, and do similar thing: + // Pass `this` from a String prototype method. + // It is funny because typeof this == "object"! + // So that when we pass `this` to a native method, + // our proxy code can fail on another even more crazy thing. + // See following test to see what fails around proxies. + String.prototype.update = function () { + assert(typeof this == "object", "in update, `this` is an object"); + assert(this.toString() == "input", "in update, `this.toString works"); + return document.querySelectorAll(this); + }; + assert("input".update().length == 3, "String.prototype overload works"); + done(); + } + ); +}); + +exports["test Element.matches()"] = createProxyTest("", function (helper) { + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Check matches XrayWrappers bug (Bug 658909): + // Test that Element.matches() does not return bad results when we are + // not calling it from the node itself. + assert(document.createElement( "div" ).matches("div"), + "matches works while being called from the node"); + assert(document.documentElement.matches.call( + document.createElement( "div" ), + "div" + ), + "matches works while being called from a " + + "function reference to " + + "document.documentElement.matches.call"); + done(); + } + ); +}); + +exports["test Events Overload"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // If we add a "____proxy" attribute on XrayWrappers in order to store + // the related proxy to create an unique proxy for each wrapper; + // we end up setting this attribute to prototype objects :x + // And so, instances created with such prototype will be considered + // as equal to the prototype ... + // // Internal method that return the proxy for a given XrayWrapper + // function proxify(obj) { + // if (obj._proxy) return obj._proxy; + // return obj._proxy = Proxy.create(...); + // } + // + // // Get a proxy of an XrayWrapper prototype object + // let proto = proxify(xpcProto); + // + // // Use this proxy as a prototype + // function Constr() {} + // Constr.proto = proto; + // + // // Try to create an instance using this prototype + // let xpcInstance = new Constr(); + // let wrapper = proxify(xpcInstance) + // + // xpcProto._proxy = proto and as xpcInstance.__proto__ = xpcProto, + // xpcInstance._proxy = proto ... and profixy(xpcInstance) = proto :( + // + let proto = window.document.createEvent('HTMLEvents').__proto__; + window.Event.prototype = proto; + let event = document.createEvent('HTMLEvents'); + assert(event !== proto, "Event should not be equal to its prototype"); + event.initEvent('dataavailable', true, true); + assert(event.type === 'dataavailable', "Events are working fine"); + done(); + } + ); + +}); + +exports["test Nested Attributes"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // XrayWrappers has a bug when you set an attribute on it, + // in some cases, it creates an unnecessary wrapper that introduces + // a different object that refers to the same original object + // Check that our wrappers don't reproduce this bug + // SEE BUG 658560: Fix identity problem with CrossOriginWrappers + let o = {sandboxObject:true}; + window.nested = o; + o.foo = true; + assert(o === window.nested, "Nested attribute to sandbox object should not be proxified"); + window.nested = document; + assert(window.nested === document, "Nested attribute to proxy should not be double proxified"); + done(); + } + ); + +}); + +exports["test Form nodeName"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + let body = document.body; + // Check form[nodeName] + let form = document.createElement("form"); + let input = document.createElement("input"); + input.setAttribute("name", "test"); + form.appendChild(input); + body.appendChild(form); + assert(form.test == input, "form[nodeName] is valid"); + body.removeChild(form); + done(); + } + ); + +}); + +exports["test localStorage"] = createProxyTest("", function (helper, assert) { + + let worker = helper.createWorker( + 'new ' + function ContentScriptScope() { + // Check localStorage: + assert(window.localStorage, "has access to localStorage"); + window.localStorage.name = 1; + assert(window.localStorage.name == 1, "localStorage appears to work"); + + self.port.on("step2", function () { + window.localStorage.clear(); + assert(window.localStorage.name == undefined, "localStorage really, really works"); + done(); + }); + self.port.emit("step1"); + } + ); + + worker.port.on("step1", function () { + assert.equal(helper.rawWindow.localStorage.name, 1, "localStorage really works"); + worker.port.emit("step2"); + }); + +}); + +exports["test Auto Unwrap Custom Attributes"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + let body = document.body; + // Setting a custom object to a proxy attribute is not wrapped when we get it afterward + let object = {custom: true, enumerable: false}; + body.customAttribute = object; + assert(object === body.customAttribute, "custom JS attributes are not wrapped"); + done(); + } + ); + +}); + +exports["test Object Tag"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // <object>, <embed> and other tags return typeof 'function' + let flash = document.createElement("object"); + assert(typeof flash == "function", "<object> is typeof 'function'"); + assert(flash.toString().match(/\[object HTMLObjectElement.*\]/), "<object> is HTMLObjectElement"); + assert("setAttribute" in flash, "<object> has a setAttribute method"); + done(); + } + ); + +}); + +exports["test Highlight toString Behavior"] = createProxyTest("", function (helper, assert) { + // We do not have any workaround this particular use of toString + // applied on <object> elements. So disable this test until we found one! + //assert.equal(helper.rawWindow.Object.prototype.toString.call(flash), "[object HTMLObjectElement]", "<object> is HTMLObjectElement"); + function f() {}; + let funToString = Object.prototype.toString.call(f); + assert.ok(/\[object Function.*\]/.test(funToString), "functions are functions 1"); + + // This is how jquery call toString: + let strToString = helper.rawWindow.Object.prototype.toString.call(""); + assert.ok(/\[object String.*\]/.test(strToString), "strings are strings"); + + let o = {__exposedProps__:{}}; + let objToString = helper.rawWindow.Object.prototype.toString.call(o); + assert.ok(/\[object Object.*\]/.test(objToString), "objects are objects"); + + // Make sure to pass a function from the same compartments + // or toString will return [object Object] on FF8+ + let f2 = helper.rawWindow.eval("(function () {})"); + let funToString2 = helper.rawWindow.Object.prototype.toString.call(f2); + assert.ok(/\[object Function.*\]/.test(funToString2), "functions are functions 2"); + + helper.done(); +}); + +exports["test Document TagName"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + let body = document.body; + // Check document[tagName] + let div = document.createElement("div"); + div.setAttribute("name", "test"); + body.appendChild(div); + assert(!document.test, "document[divName] is undefined"); + body.removeChild(div); + + let form = document.createElement("form"); + form.setAttribute("name", "test"); + body.appendChild(form); + assert(document.test == form, "document[formName] is valid"); + body.removeChild(form); + + let img = document.createElement("img"); + img.setAttribute("name", "test"); + body.appendChild(img); + assert(document.test == img, "document[imgName] is valid"); + body.removeChild(img); + done(); + } + ); + +}); + +var html = '<iframe id="iframe" name="test" src="data:text/html;charset=utf-8," />'; +exports["test Window Frames"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'let glob = this; new ' + function ContentScriptScope() { + // Check window[frameName] and window.frames[i] + let iframe = document.getElementById("iframe"); + //assert(window.frames.length == 1, "The iframe is reported in window.frames check1"); + //assert(window.frames[0] == iframe.contentWindow, "The iframe is reported in window.frames check2"); + assert(window.test == iframe.contentWindow, "window[frameName] is valid"); + done(); + } + ); + +}); + +exports["test Collections"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Highlight XPCNativeWrapper bug with HTMLCollection + // tds[0] is only defined on first access :o + let body = document.body; + let div = document.createElement("div"); + body.appendChild(div); + div.innerHTML = "<table><tr><td style='padding:0;border:0;display:none'></td><td>t</td></tr></table>"; + let tds = div.getElementsByTagName("td"); + assert(tds[0] == tds[0], "We can get array element multiple times"); + body.removeChild(div); + done(); + } + ); + +}); + +var html = '<input id="input" type="text" /><input id="input3" type="checkbox" />' + + '<input id="input2" type="checkbox" />'; +exports["test Collections 2"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Verify that NodeList/HTMLCollection are working fine + let body = document.body; + let inputs = body.getElementsByTagName("input"); + assert(body.childNodes.length == 3, "body.childNodes length is correct"); + assert(inputs.length == 3, "inputs.length is correct"); + assert(body.childNodes[0] == inputs[0], "body.childNodes[0] is correct"); + assert(body.childNodes[1] == inputs[1], "body.childNodes[1] is correct"); + assert(body.childNodes[2] == inputs[2], "body.childNodes[2] is correct"); + let count = 0; + for(let i in body.childNodes) { + count++; + } + + assert(count >= 3, "body.childNodes is iterable"); + done(); + } + ); + +}); + +exports["test XMLHttpRequest"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // XMLHttpRequest doesn't support XMLHttpRequest.apply, + // that may break our proxy code + assert(new window.XMLHttpRequest(), "we are able to instantiate XMLHttpRequest object"); + done(); + } + ); + +}); + +exports["test XPathResult"] = createProxyTest("", function (helper, assert) { + const XPathResultTypes = ["ANY_TYPE", + "NUMBER_TYPE", "STRING_TYPE", "BOOLEAN_TYPE", + "UNORDERED_NODE_ITERATOR_TYPE", + "ORDERED_NODE_ITERATOR_TYPE", + "UNORDERED_NODE_SNAPSHOT_TYPE", + "ORDERED_NODE_SNAPSHOT_TYPE", + "ANY_UNORDERED_NODE_TYPE", + "FIRST_ORDERED_NODE_TYPE"]; + + // Check XPathResult bug with constants being undefined on XPCNativeWrapper + let xpcXPathResult = helper.xrayWindow.XPathResult; + + XPathResultTypes.forEach(function(type, i) { + assert.equal(xpcXPathResult.wrappedJSObject[type], + helper.rawWindow.XPathResult[type], + "XPathResult's constants are valid on unwrapped node"); + + assert.equal(xpcXPathResult[type], i, + "XPathResult's constants are defined on " + + "XPCNativeWrapper (platform bug #)"); + }); + + let value = helper.rawWindow.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE; + let worker = helper.createWorker( + 'new ' + function ContentScriptScope() { + self.port.on("value", function (value) { + // Check that our work around is working: + assert(window.XPathResult.UNORDERED_NODE_SNAPSHOT_TYPE === value, + "XPathResult works correctly on Proxies"); + done(); + }); + } + ); + worker.port.emit("value", value); +}); + +exports["test Prototype Inheritance"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Verify that inherited prototype function like initEvent + // are handled correctly. (e2.type will return an error if it's not the case) + let event1 = document.createEvent( 'MouseEvents' ); + event1.initEvent( "click", true, true ); + let event2 = document.createEvent( 'MouseEvents' ); + event2.initEvent( "click", true, true ); + assert(event2.type == "click", "We are able to create an event"); + done(); + } + ); + +}); + +exports["test Functions"] = createProxyTest("", function (helper) { + + helper.rawWindow.callFunction = function callFunction(f) { + return f(); + }; + helper.rawWindow.isEqual = function isEqual(a, b) { + return a == b; + }; + // bug 784116: workaround in order to allow proxy code to cache proxies on + // these functions: + helper.rawWindow.callFunction.__exposedProps__ = {__proxy: 'rw'}; + helper.rawWindow.isEqual.__exposedProps__ = {__proxy: 'rw'}; + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Check basic usage of functions + let closure2 = function () {return "ok";}; + assert(window.wrappedJSObject.callFunction(closure2) == "ok", "Function references work"); + + // Ensure that functions are cached when being wrapped to native code + let closure = function () {}; + assert(window.wrappedJSObject.isEqual(closure, closure), "Function references are cached before being wrapped to native"); + done(); + } + ); + +}); + +var html = '<input id="input2" type="checkbox" />'; +exports["test Listeners"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + // Verify listeners: + let input = document.getElementById("input2"); + assert(input, "proxy.getElementById works"); + + function onclick() {}; + input.onclick = onclick; + assert(input.onclick === onclick, "on* attributes are equal to original function set"); + + let addEventListenerCalled = false; + let expandoCalled = false; + input.addEventListener("click", function onclick(event) { + input.removeEventListener("click", onclick, true); + + assert(!addEventListenerCalled, "closure given to addEventListener is called once"); + if (addEventListenerCalled) + return; + addEventListenerCalled = true; + + assert(!event.target.ownerDocument.defaultView.documentGlobal, "event object is still wrapped and doesn't expose document globals"); + + let input2 = document.getElementById("input2"); + + input.onclick = function (event) { + input.onclick = null; + assert(!expandoCalled, "closure set to expando is called once"); + if (expandoCalled) return; + expandoCalled = true; + + assert(!event.target.ownerDocument.defaultView.documentGlobal, "event object is still wrapped and doesn't expose document globals"); + + setTimeout(function () { + input.click(); + done(); + }, 0); + + } + + setTimeout(function () { + input.click(); + }, 0); + + }, true); + + input.click(); + } + ); + +}); + +exports["test requestAnimationFrame"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + var self = (function() { return this; })(); + window.requestAnimationFrame(function callback() { + assert(self == this, "self is equal to `this`"); + done(); + }); + } + ); + +}); + +exports["testGlobalScope"] = createProxyTest("", function (helper) { + + helper.createWorker( + 'let toplevelScope = true;' + + 'assert(window.toplevelScope, "variables in toplevel scope are set to `window` object");' + + 'assert(this.toplevelScope, "variables in toplevel scope are set to `this` object");' + + 'done();' + ); + +}); + +// Bug 715755: proxy code throw an exception on COW +// Create an http server in order to simulate real cross domain documents +exports["test Cross Domain Iframe"] = createProxyTest("", function (helper) { + let serverPort = 8099; + let server = require("./httpd").startServerAsync(serverPort); + server.registerPathHandler("/", function handle(request, response) { + // Returns the webpage that receive a message and forward it back to its + // parent document by appending ' world'. + let content = "<html><head><meta charset='utf-8'></head>\n"; + content += "<script>\n"; + content += " window.addEventListener('message', function (event) {\n"; + content += " parent.postMessage(event.data + ' world', '*');\n"; + content += " }, true);\n"; + content += "</script>\n"; + content += "<body></body>\n"; + content += "</html>\n"; + response.write(content); + }); + + let worker = helper.createWorker( + 'new ' + function ContentScriptScope() { + // Waits for the server page url + self.on("message", function (url) { + // Creates an iframe with this page + let iframe = document.createElement("iframe"); + iframe.addEventListener("load", function onload() { + iframe.removeEventListener("load", onload, true); + try { + // Try to communicate with iframe's content + window.addEventListener("message", function onmessage(event) { + window.removeEventListener("message", onmessage, true); + + assert(event.data == "hello world", "COW works properly"); + self.port.emit("end"); + }, true); + iframe.contentWindow.postMessage("hello", "*"); + } catch(e) { + assert(false, "COW fails : "+e.message); + } + }, true); + iframe.setAttribute("src", url); + document.body.appendChild(iframe); + }); + } + ); + + worker.port.on("end", function () { + server.stop(helper.done); + }); + + worker.postMessage("http://localhost:" + serverPort + "/"); + +}); + +// Bug 769006: Ensure that MutationObserver works fine with proxies +var html = '<a href="foo">link</a>'; +exports["test MutationObvserver"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'new ' + function ContentScriptScope() { + if (typeof MutationObserver == "undefined") { + assert(true, "No MutationObserver for this FF version"); + done(); + return; + } + let link = document.getElementsByTagName("a")[0]; + + // Register a Mutation observer + let obs = new MutationObserver(function(mutations){ + // Ensure that mutation data are valid + assert(mutations.length == 1, "only one attribute mutation"); + let mutation = mutations[0]; + assert(mutation.type == "attributes", "check `type`"); + assert(mutation.target == link, "check `target`"); + assert(mutation.attributeName == "href", "check `attributeName`"); + assert(mutation.oldValue == "foo", "check `oldValue`"); + obs.disconnect(); + done(); + }); + obs.observe(document, { + subtree: true, + attributes: true, + attributeOldValue: true, + attributeFilter: ["href"] + }); + + // Modify the DOM + link.setAttribute("href", "bar"); + } + ); + +}); + +var html = '<script>' + + 'var accessCheck = function() {' + + ' assert(true, "exporting function works");' + + ' try{' + + ' exportedObj.prop;' + + ' assert(false, "content should not have access to content-script");' + + ' } catch(e) {' + + ' assert(e.toString().indexOf("Permission denied") != -1,' + + ' "content should not have access to content-script");' + + ' }' + + '}</script>'; +exports["test nsEp for content-script"] = createProxyTest(html, function (helper) { + + helper.createWorker( + 'let glob = this; new ' + function ContentScriptScope() { + + exportFunction(assert, unsafeWindow, { defineAs: "assert" }); + window.wrappedJSObject.assert(true, "assert exported"); + window.wrappedJSObject.exportedObj = { prop: 42 }; + window.wrappedJSObject.accessCheck(); + done(); + } + ); + +}); + +// require("sdk/test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-content/lib/test-content-worker.js b/addon-sdk/source/test/addons/e10s-content/lib/test-content-worker.js new file mode 100644 index 000000000..5eddb826a --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/test-content-worker.js @@ -0,0 +1,1127 @@ +/* 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/. */ +"use strict"; + +// Skipping due to window creation being unsupported in Fennec +module.metadata = { + engines: { + 'Firefox': '*' + } +}; + +const { Cc, Ci } = require("chrome"); +const { on } = require("sdk/event/core"); +const { setTimeout } = require("sdk/timers"); +const { LoaderWithHookedConsole, Loader } = require("sdk/test/loader"); +const { Worker } = require("sdk/content/worker"); +const { close } = require("sdk/window/helpers"); +const { set: setPref } = require("sdk/preferences/service"); +const { isArray } = require("sdk/lang/type"); +const { URL } = require('sdk/url'); +const fixtures = require("./fixtures"); +const system = require("sdk/system/events"); + +const DEPRECATE_PREF = "devtools.errorconsole.deprecation_warnings"; + +const DEFAULT_CONTENT_URL = "data:text/html;charset=utf-8,foo"; + +const WINDOW_SCRIPT_URL = "data:text/html;charset=utf-8," + + "<script>window.addEventListener('message', function (e) {" + + " if (e.data === 'from -> content-script')" + + " window.postMessage('from -> window', '*');" + + "});</script>"; + +function makeWindow() { + let content = + "<?xml version=\"1.0\"?>" + + "<window " + + "xmlns=\"http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul\">" + + "<script>var documentValue=true;</script>" + + "</window>"; + var url = "data:application/vnd.mozilla.xul+xml;charset=utf-8," + + encodeURIComponent(content); + var features = ["chrome", "width=10", "height=10"]; + + return Cc["@mozilla.org/embedcomp/window-watcher;1"]. + getService(Ci.nsIWindowWatcher). + openWindow(null, url, null, features.join(","), null); +} + +// Listen for only first one occurence of DOM event +function listenOnce(node, eventName, callback) { + node.addEventListener(eventName, function onevent(event) { + node.removeEventListener(eventName, onevent, true); + callback(node); + }, true); +} + +// Load a given url in a given browser and fires the callback when it is loaded +function loadAndWait(browser, url, callback) { + listenOnce(browser, "load", callback); + // We have to wait before calling `loadURI` otherwise, if we call + // `loadAndWait` during browser load event, the history will be broken + setTimeout(function () { + browser.loadURI(url); + }, 0); +} + +// Returns a test function that will automatically open a new chrome window +// with a <browser> element loaded on a given content URL +// The callback receive 3 arguments: +// - test: reference to the jetpack test object +// - browser: a reference to the <browser> xul node +// - done: a callback to call when test is over +function WorkerTest(url, callback) { + return function testFunction(assert, done) { + let chromeWindow = makeWindow(); + chromeWindow.addEventListener("load", function onload() { + chromeWindow.removeEventListener("load", onload, true); + let browser = chromeWindow.document.createElement("browser"); + browser.setAttribute("type", "content"); + chromeWindow.document.documentElement.appendChild(browser); + // Wait for about:blank load event ... + listenOnce(browser, "load", function onAboutBlankLoad() { + // ... before loading the expected doc and waiting for its load event + loadAndWait(browser, url, function onDocumentLoaded() { + callback(assert, browser, function onTestDone() { + + close(chromeWindow).then(done); + }); + }); + }); + }, true); + }; +} + +exports["test:sample"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + assert.notEqual(browser.contentWindow.location.href, "about:blank", + "window is now on the right document"); + + let window = browser.contentWindow + let worker = Worker({ + window: window, + contentScript: "new " + function WorkerScope() { + // window is accessible + let myLocation = window.location.toString(); + self.on("message", function(data) { + if (data == "hi!") + self.postMessage("bye!"); + }); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + assert.equal("bye!", msg); + assert.equal(worker.url, window.location.href, + "worker.url still works"); + done(); + } + }); + + assert.equal(worker.url, window.location.href, + "worker.url works"); + assert.equal(worker.contentURL, window.location.href, + "worker.contentURL works"); + worker.postMessage("hi!"); + } +); + +exports["test:emit"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + // Validate self.on and self.emit + self.port.on("addon-to-content", function (data) { + self.port.emit("content-to-addon", data); + }); + + // Check for global pollution + //if (typeof on != "undefined") + // self.postMessage("`on` is in globals"); + if (typeof once != "undefined") + self.postMessage("`once` is in globals"); + if (typeof emit != "undefined") + self.postMessage("`emit` is in globals"); + + }, + onMessage: function(msg) { + assert.fail("Got an unexpected message : "+msg); + } + }); + + // Validate worker.port + worker.port.on("content-to-addon", function (data) { + assert.equal(data, "event data"); + done(); + }); + worker.port.emit("addon-to-content", "event data"); + } +); + +exports["test:emit hack message"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + // Validate self.port + self.port.on("message", function (data) { + self.port.emit("message", data); + }); + // We should not receive message on self, but only on self.port + self.on("message", function (data) { + self.postMessage("message", data); + }); + }, + onError: function(e) { + assert.fail("Got exception: "+e); + } + }); + + worker.port.on("message", function (data) { + assert.equal(data, "event data"); + done(); + }); + worker.on("message", function (data) { + assert.fail("Got an unexpected message : "+msg); + }); + worker.port.emit("message", "event data"); + } +); + +exports["test:n-arguments emit"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let repeat = 0; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + // Validate self.on and self.emit + self.port.on("addon-to-content", function (a1, a2, a3) { + self.port.emit("content-to-addon", a1, a2, a3); + }); + } + }); + + // Validate worker.port + worker.port.on("content-to-addon", function (arg1, arg2, arg3) { + if (!repeat++) { + this.emit("addon-to-content", "first argument", "second", "third"); + } else { + assert.equal(arg1, "first argument"); + assert.equal(arg2, "second"); + assert.equal(arg3, "third"); + done(); + } + }); + worker.port.emit("addon-to-content", "first argument", "second", "third"); + } +); + +exports["test:post-json-values-only"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.on("message", function (message) { + self.postMessage([ message.fun === undefined, + typeof message.w, + message.w && "port" in message.w, + message.w._url, + Array.isArray(message.array), + JSON.stringify(message.array)]); + }); + } + }); + + // Validate worker.onMessage + let array = [1, 2, 3]; + worker.on("message", function (message) { + assert.ok(message[0], "function becomes undefined"); + assert.equal(message[1], "object", "object stays object"); + assert.ok(message[2], "object's attributes are enumerable"); + assert.equal(message[3], DEFAULT_CONTENT_URL, + "jsonable attributes are accessible"); + // See bug 714891, Arrays may be broken over compartements: + assert.ok(message[4], "Array keeps being an array"); + assert.equal(message[5], JSON.stringify(array), + "Array is correctly serialized"); + done(); + }); + // Add a new url property sa the Class function used by + // Worker doesn't set enumerables to true for non-functions + worker._url = DEFAULT_CONTENT_URL; + + worker.postMessage({ fun: function () {}, w: worker, array: array }); + } +); + +exports["test:emit-json-values-only"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + // Validate self.on and self.emit + self.port.on("addon-to-content", function (fun, w, obj, array) { + self.port.emit("content-to-addon", [ + fun === null, + typeof w, + "port" in w, + w._url, + "fun" in obj, + Object.keys(obj.dom).length, + Array.isArray(array), + JSON.stringify(array) + ]); + }); + } + }); + + // Validate worker.port + let array = [1, 2, 3]; + worker.port.on("content-to-addon", function (result) { + assert.ok(result[0], "functions become null"); + assert.equal(result[1], "object", "objects stay objects"); + assert.ok(result[2], "object's attributes are enumerable"); + assert.equal(result[3], DEFAULT_CONTENT_URL, + "json attribute is accessible"); + assert.ok(!result[4], "function as object attribute is removed"); + assert.equal(result[5], 0, "DOM nodes are converted into empty object"); + // See bug 714891, Arrays may be broken over compartments: + assert.ok(result[6], "Array keeps being an array"); + assert.equal(result[7], JSON.stringify(array), + "Array is correctly serialized"); + done(); + }); + + let obj = { + fun: function () {}, + dom: browser.contentWindow.document.createElement("div") + }; + // Add a new url property sa the Class function used by + // Worker doesn't set enumerables to true for non-functions + worker._url = DEFAULT_CONTENT_URL; + worker.port.emit("addon-to-content", function () {}, worker, obj, array); + } +); + +exports["test:content is wrapped"] = WorkerTest( + "data:text/html;charset=utf-8,<script>var documentValue=true;</script>", + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.postMessage(!window.documentValue); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + assert.ok(msg, + "content script has a wrapped access to content document"); + done(); + } + }); + } +); + +// ContentWorker is not for chrome +/* +exports["test:chrome is unwrapped"] = function(assert, done) { + let window = makeWindow(); + + listenOnce(window, "load", function onload() { + + let worker = Worker({ + window: window, + contentScript: "new " + function WorkerScope() { + self.postMessage(window.documentValue); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + assert.ok(msg, + "content script has an unwrapped access to chrome document"); + close(window).then(done); + } + }); + + }); +} +*/ + +exports["test:nothing is leaked to content script"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.postMessage([ + "ContentWorker" in window, + "UNWRAP_ACCESS_KEY" in window, + "getProxyForObject" in window + ]); + }, + contentScriptWhen: "ready", + onMessage: function(list) { + assert.ok(!list[0], "worker API contrustor isn't leaked"); + assert.ok(!list[1], "Proxy API stuff isn't leaked 1/2"); + assert.ok(!list[2], "Proxy API stuff isn't leaked 2/2"); + done(); + } + }); + } +); + +exports["test:ensure console.xxx works in cs"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + const EXPECTED = ["time", "log", "info", "warn", "error", "error", "timeEnd"]; + + let calls = []; + let levels = []; + + system.on('console-api-log-event', onMessage); + + function onMessage({ subject }) { + calls.push(subject.wrappedJSObject.arguments[0]); + levels.push(subject.wrappedJSObject.level); + } + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + console.time("time"); + console.log("log"); + console.info("info"); + console.warn("warn"); + console.error("error"); + console.debug("debug"); + console.exception("error"); + console.timeEnd("timeEnd"); + self.postMessage(); + }, + onMessage: function() { + system.off('console-api-log-event', onMessage); + + assert.equal(JSON.stringify(calls), + JSON.stringify(EXPECTED), + "console methods have been called successfully, in expected order"); + + assert.equal(JSON.stringify(levels), + JSON.stringify(EXPECTED), + "console messages have correct log levels, in expected order"); + + done(); + } + }); + } +); + +exports["test:setTimeout works with string argument"] = WorkerTest( + "data:text/html;charset=utf-8,<script>var docVal=5;</script>", + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function ContentScriptScope() { + // must use "window.scVal" instead of "var csVal" + // since we are inside ContentScriptScope function. + // i'm NOT putting code-in-string inside code-in-string </YO DAWG> + window.csVal = 13; + setTimeout("self.postMessage([" + + "csVal, " + + "window.docVal, " + + "'ContentWorker' in window, " + + "'UNWRAP_ACCESS_KEY' in window, " + + "'getProxyForObject' in window, " + + "])", 1); + }, + contentScriptWhen: "ready", + onMessage: function([csVal, docVal, chrome1, chrome2, chrome3]) { + // test timer code is executed in the correct context + assert.equal(csVal, 13, "accessing content-script values"); + assert.notEqual(docVal, 5, "can't access document values (directly)"); + assert.ok(!chrome1 && !chrome2 && !chrome3, "nothing is leaked from chrome"); + done(); + } + }); + } +); + +exports["test:setInterval works with string argument"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let count = 0; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "setInterval('self.postMessage(1)', 50)", + contentScriptWhen: "ready", + onMessage: function(one) { + count++; + assert.equal(one, 1, "got " + count + " message(s) from setInterval"); + if (count >= 3) done(); + } + }); + } +); + +exports["test:setInterval async Errors passed to .onError"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let count = 0; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "setInterval(() => { throw Error('ubik') }, 50)", + contentScriptWhen: "ready", + onError: function(err) { + count++; + assert.equal(err.message, "ubik", + "error (correctly) propagated " + count + " time(s)"); + if (count >= 3) done(); + } + }); + } +); + +exports["test:setTimeout throws array, passed to .onError"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "setTimeout(function() { throw ['array', 42] }, 1)", + contentScriptWhen: "ready", + onError: function(arr) { + assert.ok(isArray(arr), + "the type of thrown/propagated object is array"); + assert.ok(arr.length==2, + "the propagated thrown array is the right length"); + assert.equal(arr[1], 42, + "element inside the thrown array correctly propagated"); + done(); + } + }); + } +); + +exports["test:setTimeout string arg with SyntaxError to .onError"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "setTimeout('syntax 123 error', 1)", + contentScriptWhen: "ready", + onError: function(err) { + assert.equal(err.name, "SyntaxError", + "received SyntaxError thrown from bad code in string argument to setTimeout"); + assert.ok('fileName' in err, + "propagated SyntaxError contains a fileName property"); + assert.ok('stack' in err, + "propagated SyntaxError contains a stack property"); + assert.equal(err.message, "missing ; before statement", + "propagated SyntaxError has the correct (helpful) message"); + assert.equal(err.lineNumber, 1, + "propagated SyntaxError was thrown on the right lineNumber"); + done(); + } + }); + } +); + +exports["test:setTimeout can't be cancelled by content"] = WorkerTest( + "data:text/html;charset=utf-8,<script>var documentValue=true;</script>", + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + let id = setTimeout(function () { + self.postMessage("timeout"); + }, 100); + unsafeWindow.eval("clearTimeout("+id+");"); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + assert.ok(msg, + "content didn't managed to cancel our setTimeout"); + done(); + } + }); + } +); + +exports["test:clearTimeout"] = WorkerTest( + "data:text/html;charset=utf-8,clear timeout", + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + let id1 = setTimeout(function() { + self.postMessage("failed"); + }, 10); + let id2 = setTimeout(function() { + self.postMessage("done"); + }, 100); + clearTimeout(id1); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + if (msg === "failed") { + assert.fail("failed to cancel timer"); + } else { + assert.pass("timer cancelled"); + done(); + } + } + }); + } +); + +exports["test:clearInterval"] = WorkerTest( + "data:text/html;charset=utf-8,clear timeout", + function(assert, browser, done) { + let called = 0; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + let id = setInterval(function() { + self.postMessage("intreval") + clearInterval(id) + setTimeout(function() { + self.postMessage("done") + }, 100) + }, 10); + }, + contentScriptWhen: "ready", + onMessage: function(msg) { + if (msg === "intreval") { + called = called + 1; + if (called > 1) assert.fail("failed to cancel timer"); + } else { + assert.pass("interval cancelled"); + done(); + } + } + }); + } +) + +exports["test:setTimeout are unregistered on content unload"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let originalWindow = browser.contentWindow; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + document.title = "ok"; + let i = 0; + setInterval(function () { + document.title = i++; + }, 10); + }, + contentScriptWhen: "ready" + }); + + // Change location so that content script is destroyed, + // and all setTimeout/setInterval should be unregistered. + // Wait some cycles in order to execute some intervals. + setTimeout(function () { + // Bug 689621: Wait for the new document load so that we are sure that + // previous document cancelled its intervals + let url2 = "data:text/html;charset=utf-8,<title>final</title>"; + loadAndWait(browser, url2, function onload() { + let titleAfterLoad = originalWindow.document.title; + // Wait additional cycles to verify that intervals are really cancelled + setTimeout(function () { + assert.equal(browser.contentDocument.title, "final", + "New document has not been modified"); + assert.equal(originalWindow.document.title, titleAfterLoad, + "Nor previous one"); + + done(); + }, 100); + }); + }, 100); + } +); + +exports['test:check window attribute in iframes'] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + // Create a first iframe and wait for its loading + let contentWin = browser.contentWindow; + let contentDoc = contentWin.document; + let iframe = contentDoc.createElement("iframe"); + contentDoc.body.appendChild(iframe); + + listenOnce(iframe, "load", function onload() { + + // Create a second iframe inside the first one and wait for its loading + let iframeDoc = iframe.contentWindow.document; + let subIframe = iframeDoc.createElement("iframe"); + iframeDoc.body.appendChild(subIframe); + + listenOnce(subIframe, "load", function onload() { + subIframe.removeEventListener("load", onload, true); + + // And finally create a worker against this second iframe + let worker = Worker({ + window: subIframe.contentWindow, + contentScript: 'new ' + function WorkerScope() { + self.postMessage([ + window.top !== window, + frameElement, + window.parent !== window, + top.location.href, + parent.location.href, + ]); + }, + onMessage: function(msg) { + assert.ok(msg[0], "window.top != window"); + assert.ok(msg[1], "window.frameElement is defined"); + assert.ok(msg[2], "window.parent != window"); + assert.equal(msg[3], contentWin.location.href, + "top.location refers to the toplevel content doc"); + assert.equal(msg[4], iframe.contentWindow.location.href, + "parent.location refers to the first iframe doc"); + done(); + } + }); + + }); + subIframe.setAttribute("src", "data:text/html;charset=utf-8,bar"); + + }); + iframe.setAttribute("src", "data:text/html;charset=utf-8,foo"); + } +); + +exports['test:check window attribute in toplevel documents'] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let worker = Worker({ + window: browser.contentWindow, + contentScript: 'new ' + function WorkerScope() { + self.postMessage([ + window.top === window, + frameElement, + window.parent === window + ]); + }, + onMessage: function(msg) { + assert.ok(msg[0], "window.top == window"); + assert.ok(!msg[1], "window.frameElement is null"); + assert.ok(msg[2], "window.parent == window"); + done(); + } + }); + } +); + +exports["test:check worker API with page history"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let url2 = "data:text/html;charset=utf-8,bar"; + + loadAndWait(browser, url2, function () { + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + // Just before the content script is disable, we register a timeout + // that will be disable until the page gets visible again + self.on("pagehide", function () { + setTimeout(function () { + self.port.emit("timeout"); + }, 0); + }); + + self.on("message", function() { + self.postMessage("saw message"); + }); + + self.on("event", function() { + self.port.emit("event", "saw event"); + }); + }, + contentScriptWhen: "start" + }); + + // postMessage works correctly when the page is visible + worker.postMessage("ok"); + + // We have to wait before going back into history, + // otherwise `goBack` won't do anything. + setTimeout(function () { + browser.goBack(); + }, 0); + + // Wait for the document to be hidden + browser.addEventListener("pagehide", function onpagehide() { + browser.removeEventListener("pagehide", onpagehide, false); + // Now any event sent to this worker should be cached + + worker.postMessage("message"); + worker.port.emit("event"); + + // Display the page with attached content script back in order to resume + // its timeout and receive the expected message. + // We have to delay this in order to not break the history. + // We delay for a non-zero amount of time in order to ensure that we + // do not receive the message immediatly, so that the timeout is + // actually disabled + setTimeout(function () { + worker.on("pageshow", function() { + let promise = Promise.all([ + new Promise(resolve => { + worker.port.on("event", () => { + assert.pass("Saw event"); + resolve(); + }); + }), + new Promise(resolve => { + worker.on("message", () => { + assert.pass("Saw message"); + resolve(); + }); + }), + new Promise(resolve => { + worker.port.on("timeout", () => { + assert.pass("Timer fired"); + resolve(); + }); + }) + ]); + promise.then(done); + }); + + browser.goForward(); + }, 500); + + }, false); + }); + + } +); + +exports['test:conentScriptFile as URL instance'] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + + let url = new URL(fixtures.url("test-contentScriptFile.js")); + let worker = Worker({ + window: browser.contentWindow, + contentScriptFile: url, + onMessage: function(msg) { + assert.equal(msg, "msg from contentScriptFile", + "received a wrong message from contentScriptFile"); + done(); + } + }); + } +); + +exports["test:worker events"] = WorkerTest( + DEFAULT_CONTENT_URL, + function (assert, browser, done) { + let window = browser.contentWindow; + let events = []; + let worker = Worker({ + window: window, + contentScript: 'new ' + function WorkerScope() { + self.postMessage('start'); + }, + onAttach: win => { + events.push('attach'); + assert.pass('attach event called when attached'); + assert.equal(window, win, 'attach event passes in attached window'); + }, + onError: err => { + assert.equal(err.message, 'Custom', + 'Error passed into error event'); + worker.detach(); + }, + onMessage: msg => { + assert.pass('`onMessage` handles postMessage') + throw new Error('Custom'); + }, + onDetach: _ => { + assert.pass('`onDetach` called when worker detached'); + done(); + } + }); + // `attach` event is called synchronously during instantiation, + // so we can't listen to that, TODO FIX? + // worker.on('attach', obj => console.log('attach', obj)); + } +); + +exports["test:onDetach in contentScript on destroy"] = WorkerTest( + "data:text/html;charset=utf-8,foo#detach", + function(assert, browser, done) { + let worker = Worker({ + window: browser.contentWindow, + contentScript: 'new ' + function WorkerScope() { + self.port.on('detach', function(reason) { + window.location.hash += '!' + reason; + }) + }, + }); + browser.contentWindow.addEventListener('hashchange', _ => { + assert.equal(browser.contentWindow.location.hash, '#detach!', + "location.href is as expected"); + done(); + }) + worker.destroy(); + } +); + +exports["test:onDetach in contentScript on unload"] = WorkerTest( + "data:text/html;charset=utf-8,foo#detach", + function(assert, browser, done) { + let { loader } = LoaderWithHookedConsole(module); + let worker = loader.require("sdk/content/worker").Worker({ + window: browser.contentWindow, + contentScript: 'new ' + function WorkerScope() { + self.port.on('detach', function(reason) { + window.location.hash += '!' + reason; + }) + }, + }); + browser.contentWindow.addEventListener('hashchange', _ => { + assert.equal(browser.contentWindow.location.hash, '#detach!shutdown', + "location.href is as expected"); + done(); + }) + loader.unload('shutdown'); + } +); + +exports["test:console method log functions properly"] = WorkerTest( + DEFAULT_CONTENT_URL, + function(assert, browser, done) { + let logs = []; + + system.on('console-api-log-event', onMessage); + + function onMessage({ subject }) { + logs.push(clean(subject.wrappedJSObject.arguments[0])); + } + + let clean = message => + message.trim(). + replace(/[\r\n]/g, " "). + replace(/ +/g, " "); + + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + console.log(Function); + console.log((foo) => foo * foo); + console.log(function foo(bar) { return bar + bar }); + + self.postMessage(); + }, + onMessage: () => { + system.off('console-api-log-event', onMessage); + + assert.deepEqual(logs, [ + "function Function() { [native code] }", + "(foo) => foo * foo", + "function foo(bar) { \"use strict\"; return bar + bar }" + ]); + + done(); + } + }); + } +); + +exports["test:global postMessage"] = WorkerTest( + WINDOW_SCRIPT_URL, + function(assert, browser, done) { + let contentScript = "window.addEventListener('message', function (e) {" + + " if (e.data === 'from -> window')" + + " self.port.emit('response', e.data, e.origin);" + + "});" + + "postMessage('from -> content-script', '*');"; + let { loader } = LoaderWithHookedConsole(module); + let worker = loader.require("sdk/content/worker").Worker({ + window: browser.contentWindow, + contentScriptWhen: "ready", + contentScript: contentScript + }); + + worker.port.on("response", (data, origin) => { + assert.equal(data, "from -> window", "Communication from content-script to window completed"); + done(); + }); +}); + +exports["test:destroy unbinds listeners from port"] = WorkerTest( + "data:text/html;charset=utf-8,portdestroyer", + function(assert, browser, done) { + let destroyed = false; + let worker = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.port.emit("destroy"); + setInterval(self.port.emit, 10, "ping"); + }, + onDestroy: done + }); + worker.port.on("ping", () => { + if (destroyed) { + assert.fail("Should not call events on port after destroy."); + } + }); + worker.port.on("destroy", () => { + destroyed = true; + worker.destroy(); + assert.pass("Worker destroyed, waiting for no future listeners handling events."); + setTimeout(done, 500); + }); + } +); + + +exports["test:destroy kills child worker"] = WorkerTest( + "data:text/html;charset=utf-8,<html><body><p id='detail'></p></body></html>", + function(assert, browser, done) { + let worker1 = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.port.on("ping", detail => { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("Test:Ping", true, true, detail); + document.dispatchEvent(event); + self.port.emit("pingsent"); + }); + + let listener = function(event) { + self.port.emit("pong", event.detail); + }; + + self.port.on("detach", () => { + window.removeEventListener("Test:Pong", listener); + }); + window.addEventListener("Test:Pong", listener); + }, + onAttach: function() { + let worker2 = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + let listener = function(event) { + let newEvent = document.createEvent("CustomEvent"); + newEvent.initCustomEvent("Test:Pong", true, true, event.detail); + document.dispatchEvent(newEvent); + }; + self.port.on("detach", () => { + window.removeEventListener("Test:Ping", listener); + }) + window.addEventListener("Test:Ping", listener); + self.postMessage(); + }, + onMessage: function() { + worker1.port.emit("ping", "test1"); + worker1.port.once("pong", detail => { + assert.equal(detail, "test1", "Saw the right message"); + worker1.port.once("pingsent", () => { + assert.pass("The message was sent"); + + worker2.destroy(); + + worker1.port.emit("ping", "test2"); + worker1.port.once("pong", detail => { + assert.fail("worker2 shouldn't have responded"); + }) + worker1.port.once("pingsent", () => { + assert.pass("The message was sent"); + worker1.destroy(); + done(); + }); + }); + }) + } + }); + } + }); + } +); + +exports["test:unload kills child worker"] = WorkerTest( + "data:text/html;charset=utf-8,<html><body><p id='detail'></p></body></html>", + function(assert, browser, done) { + let loader = Loader(module); + let worker1 = Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + self.port.on("ping", detail => { + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("Test:Ping", true, true, detail); + document.dispatchEvent(event); + self.port.emit("pingsent"); + }); + + let listener = function(event) { + self.port.emit("pong", event.detail); + }; + + self.port.on("detach", () => { + window.removeEventListener("Test:Pong", listener); + }); + window.addEventListener("Test:Pong", listener); + }, + onAttach: function() { + let worker2 = loader.require("sdk/content/worker").Worker({ + window: browser.contentWindow, + contentScript: "new " + function WorkerScope() { + let listener = function(event) { + let newEvent = document.createEvent("CustomEvent"); + newEvent.initCustomEvent("Test:Pong", true, true, event.detail); + document.dispatchEvent(newEvent); + }; + self.port.on("detach", () => { + window.removeEventListener("Test:Ping", listener); + }) + window.addEventListener("Test:Ping", listener); + self.postMessage(); + }, + onMessage: function() { + worker1.port.emit("ping", "test1"); + worker1.port.once("pong", detail => { + assert.equal(detail, "test1", "Saw the right message"); + worker1.port.once("pingsent", () => { + assert.pass("The message was sent"); + + loader.unload(); + + worker1.port.emit("ping", "test2"); + worker1.port.once("pong", detail => { + assert.fail("worker2 shouldn't have responded"); + }) + worker1.port.once("pingsent", () => { + assert.pass("The message was sent"); + worker1.destroy(); + done(); + }); + }); + }) + } + }); + } + }); + } +); + +// require("sdk/test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-content/lib/test-page-worker.js b/addon-sdk/source/test/addons/e10s-content/lib/test-page-worker.js new file mode 100644 index 000000000..3a9106ec0 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/lib/test-page-worker.js @@ -0,0 +1,524 @@ +/* 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/. */ +"use strict"; + +const { Loader } = require('sdk/test/loader'); +const { Page } = require("sdk/page-worker"); +const { URL } = require("sdk/url"); +const fixtures = require("./fixtures"); +const testURI = fixtures.url("test.html"); + +const ERR_DESTROYED = + "Couldn't find the worker to receive this message. " + + "The script may not be initialized yet, or may already have been unloaded."; + +const Isolate = fn => "(" + fn + ")()"; + +exports.testSimplePageCreation = function(assert, done) { + let page = new Page({ + contentScript: "self.postMessage(window.location.href)", + contentScriptWhen: "end", + onMessage: function (message) { + assert.equal(message, "about:blank", + "Page Worker should start with a blank page by default"); + assert.equal(this, page, "The 'this' object is the page itself."); + done(); + } + }); +} + +/* + * Tests that we can't be tricked by document overloads as we have access + * to wrapped nodes + */ +exports.testWrappedDOM = function(assert, done) { + let page = Page({ + allow: { script: true }, + contentURL: "data:text/html;charset=utf-8,<script>document.getElementById=3;window.scrollTo=3;</script>", + contentScript: 'new ' + function() { + function send() { + self.postMessage([typeof(document.getElementById), typeof(window.scrollTo)]); + } + if (document.readyState !== 'complete') + window.addEventListener('load', send, true) + else + send(); + }, + onMessage: function (message) { + assert.equal(message[0], + "function", + "getElementById from content script is the native one"); + + assert.equal(message[1], + "function", + "scrollTo from content script is the native one"); + + done(); + } + }); +} + +/* +// We do not offer unwrapped access to DOM since bug 601295 landed +// See 660780 to track progress of unwrap feature +exports.testUnwrappedDOM = function(assert, done) { + let page = Page({ + allow: { script: true }, + contentURL: "data:text/html;charset=utf-8,<script>document.getElementById=3;window.scrollTo=3;</script>", + contentScript: "window.addEventListener('load', function () { " + + "return self.postMessage([typeof(unsafeWindow.document.getElementById), " + + "typeof(unsafeWindow.scrollTo)]); }, true)", + onMessage: function (message) { + assert.equal(message[0], + "number", + "document inside page is free to be changed"); + + assert.equal(message[1], + "number", + "window inside page is free to be changed"); + + done(); + } + }); +} +*/ + +exports.testPageProperties = function(assert) { + let page = new Page(); + + for (let prop of ['contentURL', 'allow', 'contentScriptFile', + 'contentScript', 'contentScriptWhen', 'on', + 'postMessage', 'removeListener']) { + assert.ok(prop in page, prop + " property is defined on page."); + } + + assert.ok(() => page.postMessage("foo") || true, + "postMessage doesn't throw exception on page."); +} + +exports.testConstructorAndDestructor = function(assert, done) { + let loader = Loader(module); + let { Page } = loader.require("sdk/page-worker"); + let global = loader.sandbox("sdk/page-worker"); + + let pagesReady = 0; + + let page1 = Page({ + contentScript: "self.postMessage('')", + contentScriptWhen: "end", + onMessage: pageReady + }); + let page2 = Page({ + contentScript: "self.postMessage('')", + contentScriptWhen: "end", + onMessage: pageReady + }); + + assert.notEqual(page1, page2, + "Page 1 and page 2 should be different objects."); + + function pageReady() { + if (++pagesReady == 2) { + page1.destroy(); + page2.destroy(); + + assert.ok(isDestroyed(page1), "page1 correctly unloaded."); + assert.ok(isDestroyed(page2), "page2 correctly unloaded."); + + loader.unload(); + done(); + } + } +} + +exports.testAutoDestructor = function(assert, done) { + let loader = Loader(module); + let { Page } = loader.require("sdk/page-worker"); + + let page = Page({ + contentScript: "self.postMessage('')", + contentScriptWhen: "end", + onMessage: function() { + loader.unload(); + assert.ok(isDestroyed(page), "Page correctly unloaded."); + done(); + } + }); +} + +exports.testValidateOptions = function(assert) { + assert.throws( + () => Page({ contentURL: 'home' }), + /The `contentURL` option must be a valid URL\./, + "Validation correctly denied a non-URL contentURL" + ); + + assert.throws( + () => Page({ onMessage: "This is not a function."}), + /The option "onMessage" must be one of the following types: function/, + "Validation correctly denied a non-function onMessage." + ); + + assert.pass("Options validation is working."); +} + +exports.testContentAndAllowGettersAndSetters = function(assert, done) { + let content = "data:text/html;charset=utf-8,<script>window.localStorage.allowScript=3;</script>"; + + // Load up the page with testURI initially for the resource:// principal, + // then load the actual data:* content, as data:* URIs no longer + // have localStorage + let page = Page({ + contentURL: testURI, + contentScript: "if (window.location.href==='"+testURI+"')" + + " self.postMessage('reload');" + + "else " + + " self.postMessage(window.localStorage.allowScript)", + contentScriptWhen: "end", + onMessage: step0 + }); + + function step0(message) { + if (message === 'reload') + return page.contentURL = content; + assert.equal(message, "3", + "Correct value expected for allowScript - 3"); + assert.equal(page.contentURL, content, + "Correct content expected"); + page.removeListener('message', step0); + page.on('message', step1); + page.allow = { script: false }; + page.contentURL = content = + "data:text/html;charset=utf-8,<script>window.localStorage.allowScript='f'</script>"; + } + + function step1(message) { + assert.equal(message, "3", + "Correct value expected for allowScript - 3"); + assert.equal(page.contentURL, content, "Correct content expected"); + page.removeListener('message', step1); + page.on('message', step2); + page.allow = { script: true }; + page.contentURL = content = + "data:text/html;charset=utf-8,<script>window.localStorage.allowScript='g'</script>"; + } + + function step2(message) { + assert.equal(message, "g", + "Correct value expected for allowScript - g"); + assert.equal(page.contentURL, content, "Correct content expected"); + page.removeListener('message', step2); + page.on('message', step3); + page.allow.script = false; + page.contentURL = content = + "data:text/html;charset=utf-8,<script>window.localStorage.allowScript=3</script>"; + } + + function step3(message) { + assert.equal(message, "g", + "Correct value expected for allowScript - g"); + assert.equal(page.contentURL, content, "Correct content expected"); + page.removeListener('message', step3); + page.on('message', step4); + page.allow.script = true; + page.contentURL = content = + "data:text/html;charset=utf-8,<script>window.localStorage.allowScript=4</script>"; + } + + function step4(message) { + assert.equal(message, "4", + "Correct value expected for allowScript - 4"); + assert.equal(page.contentURL, content, "Correct content expected"); + done(); + } + +} + +exports.testOnMessageCallback = function(assert, done) { + Page({ + contentScript: "self.postMessage('')", + contentScriptWhen: "end", + onMessage: function() { + assert.pass("onMessage callback called"); + done(); + } + }); +} + +exports.testMultipleOnMessageCallbacks = function(assert, done) { + let count = 0; + let page = Page({ + contentScript: "self.postMessage('')", + contentScriptWhen: "end", + onMessage: () => count += 1 + }); + page.on('message', () => count += 2); + page.on('message', () => count *= 3); + page.on('message', () => + assert.equal(count, 9, "All callbacks were called, in order.")); + page.on('message', done); +}; + +exports.testLoadContentPage = function(assert, done) { + let page = Page({ + onMessage: function(message) { + // The message is an array whose first item is the test method to call + // and the rest of whose items are arguments to pass it. + let msg = message.shift(); + if (msg == "done") + return done(); + assert[msg].apply(assert, message); + }, + contentURL: fixtures.url("test-page-worker.html"), + contentScriptFile: fixtures.url("test-page-worker.js"), + contentScriptWhen: "ready" + }); +} + +exports.testLoadContentPageRelativePath = function(assert, done) { + let page = require("sdk/page-worker").Page({ + onMessage: function(message) { + // The message is an array whose first item is the test method to call + // and the rest of whose items are arguments to pass it. + let msg = message.shift(); + if (msg == "done") + return done(); + assert[msg].apply(assert, message); + }, + contentURL: "./test-page-worker.html", + contentScriptFile: "./test-page-worker.js", + contentScriptWhen: "ready" + }); +} + +exports.testAllowScriptDefault = function(assert, done) { + let page = Page({ + onMessage: function(message) { + assert.ok(message, "Script is allowed to run by default."); + done(); + }, + contentURL: "data:text/html;charset=utf-8,<script>document.documentElement.setAttribute('foo', 3);</script>", + contentScript: "self.postMessage(document.documentElement.getAttribute('foo'))", + contentScriptWhen: "ready" + }); +} + +exports.testAllowScript = function(assert, done) { + let page = Page({ + onMessage: function(message) { + assert.ok(message, "Script runs when allowed to do so."); + done(); + }, + allow: { script: true }, + contentURL: "data:text/html;charset=utf-8,<script>document.documentElement.setAttribute('foo', 3);</script>", + contentScript: "self.postMessage(document.documentElement.hasAttribute('foo') && " + + " document.documentElement.getAttribute('foo') == 3)", + contentScriptWhen: "ready" + }); +} + +exports.testPingPong = function(assert, done) { + let page = Page({ + contentURL: 'data:text/html;charset=utf-8,ping-pong', + contentScript: 'self.on("message", function(message) { return self.postMessage("pong"); });' + + 'self.postMessage("ready");', + onMessage: function(message) { + if ('ready' == message) { + page.postMessage('ping'); + } + else { + assert.ok(message, 'pong', 'Callback from contentScript'); + done(); + } + } + }); +}; + +exports.testRedirect = function (assert, done) { + let page = Page({ + contentURL: 'data:text/html;charset=utf-8,first-page', + contentScriptWhen: "end", + contentScript: '' + + 'if (/first-page/.test(document.location.href)) ' + + ' document.location.href = "data:text/html;charset=utf-8,redirect";' + + 'else ' + + ' self.port.emit("redirect", document.location.href);' + }); + + page.port.on('redirect', function (url) { + assert.equal(url, 'data:text/html;charset=utf-8,redirect', 'Reinjects contentScript on reload'); + done(); + }); +}; + +exports.testRedirectIncludeArrays = function (assert, done) { + let firstURL = 'data:text/html;charset=utf-8,first-page'; + let page = Page({ + contentURL: firstURL, + contentScript: '(function () {' + + 'self.port.emit("load", document.location.href);' + + ' self.port.on("redirect", function (url) {' + + ' document.location.href = url;' + + ' })' + + '})();', + include: ['about:blank', 'data:*'] + }); + + page.port.on('load', function (url) { + if (url === firstURL) { + page.port.emit('redirect', 'about:blank'); + } else if (url === 'about:blank') { + page.port.emit('redirect', 'about:mozilla'); + assert.ok('`include` property handles arrays'); + assert.equal(url, 'about:blank', 'Redirects work with accepted domains'); + done(); + } else if (url === 'about:mozilla') { + assert.fail('Should not redirect to restricted domain'); + } + }); +}; + +exports.testRedirectFromWorker = function (assert, done) { + let firstURL = 'data:text/html;charset=utf-8,first-page'; + let secondURL = 'data:text/html;charset=utf-8,second-page'; + let thirdURL = 'data:text/html;charset=utf-8,third-page'; + let page = Page({ + contentURL: firstURL, + contentScript: '(function () {' + + 'self.port.emit("load", document.location.href);' + + ' self.port.on("redirect", function (url) {' + + ' document.location.href = url;' + + ' })' + + '})();', + include: 'data:*' + }); + + page.port.on('load', function (url) { + if (url === firstURL) { + page.port.emit('redirect', secondURL); + } else if (url === secondURL) { + page.port.emit('redirect', thirdURL); + } else if (url === thirdURL) { + page.port.emit('redirect', 'about:mozilla'); + assert.equal(url, thirdURL, 'Redirects work with accepted domains on include strings'); + done(); + } else { + assert.fail('Should not redirect to unauthorized domains'); + } + }); +}; + +exports.testRedirectWithContentURL = function (assert, done) { + let firstURL = 'data:text/html;charset=utf-8,first-page'; + let secondURL = 'data:text/html;charset=utf-8,second-page'; + let thirdURL = 'data:text/html;charset=utf-8,third-page'; + let page = Page({ + contentURL: firstURL, + contentScript: '(function () {' + + 'self.port.emit("load", document.location.href);' + + '})();', + include: 'data:*' + }); + + page.port.on('load', function (url) { + if (url === firstURL) { + page.contentURL = secondURL; + } else if (url === secondURL) { + page.contentURL = thirdURL; + } else if (url === thirdURL) { + page.contentURL = 'about:mozilla'; + assert.equal(url, thirdURL, 'Redirects work with accepted domains on include strings'); + done(); + } else { + assert.fail('Should not redirect to unauthorized domains'); + } + }); +}; + + +exports.testMultipleDestroys = function(assert) { + let page = Page(); + page.destroy(); + page.destroy(); + assert.pass("Multiple destroys should not cause an error"); +}; + +exports.testContentScriptOptionsOption = function(assert, done) { + let page = new Page({ + contentScript: "self.postMessage( [typeof self.options.d, self.options] );", + contentScriptWhen: "end", + contentScriptOptions: {a: true, b: [1,2,3], c: "string", d: function(){ return 'test'}}, + onMessage: function(msg) { + assert.equal(msg[0], 'undefined', 'functions are stripped from contentScriptOptions'); + assert.equal(typeof msg[1], 'object', 'object as contentScriptOptions'); + assert.equal(msg[1].a, true, 'boolean in contentScriptOptions'); + assert.equal(msg[1].b.join(), '1,2,3', 'array and numbers in contentScriptOptions'); + assert.equal(msg[1].c, 'string', 'string in contentScriptOptions'); + done(); + } + }); +}; + +exports.testMessageQueue = function (assert, done) { + let page = new Page({ + contentScript: 'self.on("message", function (m) {' + + 'self.postMessage(m);' + + '});', + contentURL: 'data:text/html;charset=utf-8,', + }); + page.postMessage('ping'); + page.on('message', function (m) { + assert.equal(m, 'ping', 'postMessage should queue messages'); + done(); + }); +}; + +exports.testWindowStopDontBreak = function (assert, done) { + const { Ci, Cc } = require('chrome'); + const consoleService = Cc['@mozilla.org/consoleservice;1']. + getService(Ci.nsIConsoleService); + const listener = { + observe: ({message}) => { + if (message.includes('contentWorker is null')) + assert.fail('contentWorker is null'); + } + }; + consoleService.registerListener(listener) + + let page = new Page({ + contentURL: 'data:text/html;charset=utf-8,testWindowStopDontBreak', + contentScriptWhen: 'ready', + contentScript: Isolate(() => { + window.stop(); + self.port.on('ping', () => self.port.emit('pong')); + }) + }); + + page.port.on('pong', () => { + assert.pass('page-worker works after window.stop'); + page.destroy(); + consoleService.unregisterListener(listener); + done(); + }); + + page.port.emit("ping"); +}; + + +function isDestroyed(page) { + try { + page.postMessage("foo"); + } + catch (err) { + if (err.message == ERR_DESTROYED) { + return true; + } + else { + throw err; + } + } + return false; +} + +// require("sdk/test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-content/package.json b/addon-sdk/source/test/addons/e10s-content/package.json new file mode 100644 index 000000000..c7cf6847a --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-content/package.json @@ -0,0 +1,11 @@ +{ + "name": "e10s-content", + "title": "e10s-content", + "id": "jid1-ZZaXFHAPlHDDD@jetpack", + "description": "run content worker tests in e10s mode", + "author": "Tomislav Jovanovic", + "license": "MPL-2.0", + "version": "0.1.0", + "main": "./lib/main.js", + "e10s": true +} diff --git a/addon-sdk/source/test/addons/e10s-l10n/data/test-localization.html b/addon-sdk/source/test/addons/e10s-l10n/data/test-localization.html new file mode 100644 index 000000000..5646946da --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/data/test-localization.html @@ -0,0 +1,29 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>HTML Localization</title> + </head> + <body> + <div data-l10n-id="Not translated">Kept as-is</div> + <ul data-l10n-id="Translated"> + <li>Inner html content is replaced,</li> + <li data-l10n-id="text-content"> + Elements with data-l10n-id attribute whose parent element is translated + will be replaced by the content of the translation. + </li> + </ul> + <div data-l10n-id="text-content">No</div> + <div data-l10n-id="Translated"> + A data-l10n-id value can be used in multiple elements + </div> + <a data-l10n-id="link-attributes" title="Certain whitelisted attributes get translated too" alt="No" accesskey="A"></a> + <input data-l10n-id="input" type="text" placeholder="Form placeholders are translateable"> + <menu> + <menuitem data-l10n-id="contextitem" label="Labels of select options and context menus are translateable"> + </menu> + </body> +</html diff --git a/addon-sdk/source/test/addons/e10s-l10n/locale/en.properties b/addon-sdk/source/test/addons/e10s-l10n/locale/en.properties new file mode 100644 index 000000000..c9e53ecb3 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/locale/en.properties @@ -0,0 +1,38 @@ +# 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/. + +Translated= Yes + +text-content=no <b>HTML</b> injection + +downloadsCount=%d downloads +downloadsCount[one]=one download + +pluralTest=fallback to other +pluralTest[zero]=optional zero form + +explicitPlural[one]=one +explicitPlural[other]=other + +# You can use unicode char escaping in order to inject space at the beginning/ +# end of your string. (Regular spaces are automatically ignore by .properties +# file parser) +unicodeEscape = \u0020\u0040\u0020 +# this string equals to " @ " + +# bug 1033309 plurals with multiple placeholders +first_identifier[one]=first entry is %s and the second one is %s. +first_identifier=the entries are %s and %s. +second_identifier[other]=first entry is %s and the second one is %s. +third_identifier=first entry is %s and the second one is %s. + +# bug 824489 allow translation of element attributes +link-attributes.title=Yes +link-attributes.alt=Yes +link-attributes.accesskey=B +input.placeholder=Yes +contextitem.label=Yes +link-attributes.ariaLabel=Yes +link-attributes.ariaValueText=Value +link-attributes.ariaMozHint=Hint diff --git a/addon-sdk/source/test/addons/e10s-l10n/locale/eo.properties b/addon-sdk/source/test/addons/e10s-l10n/locale/eo.properties new file mode 100644 index 000000000..a979fca1a --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/locale/eo.properties @@ -0,0 +1,5 @@ +# 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/. + +Translated= jes diff --git a/addon-sdk/source/test/addons/e10s-l10n/locale/fr-FR.properties b/addon-sdk/source/test/addons/e10s-l10n/locale/fr-FR.properties new file mode 100644 index 000000000..2c5ffbb17 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/locale/fr-FR.properties @@ -0,0 +1,14 @@ +# 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/. + +Translated= Oui + +placeholderString= Placeholder %s + +# Plural forms +%d downloads=%d téléchargements +%d downloads[one]=%d téléchargement + +downloadsCount=%d téléchargements +downloadsCount[one]=%d téléchargement diff --git a/addon-sdk/source/test/addons/e10s-l10n/main.js b/addon-sdk/source/test/addons/e10s-l10n/main.js new file mode 100644 index 000000000..e12cab87d --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/main.js @@ -0,0 +1,289 @@ +/* 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/. */ +"use strict"; + +const prefs = require("sdk/preferences/service"); +const { Loader } = require('sdk/test/loader'); +const { resolveURI } = require('toolkit/loader'); +const { rootURI, isNative } = require("@loader/options"); +const { usingJSON } = require('sdk/l10n/json/core'); + +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +function setLocale(locale) { + prefs.set(PREF_MATCH_OS_LOCALE, false); + prefs.set(PREF_SELECTED_LOCALE, locale); +} + +function resetLocale() { + prefs.reset(PREF_MATCH_OS_LOCALE); + prefs.reset(PREF_SELECTED_LOCALE); +} + +function definePseudo(loader, id, exports) { + let uri = resolveURI(id, loader.mapping); + loader.modules[uri] = { exports: exports }; +} + +function createTest(locale, testFunction) { + return function (assert, done) { + let loader = Loader(module); + // Change the locale before loading new l10n modules in order to load + // the right .json file + setLocale(locale); + // Initialize main l10n module in order to load new locale files + loader.require("sdk/l10n/loader"). + load(rootURI). + then(null, function failure(error) { + if (!isNative) + assert.fail("Unable to load locales: " + error); + }). + then(function success(data) { + definePseudo(loader, '@l10n/data', data ? data : null); + // Execute the given test function + try { + testFunction(assert, loader, function onDone() { + loader.unload(); + resetLocale(); + done(); + }); + } + catch(e) { + console.exception(e); + } + }, + function failure(error) { + assert.fail("Unable to load locales: " + error); + }); + }; +} + +exports.testExactMatching = createTest("fr-FR", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "Key not translated"); + assert.equal(_("Translated"), "Oui", + "Simple key translated"); + + // Placeholders + assert.equal(_("placeholderString", "works"), "Placeholder works", + "Value with placeholder"); + assert.equal(_("Placeholder %s", "works"), "Placeholder works", + "Key without value but with placeholder"); + assert.equal(_("Placeholders %2s %1s %s.", "working", "are", "correctly"), + "Placeholders are working correctly.", + "Multiple placeholders"); + + // Plurals + assert.equal(_("downloadsCount", 0), + "0 téléchargement", + "PluralForm form 'one' for 0 in french"); + assert.equal(_("downloadsCount", 1), + "1 téléchargement", + "PluralForm form 'one' for 1 in french"); + assert.equal(_("downloadsCount", 2), + "2 téléchargements", + "PluralForm form 'other' for n > 1 in french"); + + done(); +}); + +exports.testHtmlLocalizationPageWorker = createTest("en-GB", function(assert, loader, done) { + // Ensure initing html component that watch document creations + // Note that this module is automatically initialized in + // cuddlefish.js:Loader.main in regular addons. But it isn't for unit tests. + let loaderHtmlL10n = loader.require("sdk/l10n/html"); + loaderHtmlL10n.enable(); + + let uri = require("sdk/self").data.url("test-localization.html"); + let worker = loader.require("sdk/page-worker").Page({ + contentURL: uri, + contentScript: "new " + function ContentScriptScope() { + let nodes = document.body.querySelectorAll("*[data-l10n-id]"); + self.postMessage([nodes[0].innerHTML, + nodes[1].innerHTML, + nodes[2].innerHTML, + nodes[3].innerHTML, + nodes[4].title, + nodes[4].getAttribute("alt"), + nodes[4].getAttribute("accesskey"), + nodes[4].getAttribute("aria-label"), + nodes[4].getAttribute("aria-valuetext"), + nodes[4].getAttribute("aria-moz-hint"), + nodes[5].placeholder, + nodes[6].label]); + }, + onMessage: function (data) { + assert.equal( + data[0], + "Kept as-is", + "Nodes with unknown id in .properties are kept 'as-is'" + ); + assert.equal(data[1], "Yes", "HTML is translated"); + assert.equal( + data[2], + "no <b>HTML</b> injection", + "Content from .properties is text content; HTML can't be injected." + ); + assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted."); + + // Attribute translation tests + assert.equal(data[4], "Yes", "Title attributes gets translated."); + assert.equal(data[5], "Yes", "Alt attributes gets translated."); + assert.equal(data[6], "B", "Accesskey gets translated."); + + assert.equal(data[7], "Yes", "Aria-Label gets translated."); + assert.equal(data[8], "Value", "Aria-valuetext gets translated."); + assert.equal(data[9], "Hint", "Aria-moz-hint gets translated."); + + assert.equal(data[10], "Yes", "Form placeholders are translateable."); + + assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable."); + + done(); + } + }); +}); + +exports.testHtmlLocalization = createTest("en-GB", function(assert, loader, done) { + // Ensure initing html component that watch document creations + // Note that this module is automatically initialized in + // cuddlefish.js:Loader.main in regular addons. But it isn't for unit tests. + let loaderHtmlL10n = loader.require("sdk/l10n/html"); + loaderHtmlL10n.enable(); + + let uri = require("sdk/self").data.url("test-localization.html"); + loader.require("sdk/tabs").open({ + url: uri, + onReady: function(tab) { + tab.attach({ + contentURL: uri, + contentScript: "new " + function ContentScriptScope() { + let nodes = document.body.querySelectorAll("*[data-l10n-id]"); + self.postMessage([nodes[0].innerHTML, + nodes[1].innerHTML, + nodes[2].innerHTML, + nodes[3].innerHTML, + nodes[4].title, + nodes[4].getAttribute("alt"), + nodes[4].getAttribute("accesskey"), + nodes[4].getAttribute("aria-label"), + nodes[4].getAttribute("aria-valuetext"), + nodes[4].getAttribute("aria-moz-hint"), + nodes[5].placeholder, + nodes[6].label]); + }, + onMessage: function (data) { + assert.equal( + data[0], + "Kept as-is", + "Nodes with unknown id in .properties are kept 'as-is'" + ); + assert.equal(data[1], "Yes", "HTML is translated"); + assert.equal( + data[2], + "no <b>HTML</b> injection", + "Content from .properties is text content; HTML can't be injected." + ); + assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted."); + + // Attribute translation tests + assert.equal(data[4], "Yes", "Title attributes gets translated."); + assert.equal(data[5], "Yes", "Alt attributes gets translated."); + assert.equal(data[6], "B", "Accesskey gets translated."); + + assert.equal(data[7], "Yes", "Aria-Label gets translated."); + assert.equal(data[8], "Value", "Aria-valuetext gets translated."); + assert.equal(data[9], "Hint", "Aria-moz-hint gets translated."); + + assert.equal(data[10], "Yes", "Form placeholders are translateable."); + + assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable."); + + tab.close(done); + } + }); + } + }); +}); + +exports.testEnUsLocaleName = createTest("en-US", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "Yes", + "String with translation is correctly translated"); + + // Check Unicode char escaping sequences + assert.equal(_("unicodeEscape"), " @ ", + "Unicode escaped sequances are correctly converted"); + + // Check plural forms regular matching + assert.equal(_("downloadsCount", 0), + "0 downloads", + "PluralForm form 'other' for 0 in english"); + assert.equal(_("downloadsCount", 1), + "one download", + "PluralForm form 'one' for 1 in english"); + assert.equal(_("downloadsCount", 2), + "2 downloads", + "PluralForm form 'other' for n != 1 in english"); + + // Check optional plural forms + assert.equal(_("pluralTest", 0), + "optional zero form", + "PluralForm form 'zero' can be optionaly specified. (Isn't mandatory in english)"); + assert.equal(_("pluralTest", 1), + "fallback to other", + "If the specific plural form is missing, we fallback to 'other'"); + + // Ensure that we can omit specifying the generic key without [other] + // key[one] = ... + // key[other] = ... # Instead of `key = ...` + assert.equal(_("explicitPlural", 1), + "one", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + assert.equal(_("explicitPlural", 10), + "other", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + + assert.equal(_("first_identifier", "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier no count"); + assert.equal(_("first_identifier", 0, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 0"); + assert.equal(_("first_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "first_identifier with count = 1"); + assert.equal(_("first_identifier", 2, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 2"); + + assert.equal(_("second_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with no count"); + assert.equal(_("second_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 0"); + assert.equal(_("second_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 1"); + assert.equal(_("second_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 2"); + + assert.equal(_("third_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with no count"); + assert.equal(_("third_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 0"); + assert.equal(_("third_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 2"); + + done(); +}); + +exports.testUsingJSON = function(assert) { + assert.equal(usingJSON, !isNative, 'using json'); +} + +exports.testShortLocaleName = createTest("eo", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "jes", + "String with translation is correctly translated"); + + done(); +}); + + +// Before running tests, disable HTML service which is automatially enabled +// in api-utils/addon/runner.js +require('sdk/l10n/html').disable(); + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/e10s-l10n/package.json b/addon-sdk/source/test/addons/e10s-l10n/package.json new file mode 100644 index 000000000..51591a86e --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-l10n/package.json @@ -0,0 +1,5 @@ +{ + "id": "e10s-l10n@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/e10s-remote/main.js b/addon-sdk/source/test/addons/e10s-remote/main.js new file mode 100644 index 000000000..cea27af9b --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-remote/main.js @@ -0,0 +1,578 @@ +/* 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/. */ + +"use strict"; + +const LOCAL_URI = "about:robots"; +const REMOTE_URI = "data:text/html;charset=utf-8,remote"; + +const { Cu } = require('chrome'); +const { Loader } = require('sdk/test/loader'); +const { getTabs, openTab, closeTab, setTabURL, getBrowserForTab, getURI } = require('sdk/tabs/utils'); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { cleanUI } = require("sdk/test/utils"); +const { setTimeout } = require("sdk/timers"); +const { promiseEvent, promiseDOMEvent, promiseEventOnItemAndContainer, + waitForProcesses, getChildFrameCount, isE10S } = require("./utils"); +const { after } = require('sdk/test/utils'); +const { processID } = require('sdk/system/runtime'); + +const { set } = require('sdk/preferences/service'); +// The hidden preload browser messes up our frame counts +set('browser.newtab.preload', false); + +function promiseTabFrameAttach(frames) { + return new Promise(resolve => { + let listener = function(frame, ...args) { + if (!frame.isTab) + return; + frames.off("attach", listener); + resolve([frame, ...args]); + } + + frames.on("attach", listener); + }); +} + +// Check that we see a process stop and start +exports["test process restart"] = function*(assert) { + if (!isE10S) { + assert.pass("Skipping test in non-e10s mode"); + return; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + let tab = tabs[0]; + let browser = getBrowserForTab(tab); + + let loader = new Loader(module); + let { processes, frames } = yield waitForProcesses(loader); + + let remoteProcess = Array.filter(processes, p => p.isRemote)[0]; + let localProcess = Array.filter(processes, p => !p.isRemote)[0]; + let remoteFrame = Array.filter(frames, f => f.process == remoteProcess)[0]; + + // Switch the remote tab to a local URI which should kill the remote process + + let frameDetach = promiseEventOnItemAndContainer(assert, remoteFrame, frames, 'detach'); + let frameAttach = promiseTabFrameAttach(frames); + let processDetach = promiseEventOnItemAndContainer(assert, remoteProcess, processes, 'detach'); + let browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, LOCAL_URI); + // The load should kill the remote frame + yield frameDetach; + // And create a new frame in the local process + let [newFrame] = yield frameAttach; + assert.equal(newFrame.process, localProcess, "New frame should be in the local process"); + // And kill the process + yield processDetach; + yield browserLoad; + + frameDetach = promiseEventOnItemAndContainer(assert, newFrame, frames, 'detach'); + let processAttach = promiseEvent(processes, 'attach'); + frameAttach = promiseTabFrameAttach(frames); + browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, REMOTE_URI); + // The load should kill the remote frame + yield frameDetach; + // And create a new remote process + [remoteProcess] = yield processAttach; + assert.ok(remoteProcess.isRemote, "Process should be remote"); + // And create a new frame in the remote process + [newFrame] = yield frameAttach; + assert.equal(newFrame.process, remoteProcess, "New frame should be in the remote process"); + yield browserLoad; + + browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, "about:blank"); + yield browserLoad; + + loader.unload(); +}; + +// Test that we find the right number of processes and that messaging between +// them works and none of the streams cross +exports["test process list"] = function*(assert) { + let loader = new Loader(module); + let { processes } = loader.require('sdk/remote/parent'); + + let processCount = 0; + processes.forEvery(processes => processCount++); + + yield waitForProcesses(loader); + + let remoteProcesses = Array.filter(processes, process => process.isRemote); + let localProcesses = Array.filter(processes, process => !process.isRemote); + + assert.equal(localProcesses.length, 1, "Should always be one process"); + + if (isE10S) { + assert.equal(remoteProcesses.length, 1, "Should be one remote process"); + } + else { + assert.equal(remoteProcesses.length, 0, "Should be no remote processes"); + } + + assert.equal(processCount, processes.length, "Should have seen all processes"); + + processCount = 0; + processes.forEvery(process => processCount++); + + assert.equal(processCount, processes.length, "forEvery should send existing processes to the listener"); + + localProcesses[0].port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "local", "Should not have seen a pong from the local process with the wrong key"); + }); + + if (isE10S) { + remoteProcesses[0].port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "remote", "Should not have seen a pong from the remote process with the wrong key"); + }); + } + + let promise = promiseEventOnItemAndContainer(assert, localProcesses[0].port, processes.port, 'sdk/test/pong', localProcesses[0]); + localProcesses[0].port.emit('sdk/test/ping', "local"); + + let reply = yield promise; + assert.equal(reply[0], "local", "Saw the process reply with the right key"); + + if (isE10S) { + promise = promiseEventOnItemAndContainer(assert, remoteProcesses[0].port, processes.port, 'sdk/test/pong', remoteProcesses[0]); + remoteProcesses[0].port.emit('sdk/test/ping', "remote"); + + reply = yield promise; + assert.equal(reply[0], "remote", "Saw the process reply with the right key"); + + assert.notEqual(localProcesses[0], remoteProcesses[0], "Processes should be different"); + } + + loader.unload(); +}; + +// Test that the frame lists are kept up to date +exports["test frame list"] = function*(assert) { + function browserFrames(list) { + return Array.filter(list, b => b.isTab).length; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + + let loader = new Loader(module); + let { processes, frames } = yield waitForProcesses(loader); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + let promise = promiseTabFrameAttach(frames); + let tab1 = openTab(window, LOCAL_URI); + let [frame1] = yield promise; + assert.ok(!!frame1, "Should have seen the new frame"); + assert.ok(!frame1.process.isRemote, "Frame should not be remote"); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + promise = promiseTabFrameAttach(frames); + let tab2 = openTab(window, REMOTE_URI); + let [frame2] = yield promise; + assert.ok(!!frame2, "Should have seen the new frame"); + if (isE10S) + assert.ok(frame2.process.isRemote, "Frame should be remote"); + else + assert.ok(!frame2.process.isRemote, "Frame should not be remote"); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + frames.port.emit('sdk/test/ping') + yield new Promise(resolve => { + let count = 0; + let listener = () => { + console.log("Saw pong"); + count++; + if (count == frames.length) { + frames.port.off('sdk/test/pong', listener); + resolve(); + } + }; + frames.port.on('sdk/test/pong', listener); + }); + + let badListener = () => { + assert.fail("Should not have seen a response through this frame"); + } + frame1.port.on('sdk/test/pong', badListener); + frame2.port.emit('sdk/test/ping', 'b'); + let [key] = yield promiseEventOnItemAndContainer(assert, frame2.port, frames.port, 'sdk/test/pong', frame2); + assert.equal(key, 'b', "Should have seen the right response"); + frame1.port.off('sdk/test/pong', badListener); + + frame2.port.on('sdk/test/pong', badListener); + frame1.port.emit('sdk/test/ping', 'b'); + [key] = yield promiseEventOnItemAndContainer(assert, frame1.port, frames.port, 'sdk/test/pong', frame1); + assert.equal(key, 'b', "Should have seen the right response"); + frame2.port.off('sdk/test/pong', badListener); + + promise = promiseEventOnItemAndContainer(assert, frame1, frames, 'detach'); + closeTab(tab1); + yield promise; + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + promise = promiseEventOnItemAndContainer(assert, frame2, frames, 'detach'); + closeTab(tab2); + yield promise; + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + loader.unload(); +}; + +// Test that multiple loaders get their own loaders in the child and messages +// don't cross. Unload should work +exports["test new loader"] = function*(assert) { + let loader1 = new Loader(module); + let { processes: processes1 } = yield waitForProcesses(loader1); + + let loader2 = new Loader(module); + let { processes: processes2 } = yield waitForProcesses(loader2); + + let process1 = [...processes1][0]; + let process2 = [...processes2][0]; + + process1.port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "a", "Should have seen the right pong"); + }); + + process2.port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "b", "Should have seen the right pong"); + }); + + process1.port.emit('sdk/test/ping', 'a'); + yield promiseEvent(process1.port, 'sdk/test/pong'); + + process2.port.emit('sdk/test/ping', 'b'); + yield promiseEvent(process2.port, 'sdk/test/pong'); + + loader1.unload(); + + process2.port.emit('sdk/test/ping', 'b'); + yield promiseEvent(process2.port, 'sdk/test/pong'); + + loader2.unload(); +}; + +// Test that unloading the loader unloads the child instances +exports["test unload"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:,<html/>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + promise = promiseDOMEvent(browser, 'hashchange'); + frame.port.emit('sdk/test/testunload'); + loader.unload("shutdown"); + yield promise; + + let hash = getURI(tab).replace(/.*#/, ""); + assert.equal(hash, "unloaded:shutdown", "Saw the correct hash change.") + + closeTab(tab); +} + +// Test that unloading the loader causes the child to see frame detach events +exports["test frame detach on unload"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:,<html/>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + promise = promiseDOMEvent(browser, 'hashchange'); + frame.port.emit('sdk/test/testdetachonunload'); + loader.unload("shutdown"); + yield promise; + + let hash = getURI(tab).replace(/.*#/, ""); + assert.equal(hash, "unloaded", "Saw the correct hash change.") + + closeTab(tab); +} + +// Test that DOM event listener on the frame object works +exports["test frame event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframeevent'); + promise = Promise.all([ + promiseEvent(frame.port, 'sdk/test/sawreply'), + promiseEvent(frame.port, 'sdk/test/eventsent') + ]); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + frame.port.emit('sdk/test/unregisterframeevent'); + promise = promiseEvent(frame.port, 'sdk/test/eventsent'); + frame.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader.unload(); +} + +// Test that DOM event listener on the frames object works +exports["test frames event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframesevent'); + promise = Promise.all([ + promiseEvent(frame.port, 'sdk/test/sawreply'), + promiseEvent(frame.port, 'sdk/test/eventsent') + ]); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + frame.port.emit('sdk/test/unregisterframesevent'); + promise = promiseEvent(frame.port, 'sdk/test/eventsent'); + frame.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader.unload(); +} + +// Test that unloading unregisters frame DOM events +exports["test unload removes frame event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let loader2 = new Loader(module); + let { frames: frames2 } = yield waitForProcesses(loader2); + + let promise = promiseTabFrameAttach(frames); + let promise2 = promiseTabFrameAttach(frames2); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + let [frame2] = yield promise2; + assert.ok(!!frame && !!frame2, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframeevent'); + promise = Promise.all([ + promiseEvent(frame2.port, 'sdk/test/sawreply'), + promiseEvent(frame2.port, 'sdk/test/eventsent') + ]); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + loader.unload(); + + promise = promiseEvent(frame2.port, 'sdk/test/eventsent'); + frame2.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader2.unload(); +} + +// Test that unloading unregisters frames DOM events +exports["test unload removes frames event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let loader2 = new Loader(module); + let { frames: frames2 } = yield waitForProcesses(loader2); + + let promise = promiseTabFrameAttach(frames); + let promise2 = promiseTabFrameAttach(frames2); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + let [frame2] = yield promise2; + assert.ok(!!frame && !!frame2, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframesevent'); + promise = Promise.all([ + promiseEvent(frame2.port, 'sdk/test/sawreply'), + promiseEvent(frame2.port, 'sdk/test/eventsent') + ]); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + loader.unload(); + + promise = promiseEvent(frame2.port, 'sdk/test/eventsent'); + frame2.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader2.unload(); +} + +// Check that the child frame has the right properties +exports["test frame properties"] = function*(assert) { + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = new Promise(resolve => { + let count = frames.length; + let listener = (frame, properties) => { + assert.equal(properties.isTab, frame.isTab, + "Child frame should have the same isTab property"); + + if (--count == 0) { + frames.port.off('sdk/test/replyproperties', listener); + resolve(); + } + } + + frames.port.on('sdk/test/replyproperties', listener); + }) + + frames.port.emit('sdk/test/checkproperties'); + yield promise; + + loader.unload(); +} + +// Check that non-remote processes have the same process ID and remote processes +// have different IDs +exports["test processID"] = function*(assert) { + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + for (let process of processes) { + process.port.emit('sdk/test/getprocessid'); + let [p, ID] = yield promiseEvent(process.port, 'sdk/test/processid'); + if (process.isRemote) { + assert.notEqual(ID, processID, "Remote processes should have a different process ID"); + } + else { + assert.equal(ID, processID, "Remote processes should have the same process ID"); + } + } + + loader.unload(); +} + +// Check that sdk/remote/parent and sdk/remote/child can only be loaded in the +// appropriate loaders +exports["test cannot load in wrong loader"] = function*(assert) { + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + try { + require('sdk/remote/child'); + assert.fail("Should not have been able to load sdk/remote/child"); + } + catch (e) { + assert.ok(/Cannot load sdk\/remote\/child in a main process loader/.test(e), + "Should have seen the right exception."); + } + + for (let process of processes) { + processes.port.emit('sdk/test/parentload'); + let [_, isChildLoader, loaded, message] = yield promiseEvent(processes.port, 'sdk/test/parentload'); + assert.ok(isChildLoader, "Process should see itself in a child loader."); + assert.ok(!loaded, "Process couldn't load sdk/remote/parent."); + assert.ok(/Cannot load sdk\/remote\/parent in a child loader/.test(message), + "Should have seen the right exception."); + } + + loader.unload(); +}; + +exports["test send cpow"] = function*(assert) { + if (!isE10S) { + assert.pass("Skipping test in non-e10s mode"); + return; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + let tab = tabs[0]; + let browser = getBrowserForTab(tab); + + assert.ok(Cu.isCrossProcessWrapper(browser.contentWindow), + "Should have a CPOW for the browser content window"); + + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + processes.port.emitCPOW('sdk/test/cpow', ['foobar'], { window: browser.contentWindow }); + let [process, arg, id] = yield promiseEvent(processes.port, 'sdk/test/cpow'); + + assert.ok(process.isRemote, "Response should come from the remote process"); + assert.equal(arg, "foobar", "Argument should have passed through"); + assert.equal(id, browser.outerWindowID, "Should have got the ID from the child"); +}; + +after(exports, function*(name, assert) { + yield cleanUI(); +}); + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/e10s-remote/package.json b/addon-sdk/source/test/addons/e10s-remote/package.json new file mode 100644 index 000000000..88626d38d --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-remote/package.json @@ -0,0 +1,9 @@ +{ + "name": "e10s-remote", + "title": "e10s-remote", + "id": "remote@jetpack", + "description": "Run remote tests", + "version": "1.0.0", + "main": "main.js", + "e10s": true +} diff --git a/addon-sdk/source/test/addons/e10s-remote/remote-module.js b/addon-sdk/source/test/addons/e10s-remote/remote-module.js new file mode 100644 index 000000000..cedf005a9 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-remote/remote-module.js @@ -0,0 +1,129 @@ +/* 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/. */ + +const { when } = require('sdk/system/unload'); +const { process, frames } = require('sdk/remote/child'); +const { loaderID } = require('@loader/options'); +const { processID } = require('sdk/system/runtime'); +const system = require('sdk/system/events'); +const { Cu } = require('chrome'); +const { isChildLoader } = require('sdk/remote/core'); +const { getOuterId } = require('sdk/window/utils'); + +function log(str) { + console.log("remote[" + loaderID + "][" + processID + "]: " + str); +} + +log("module loaded"); + +process.port.emit('sdk/test/load'); + +process.port.on('sdk/test/ping', (process, key) => { + log("received process ping"); + process.port.emit('sdk/test/pong', key); +}); + +var frameCount = 0; +frames.forEvery(frame => { + frameCount++; + frame.on('detach', () => { + frameCount--; + }); + + frame.port.on('sdk/test/ping', (frame, key) => { + log("received frame ping"); + frame.port.emit('sdk/test/pong', key); + }); +}); + +frames.port.on('sdk/test/checkproperties', frame => { + frame.port.emit('sdk/test/replyproperties', { + isTab: frame.isTab + }); +}); + +process.port.on('sdk/test/count', () => { + log("received count ping"); + process.port.emit('sdk/test/count', frameCount); +}); + +process.port.on('sdk/test/getprocessid', () => { + process.port.emit('sdk/test/processid', processID); +}); + +frames.port.on('sdk/test/testunload', (frame) => { + // Cache the content since the frame will have been destroyed by the time + // we see the unload event. + let content = frame.content; + when((reason) => { + content.location = "#unloaded:" + reason; + }); +}); + +frames.port.on('sdk/test/testdetachonunload', (frame) => { + let content = frame.content; + frame.on('detach', () => { + console.log("Detach from " + frame.content.location); + frame.content.location = "#unloaded"; + }); +}); + +frames.port.on('sdk/test/sendevent', (frame) => { + let doc = frame.content.document; + + let listener = () => { + frame.port.emit('sdk/test/sawreply'); + } + + system.on("Test:Reply", listener); + let event = new frame.content.CustomEvent("Test:Event"); + doc.dispatchEvent(event); + system.off("Test:Reply", listener); + frame.port.emit('sdk/test/eventsent'); +}); + +process.port.on('sdk/test/parentload', () => { + let loaded = false; + let message = ""; + try { + require('sdk/remote/parent'); + loaded = true; + } + catch (e) { + message = "" + e; + } + + process.port.emit('sdk/test/parentload', + isChildLoader, + loaded, + message + ) +}); + +function listener(event) { + // Use the raw observer service here since it will be usable even if the + // loader has unloaded + let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + Services.obs.notifyObservers(null, "Test:Reply", ""); +} + +frames.port.on('sdk/test/registerframesevent', (frame) => { + frames.addEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/unregisterframesevent', (frame) => { + frames.removeEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/registerframeevent', (frame) => { + frame.addEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/unregisterframeevent', (frame) => { + frame.removeEventListener("Test:Event", listener, true); +}); + +process.port.on('sdk/test/cpow', (process, arg, cpows) => { + process.port.emit('sdk/test/cpow', arg, getOuterId(cpows.window)); +}); diff --git a/addon-sdk/source/test/addons/e10s-remote/utils.js b/addon-sdk/source/test/addons/e10s-remote/utils.js new file mode 100644 index 000000000..f30f4f3a4 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-remote/utils.js @@ -0,0 +1,110 @@ +/* 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/. */ + +"use strict"; + +const { Cu } = require('chrome'); +const { Task: { async } } = Cu.import('resource://gre/modules/Task.jsm', {}); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); + +const REMOTE_MODULE = "./remote-module"; + +function promiseEvent(emitter, event) { + console.log("Waiting for " + event); + return new Promise(resolve => { + emitter.once(event, (...args) => { + console.log("Saw " + event); + resolve(args); + }); + }); +} +exports.promiseEvent = promiseEvent; + +function promiseDOMEvent(target, event, isCapturing = false) { + console.log("Waiting for " + event); + return new Promise(resolve => { + let listener = (event) => { + target.removeEventListener(event, listener, isCapturing); + resolve(event); + }; + target.addEventListener(event, listener, isCapturing); + }) +} +exports.promiseDOMEvent = promiseDOMEvent; + +const promiseEventOnItemAndContainer = async(function*(assert, itemport, container, event, item = itemport) { + let itemEvent = promiseEvent(itemport, event); + let containerEvent = promiseEvent(container, event); + + let itemArgs = yield itemEvent; + let containerArgs = yield containerEvent; + + assert.equal(containerArgs[0], item, "Should have seen a container event for the right item"); + assert.equal(JSON.stringify(itemArgs), JSON.stringify(containerArgs), "Arguments should have matched"); + + // Strip off the item from the returned arguments + return itemArgs.slice(1); +}); +exports.promiseEventOnItemAndContainer = promiseEventOnItemAndContainer; + +const waitForProcesses = async(function*(loader) { + console.log("Starting remote"); + let { processes, frames, remoteRequire } = loader.require('sdk/remote/parent'); + remoteRequire(REMOTE_MODULE, module); + + let events = []; + + // In e10s we should expect to see two processes + let expectedCount = isE10S ? 2 : 1; + + yield new Promise(resolve => { + let count = 0; + + // Wait for a process to be detected + let listener = process => { + console.log("Saw a process attach"); + // Wait for the remote module to load in this process + process.port.once('sdk/test/load', () => { + console.log("Saw a remote module load"); + count++; + if (count == expectedCount) { + processes.off('attach', listener); + resolve(); + } + }); + } + processes.on('attach', listener); + }); + + console.log("Remote ready"); + return { processes, frames, remoteRequire }; +}); +exports.waitForProcesses = waitForProcesses; + +// Counts the frames in all the child processes +const getChildFrameCount = async(function*(processes) { + let frameCount = 0; + + for (let process of processes) { + process.port.emit('sdk/test/count'); + let [p, count] = yield promiseEvent(process.port, 'sdk/test/count'); + frameCount += count; + } + + return frameCount; +}); +exports.getChildFrameCount = getChildFrameCount; + +const mainWindow = getMostRecentBrowserWindow(); +const isE10S = mainWindow.gMultiProcessBrowser; +exports.isE10S = isE10S; + +if (isE10S) { + console.log("Testing in E10S mode"); + // We expect a child process to already be present, make sure that is the case + mainWindow.XULBrowserWindow.forceInitialBrowserRemote(); +} +else { + console.log("Testing in non-E10S mode"); +} diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/main.js b/addon-sdk/source/test/addons/e10s-tabs/lib/main.js new file mode 100644 index 000000000..09ccf5008 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/main.js @@ -0,0 +1,23 @@ +/* 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/. */ +'use strict'; + +const { merge } = require('sdk/util/object'); +const { version } = require('sdk/system'); + +const SKIPPING_TESTS = { + "test skip": (assert) => assert.pass("nothing to test here") +}; + +merge(module.exports, require('./test-tab')); +merge(module.exports, require('./test-tab-events')); +merge(module.exports, require('./test-tab-observer')); +merge(module.exports, require('./test-tab-utils')); + +// run e10s tests only on builds from trunk, fx-team, Nightly.. +if (!version.endsWith('a1')) { + module.exports = SKIPPING_TESTS; +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/private-browsing/helper.js b/addon-sdk/source/test/addons/e10s-tabs/lib/private-browsing/helper.js new file mode 100644 index 000000000..f581c92c0 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/private-browsing/helper.js @@ -0,0 +1,91 @@ +/* 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/. */ +'use strict'; + +const { Loader } = require('sdk/test/loader'); + +const { loader } = LoaderWithHookedConsole(module); + +const pb = loader.require('sdk/private-browsing'); +const pbUtils = loader.require('sdk/private-browsing/utils'); +const xulApp = require("sdk/system/xul-app"); +const { open: openWindow, getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { openTab, getTabContentWindow, getActiveTab, setTabURL, closeTab } = require('sdk/tabs/utils'); +const promise = require("sdk/core/promise"); +const windowHelpers = require('sdk/window/helpers'); +const events = require("sdk/system/events"); + +function LoaderWithHookedConsole(module) { + let globals = {}; + let errors = []; + + globals.console = Object.create(console, { + error: { + value: function(e) { + errors.push(e); + if (!/DEPRECATED:/.test(e)) { + console.error(e); + } + } + } + }); + + let loader = Loader(module, globals); + + return { + loader: loader, + errors: errors + } +} + +exports.pb = pb; +exports.pbUtils = pbUtils; +exports.LoaderWithHookedConsole = LoaderWithHookedConsole; + +exports.openWebpage = function openWebpage(url, enablePrivate) { + if (xulApp.is("Fennec")) { + let chromeWindow = getMostRecentBrowserWindow(); + let rawTab = openTab(chromeWindow, url, { + isPrivate: enablePrivate + }); + + return { + ready: promise.resolve(getTabContentWindow(rawTab)), + close: function () { + closeTab(rawTab); + // Returns a resolved promise as there is no need to wait + return promise.resolve(); + } + }; + } + else { + let win = openWindow(null, { + features: { + private: enablePrivate + } + }); + let deferred = promise.defer(); + + // Wait for delayed startup code to be executed, in order to ensure + // that the window is really ready + events.on("browser-delayed-startup-finished", function onReady({subject}) { + if (subject == win) { + events.off("browser-delayed-startup-finished", onReady); + deferred.resolve(win); + + let rawTab = getActiveTab(win); + setTabURL(rawTab, url); + deferred.resolve(getTabContentWindow(rawTab)); + } + }, true); + + return { + ready: deferred.promise, + close: function () { + return windowHelpers.close(win); + } + }; + } + return null; +} diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-events.js b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-events.js new file mode 100644 index 000000000..4d4eae347 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-events.js @@ -0,0 +1,238 @@ +/* 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/. */ + +"use strict"; + +const { Loader } = require("sdk/test/loader"); +const utils = require("sdk/tabs/utils"); +const { open, close } = require("sdk/window/helpers"); +const { getMostRecentBrowserWindow } = require("sdk/window/utils"); +const { events } = require("sdk/tab/events"); +const { on, off } = require("sdk/event/core"); +const { resolve, defer } = require("sdk/core/promise"); + +var isFennec = require("sdk/system/xul-app").is("Fennec"); + +function test(options) { + return function(assert, done) { + let tabEvents = []; + let tabs = []; + let { promise, resolve: resolveP } = defer(); + let win = isFennec ? resolve(getMostRecentBrowserWindow()) : + open(null, { + features: { private: true, toolbar:true, chrome: true } + }); + let window = null; + + // Firefox events are fired sync; Fennec events async + // this normalizes the tests + function handler (event) { + tabEvents.push(event); + runIfReady(); + } + + function runIfReady () { + let releventEvents = getRelatedEvents(tabEvents, tabs); + if (options.readyWhen(releventEvents)) + options.end({ + tabs: tabs, + events: releventEvents, + assert: assert, + done: resolveP + }); + } + + win.then(function(w) { + window = w; + on(events, "data", handler); + options.start({ tabs: tabs, window: window }); + + // Execute here for synchronous FF events, as the handlers + // were called before tabs were pushed to `tabs` + runIfReady(); + return promise; + }).then(function() { + off(events, "data", handler); + return isFennec ? null : close(window); + }).then(done, assert.fail); + }; +} + +// Just making sure that tab events work for already opened tabs not only +// for new windows. +exports["test current window"] = test({ + readyWhen: events => events.length === 3, + start: ({ tabs, window }) => { + let tab = utils.openTab(window, 'data:text/plain,open'); + tabs.push(tab); + utils.closeTab(tab); + }, + end: ({ tabs, events, assert, done }) => { + let [open, select, close] = events; + let tab = tabs[0]; + + assert.equal(open.type, "TabOpen"); + assert.equal(open.target, tab); + + assert.equal(select.type, "TabSelect"); + assert.equal(select.target, tab); + + assert.equal(close.type, "TabClose"); + assert.equal(close.target, tab); + done(); + } +}); + +exports["test open"] = test({ + readyWhen: events => events.length === 2, + start: ({ tabs, window }) => { + tabs.push(utils.openTab(window, 'data:text/plain,open')); + }, + end: ({ tabs, events, assert, done }) => { + let [open, select] = events; + let tab = tabs[0]; + + assert.equal(open.type, "TabOpen"); + assert.equal(open.target, tab); + + assert.equal(select.type, "TabSelect"); + assert.equal(select.target, tab); + done(); + } +}); + +exports["test open -> close"] = test({ + readyWhen: events => events.length === 3, + start: ({ tabs, window }) => { + // First tab is useless we just open it so that closing second tab won't + // close window on some platforms. + utils.openTab(window, 'data:text/plain,ignore'); + let tab = utils.openTab(window, 'data:text/plain,open-close'); + tabs.push(tab); + utils.closeTab(tab); + }, + end: ({ tabs, events, assert, done }) => { + let [open, select, close] = events; + let tab = tabs[0]; + + assert.equal(open.type, "TabOpen"); + assert.equal(open.target, tab); + + assert.equal(select.type, "TabSelect"); + assert.equal(select.target, tab); + + assert.equal(close.type, "TabClose"); + assert.equal(close.target, tab); + done(); + } +}); + +exports["test open -> open -> select"] = test({ + readyWhen: events => events.length === 5, + start: ({tabs, window}) => { + tabs.push(utils.openTab(window, 'data:text/plain,Tab-1')); + tabs.push(utils.openTab(window, 'data:text/plain,Tab-2')); + utils.activateTab(tabs[0], window); + }, + end: ({ tabs, events, assert, done }) => { + let [ tab1, tab2 ] = tabs; + let tab1Events = 0; + getRelatedEvents(events, tab1).map(event => { + tab1Events++; + if (tab1Events === 1) + assert.equal(event.type, "TabOpen", "first tab opened"); + else + assert.equal(event.type, "TabSelect", "first tab selected"); + assert.equal(event.target, tab1); + }); + assert.equal(tab1Events, 3, "first tab has 3 events"); + + let tab2Opened; + getRelatedEvents(events, tab2).map(event => { + if (!tab2Opened) + assert.equal(event.type, "TabOpen", "second tab opened"); + else + assert.equal(event.type, "TabSelect", "second tab selected"); + tab2Opened = true; + assert.equal(event.target, tab2); + }); + done(); + } +}); + +exports["test open -> pin -> unpin"] = test({ + readyWhen: events => events.length === (isFennec ? 2 : 5), + start: ({ tabs, window }) => { + tabs.push(utils.openTab(window, 'data:text/plain,pin-unpin')); + utils.pin(tabs[0]); + utils.unpin(tabs[0]); + }, + end: ({ tabs, events, assert, done }) => { + let [open, select, move, pin, unpin] = events; + let tab = tabs[0]; + + assert.equal(open.type, "TabOpen"); + assert.equal(open.target, tab); + + assert.equal(select.type, "TabSelect"); + assert.equal(select.target, tab); + + if (isFennec) { + assert.pass("Tab pin / unpin is not supported by Fennec"); + } + else { + assert.equal(move.type, "TabMove"); + assert.equal(move.target, tab); + + assert.equal(pin.type, "TabPinned"); + assert.equal(pin.target, tab); + + assert.equal(unpin.type, "TabUnpinned"); + assert.equal(unpin.target, tab); + } + done(); + } +}); + +exports["test open -> open -> move "] = test({ + readyWhen: events => events.length === (isFennec ? 4 : 5), + start: ({tabs, window}) => { + tabs.push(utils.openTab(window, 'data:text/plain,Tab-1')); + tabs.push(utils.openTab(window, 'data:text/plain,Tab-2')); + utils.move(tabs[0], 2); + }, + end: ({ tabs, events, assert, done }) => { + let [ tab1, tab2 ] = tabs; + let tab1Events = 0; + getRelatedEvents(events, tab1).map(event => { + tab1Events++; + if (tab1Events === 1) + assert.equal(event.type, "TabOpen", "first tab opened"); + else if (tab1Events === 2) + assert.equal(event.type, "TabSelect", "first tab selected"); + else if (tab1Events === 3 && isFennec) + assert.equal(event.type, "TabMove", "first tab moved"); + assert.equal(event.target, tab1); + }); + assert.equal(tab1Events, isFennec ? 2 : 3, + "correct number of events for first tab"); + + let tab2Events = 0; + getRelatedEvents(events, tab2).map(event => { + tab2Events++; + if (tab2Events === 1) + assert.equal(event.type, "TabOpen", "second tab opened"); + else + assert.equal(event.type, "TabSelect", "second tab selected"); + assert.equal(event.target, tab2); + }); + done(); + } +}); + +function getRelatedEvents (events, tabs) { + return events.filter(({target}) => ~([].concat(tabs)).indexOf(target)); +} + +// require("sdk/test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-observer.js b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-observer.js new file mode 100644 index 000000000..b0e1753a2 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-observer.js @@ -0,0 +1,46 @@ +/* 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/. */ + +"use strict"; + +// TODO Fennec support in Bug #894525 +module.metadata = { + "engines": { + "Firefox": "*" + } +} + +const { openTab, closeTab } = require("sdk/tabs/utils"); +const { Loader } = require("sdk/test/loader"); +const { setTimeout } = require("sdk/timers"); + +exports["test unload tab observer"] = function(assert, done) { + let loader = Loader(module); + + let window = loader.require("sdk/deprecated/window-utils").activeBrowserWindow; + let observer = loader.require("sdk/tabs/observer").observer; + let opened = 0; + let closed = 0; + + observer.on("open", function onOpen(window) { opened++; }); + observer.on("close", function onClose(window) { closed++; }); + + // Open and close tab to trigger observers. + closeTab(openTab(window, "data:text/html;charset=utf-8,tab-1")); + + // Unload the module so that all listeners set by observer are removed. + loader.unload(); + + // Open and close tab once again. + closeTab(openTab(window, "data:text/html;charset=utf-8,tab-2")); + + // Enqueuing asserts to make sure that assertion is not performed early. + setTimeout(function () { + assert.equal(1, opened, "observer open was called before unload only"); + assert.equal(1, closed, "observer close was called before unload only"); + done(); + }, 0); +}; + +// require("test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-utils.js b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-utils.js new file mode 100644 index 000000000..97c388b3c --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab-utils.js @@ -0,0 +1,67 @@ +/* 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/. */ +'use strict'; + +const { getTabs } = require('sdk/tabs/utils'); +const { isWindowPBSupported, isTabPBSupported } = require('sdk/private-browsing/utils'); +const { browserWindows } = require('sdk/windows'); +const tabs = require('sdk/tabs'); +const { isPrivate } = require('sdk/private-browsing'); +const { openTab, closeTab, getTabContentWindow, getOwnerWindow } = require('sdk/tabs/utils'); +const { open, close } = require('sdk/window/helpers'); +const { windows } = require('sdk/window/utils'); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { fromIterator } = require('sdk/util/array'); + +if (isWindowPBSupported) { + exports.testGetTabsPWPB = function(assert, done) { + let tabCount = getTabs().length; + let windowCount = browserWindows.length; + + open(null, { + features: { + private: true, + toolbar: true, + chrome: true + } + }).then(function(window) { + assert.ok(isPrivate(window), 'new tab is private'); + + assert.equal(getTabs().length, tabCount, 'there are no new tabs found'); + getTabs().forEach(function(tab) { + assert.equal(isPrivate(tab), false, 'all found tabs are not private'); + assert.equal(isPrivate(getOwnerWindow(tab)), false, 'all found tabs are not private'); + assert.equal(isPrivate(getTabContentWindow(tab)), false, 'all found tabs are not private'); + }); + + assert.equal(browserWindows.length, windowCount, 'there are no new windows found'); + fromIterator(browserWindows).forEach(function(window) { + assert.equal(isPrivate(window), false, 'all found windows are not private'); + }); + + assert.equal(windows(null, {includePrivate: true}).length, 2, 'there are really two windows'); + + close(window).then(done); + }); + }; +} +else if (isTabPBSupported) { + exports.testGetTabsPTPB = function(assert, done) { + let startTabCount = getTabs().length; + let tab = openTab(getMostRecentBrowserWindow(), 'about:blank', { + isPrivate: true + }); + + assert.ok(isPrivate(getTabContentWindow(tab)), 'new tab is private'); + let utils_tabs = getTabs(); + assert.equal(utils_tabs.length, startTabCount + 1, + 'there are two tabs found'); + assert.equal(utils_tabs[utils_tabs.length-1], tab, + 'the last tab is the opened tab'); + assert.equal(browserWindows.length, 1, 'there is only one window'); + closeTab(tab); + + done(); + }; +} diff --git a/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab.js b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab.js new file mode 100644 index 000000000..0a94984a6 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/lib/test-tab.js @@ -0,0 +1,87 @@ +/* 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/. */ +"use strict"; + +const tabs = require("sdk/tabs"); // From addon-kit +const windowUtils = require("sdk/deprecated/window-utils"); +const app = require("sdk/system/xul-app"); +const { viewFor } = require("sdk/view/core"); +const { modelFor } = require("sdk/model/core"); +const { getTabId, isTab } = require("sdk/tabs/utils"); +const { defer } = require("sdk/lang/functional"); + +exports["test behavior on close"] = function(assert, done) { + tabs.open({ + url: "about:mozilla", + onReady: function(tab) { + assert.equal(tab.url, "about:mozilla", "Tab has the expected url"); + // if another test ends before closing a tab then index != 1 here + assert.ok(tab.index >= 1, "Tab has the expected index, a value greater than 0"); + tab.close(function () { + assert.equal(tab.url, undefined, + "After being closed, tab attributes are undefined (url)"); + assert.equal(tab.index, undefined, + "After being closed, tab attributes are undefined (index)"); + if (app.is("Firefox")) { + // Ensure that we can call destroy multiple times without throwing; + // Fennec doesn't use this internal utility + tab.destroy(); + tab.destroy(); + } + + done(); + }); + } + }); +}; + +exports["test viewFor(tab)"] = (assert, done) => { + // Note we defer handlers as length collection is updated after + // handler is invoked, so if test is finished before counnts are + // updated wrong length will show up in followup tests. + tabs.once("open", defer(tab => { + const view = viewFor(tab); + assert.ok(view, "view is returned"); + assert.equal(getTabId(view), tab.id, "tab has a same id"); + + tab.close(defer(done)); + })); + + tabs.open({ url: "about:mozilla" }); +}; + +exports["test modelFor(xulTab)"] = (assert, done) => { + tabs.open({ + url: "about:mozilla", + onReady: tab => { + const view = viewFor(tab); + assert.ok(view, "view is returned"); + assert.ok(isTab(view), "view is underlaying tab"); + assert.equal(getTabId(view), tab.id, "tab has a same id"); + assert.equal(modelFor(view), tab, "modelFor(view) is SDK tab"); + + tab.close(defer(done)); + } + }); +}; + +exports["test tab.readyState"] = (assert, done) => { + tabs.open({ + url: "data:text/html;charset=utf-8,test_readyState", + onOpen: (tab) => { + assert.notEqual(["uninitialized", "loading"].indexOf(tab.readyState), -1, + "tab is either uninitialized or loading when onOpen"); + }, + onReady: (tab) => { + assert.notEqual(["interactive", "complete"].indexOf(tab.readyState), -1, + "tab is either interactive or complete when onReady"); + }, + onLoad: (tab) => { + assert.equal(tab.readyState, "complete", "tab is complete onLoad"); + tab.close(defer(done)); + } + }); +} + +// require("sdk/test").run(exports); diff --git a/addon-sdk/source/test/addons/e10s-tabs/package.json b/addon-sdk/source/test/addons/e10s-tabs/package.json new file mode 100644 index 000000000..45a11419d --- /dev/null +++ b/addon-sdk/source/test/addons/e10s-tabs/package.json @@ -0,0 +1,11 @@ +{ + "name": "e10s-tabs", + "title": "e10s-tabs", + "id": "jid1-ZZaXFHAPlHwbgw@jetpack", + "description": "run tab tests in e10s mode", + "author": "Tomislav Jovanovic", + "license": "MPL-2.0", + "version": "0.1.0", + "main": "./lib/main.js", + "e10s": true +} diff --git a/addon-sdk/source/test/addons/e10s/lib/main.js b/addon-sdk/source/test/addons/e10s/lib/main.js new file mode 100644 index 000000000..1eee511c3 --- /dev/null +++ b/addon-sdk/source/test/addons/e10s/lib/main.js @@ -0,0 +1,65 @@ +/* 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/. */ +'use strict'; + +const { getMostRecentBrowserWindow, isBrowser } = require('sdk/window/utils'); +const { promise: windowPromise, close, focus } = require('sdk/window/helpers'); +const { openTab, closeTab, getBrowserForTab } = require('sdk/tabs/utils'); +const { WindowTracker } = require('sdk/deprecated/window-utils'); +const { version, platform } = require('sdk/system'); +const { when } = require('sdk/system/unload'); +const tabs = require('sdk/tabs'); + +const SKIPPING_TESTS = { + "test skip": (assert) => assert.pass("nothing to test here") +}; + +exports.testTabIsRemote = function(assert, done) { + const url = 'data:text/html,test-tab-is-remote'; + let tab = openTab(getMostRecentBrowserWindow(), url); + assert.ok(tab.linkedBrowser.isRemoteBrowser, "The new tab should be remote"); + + // can't simply close a remote tab before it is loaded, bug 1006043 + let mm = getBrowserForTab(tab).messageManager; + mm.addMessageListener('7', function listener() { + mm.removeMessageListener('7', listener); + tabs.once('close', done); + closeTab(tab); + }) + mm.loadFrameScript('data:,sendAsyncMessage("7")', true); +} + +// run e10s tests only on builds from trunk, fx-team, Nightly.. +if (!version.endsWith('a1')) { + module.exports = {}; +} + +function replaceWindow(remote) { + let next = null; + let old = getMostRecentBrowserWindow(); + let promise = new Promise(resolve => { + let tracker = WindowTracker({ + onTrack: window => { + if (window !== next) + return; + resolve(window); + tracker.unload(); + } + }); + }) + next = old.OpenBrowserWindow({ remote }); + return promise.then(focus).then(_ => close(old)); +} + +// bug 1054482 - e10s test addons time out on linux +if (platform === 'linux') { + module.exports = SKIPPING_TESTS; + require('sdk/test/runner').runTestsFromModule(module); +} +else { + replaceWindow(true).then(_ => + require('sdk/test/runner').runTestsFromModule(module)); + + when(_ => replaceWindow(false)); +} diff --git a/addon-sdk/source/test/addons/e10s/package.json b/addon-sdk/source/test/addons/e10s/package.json new file mode 100644 index 000000000..93039749a --- /dev/null +++ b/addon-sdk/source/test/addons/e10s/package.json @@ -0,0 +1,10 @@ +{ + "name": "e10s", + "title": "e10s", + "id": "jid1-DYaXFHAPlHwbgw@jetpack", + "description": "a basic e10s test add-on", + "author": "Tomislav Jovanovic", + "license": "MPL-2.0", + "version": "0.1.0", + "main": "./lib/main.js" +} diff --git a/addon-sdk/source/test/addons/embedded-webextension/main.js b/addon-sdk/source/test/addons/embedded-webextension/main.js new file mode 100644 index 000000000..11249c504 --- /dev/null +++ b/addon-sdk/source/test/addons/embedded-webextension/main.js @@ -0,0 +1,159 @@ +const tabs = require("sdk/tabs"); +const webExtension = require('sdk/webextension'); + +exports.testEmbeddedWebExtensionModuleInitializedException = function (assert) { + let actualErr; + + assert.throws( + () => webExtension.initFromBootstrapAddonParam({webExtension: null}), + /'sdk\/webextension' module has been already initialized/, + "Got the expected exception if the module is initialized twice" + ); +}; + +exports.testEmbeddedWebExtensionBackgroungPage = function* (assert) { + try { + const api = yield webExtension.startup(); + assert.ok(api, `webextension waitForStartup promise successfully resolved`); + + const apiSecondStartup = yield webExtension.startup(); + assert.equal(api, apiSecondStartup, "Got the same API object from the second startup call"); + + const {browser} = api; + + let messageListener; + let waitForBackgroundPageMessage = new Promise((resolve, reject) => { + let numExpectedMessage = 2; + messageListener = (msg, sender, sendReply) => { + numExpectedMessage -= 1; + if (numExpectedMessage == 1) { + assert.equal(msg, "bg->sdk message", + "Got the expected message from the background page"); + sendReply("sdk reply"); + } else if (numExpectedMessage == 0) { + assert.equal(msg, "sdk reply", + "The background page received the expected reply message"); + resolve(); + } else { + console.error("Unexpected message received", {msg,sender, numExpectedMessage}); + assert.ok(false, `unexpected message received`); + reject(); + } + }; + browser.runtime.onMessage.addListener(messageListener); + }); + + let portListener; + let waitForBackgroundPagePort = new Promise((resolve, reject) => { + portListener = (port) => { + let numExpectedMessages = 2; + port.onMessage.addListener((msg) => { + numExpectedMessages -= 1; + + if (numExpectedMessages == 1) { + // Check that the legacy context has been able to receive the first port message + // and reply with a port message to the background page. + assert.equal(msg, "bg->sdk port message", + "Got the expected port message from the background page"); + port.postMessage("sdk->bg port message"); + } else if (numExpectedMessages == 0) { + // Check that the background page has received the above port message. + assert.equal(msg, "bg received sdk->bg port message", + "The background page received the expected port message"); + } + }); + + port.onDisconnect.addListener(() => { + assert.equal(numExpectedMessages, 0, "Got the expected number of port messages"); + resolve(); + }); + }; + browser.runtime.onConnect.addListener(portListener); + }); + + yield Promise.all([ + waitForBackgroundPageMessage, + waitForBackgroundPagePort, + ]).then(() => { + browser.runtime.onMessage.removeListener(messageListener); + browser.runtime.onConnect.removeListener(portListener); + }); + + } catch (err) { + assert.fail(`Unexpected webextension startup exception: ${err} - ${err.stack}`); + } +}; + +exports.testEmbeddedWebExtensionContentScript = function* (assert, done) { + try { + const {browser} = yield webExtension.startup(); + assert.ok(browser, `webextension startup promise resolved successfully to the API object`); + + let messageListener; + let waitForContentScriptMessage = new Promise((resolve, reject) => { + let numExpectedMessage = 2; + messageListener = (msg, sender, sendReply) => { + numExpectedMessage -= 1; + if (numExpectedMessage == 1) { + assert.equal(msg, "content script->sdk message", + "Got the expected message from the content script"); + sendReply("sdk reply"); + } else if (numExpectedMessage == 0) { + assert.equal(msg, "sdk reply", + "The content script received the expected reply message"); + resolve(); + } else { + console.error("Unexpected message received", {msg,sender, numExpectedMessage}); + assert.ok(false, `unexpected message received`); + reject(); + } + }; + browser.runtime.onMessage.addListener(messageListener); + }); + + let portListener; + let waitForContentScriptPort = new Promise((resolve, reject) => { + portListener = (port) => { + let numExpectedMessages = 2; + port.onMessage.addListener((msg) => { + numExpectedMessages -= 1; + + if (numExpectedMessages == 1) { + assert.equal(msg, "content script->sdk port message", + "Got the expected message from the content script port"); + port.postMessage("sdk->content script port message"); + } else if (numExpectedMessages == 0) { + assert.equal(msg, "content script received sdk->content script port message", + "The content script received the expected port message"); + } + }); + port.onDisconnect.addListener(() => { + assert.equal(numExpectedMessages, 0, "Got the epected number of port messages"); + resolve(); + }); + }; + browser.runtime.onConnect.addListener(portListener); + }); + + let url = "http://example.org/"; + + var openedTab; + tabs.once('open', function onOpen(tab) { + openedTab = tab; + }); + tabs.open(url); + + yield Promise.all([ + waitForContentScriptMessage, + waitForContentScriptPort, + ]).then(() => { + browser.runtime.onMessage.removeListener(messageListener); + browser.runtime.onConnect.removeListener(portListener); + openedTab.close(); + }); + } catch (err) { + assert.fail(`Unexpected webextension startup exception: ${err} - ${err.stack}`); + } +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/embedded-webextension/package.json b/addon-sdk/source/test/addons/embedded-webextension/package.json new file mode 100644 index 000000000..25dec41c3 --- /dev/null +++ b/addon-sdk/source/test/addons/embedded-webextension/package.json @@ -0,0 +1,6 @@ +{ + "id": "embedded-webextension@jetpack", + "version": "0.1.0", + "main": "./main.js", + "hasEmbeddedWebExtension": true +} diff --git a/addon-sdk/source/test/addons/embedded-webextension/webextension/background-page.js b/addon-sdk/source/test/addons/embedded-webextension/webextension/background-page.js new file mode 100644 index 000000000..05e7a613b --- /dev/null +++ b/addon-sdk/source/test/addons/embedded-webextension/webextension/background-page.js @@ -0,0 +1,10 @@ +browser.runtime.sendMessage("bg->sdk message", (reply) => { + browser.runtime.sendMessage(reply); +}); + +let port = browser.runtime.connect(); +port.onMessage.addListener((msg) => { + port.postMessage(`bg received ${msg}`); + port.disconnect(); +}); +port.postMessage("bg->sdk port message"); diff --git a/addon-sdk/source/test/addons/embedded-webextension/webextension/content-script.js b/addon-sdk/source/test/addons/embedded-webextension/webextension/content-script.js new file mode 100644 index 000000000..a8770e623 --- /dev/null +++ b/addon-sdk/source/test/addons/embedded-webextension/webextension/content-script.js @@ -0,0 +1,10 @@ +browser.runtime.sendMessage("content script->sdk message", (reply) => { + browser.runtime.sendMessage(reply); +}); + +let port = browser.runtime.connect(); +port.onMessage.addListener((msg) => { + port.postMessage(`content script received ${msg}`); + port.disconnect(); +}); +port.postMessage("content script->sdk port message"); diff --git a/addon-sdk/source/test/addons/embedded-webextension/webextension/manifest.json b/addon-sdk/source/test/addons/embedded-webextension/webextension/manifest.json new file mode 100644 index 000000000..d2188e7ba --- /dev/null +++ b/addon-sdk/source/test/addons/embedded-webextension/webextension/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Test SDK Embedded WebExtension", + "description": "", + "version": "0.1.0", + "applications": { + "gecko": { + "id": "embedded-webextension@jetpack" + } + }, + "manifest_version": 2, + "permissions": ["tabs"], + "background": { + "scripts": ["background-page.js"] + }, + "content_scripts": [ + {"matches": ["<all_urls>"], "js": ["content-script.js"]} + ] +} diff --git a/addon-sdk/source/test/addons/jetpack-addon.ini b/addon-sdk/source/test/addons/jetpack-addon.ini new file mode 100644 index 000000000..c1c8f060d --- /dev/null +++ b/addon-sdk/source/test/addons/jetpack-addon.ini @@ -0,0 +1,48 @@ +[addon-manager.xpi] +[author-email.xpi] +[child_process.xpi] +[chrome.xpi] +[content-permissions.xpi] +[content-script-messages-latency.xpi] +[contributors.xpi] +[curly-id.xpi] +[developers.xpi] +[e10s.xpi] +skip-if = true +[e10s-content.xpi] +skip-if = true +[e10s-l10n.xpi] +skip-if = true +[e10s-remote.xpi] +skip-if = true +[e10s-tabs.xpi] +skip-if = true +[embedded-webextension.xpi] +[l10n.xpi] +[l10n-properties.xpi] +[layout-change.xpi] +[main.xpi] +[name-in-numbers.xpi] +[name-in-numbers-plus.xpi] +[packaging.xpi] +[packed.xpi] +[page-mod-debugger-post.xpi] +[page-mod-debugger-pre.xpi] +[page-worker.xpi] +skip-if = true # Bug 1288619 and Bug 1288708 +[places.xpi] +[predefined-id-with-at.xpi] +[preferences-branch.xpi] +[private-browsing-supported.xpi] +skip-if = true +[remote.xpi] +[require.xpi] +[self.xpi] +[simple-prefs.xpi] +[simple-prefs-l10n.xpi] +[simple-prefs-regression.xpi] +[standard-id.xpi] +[tab-close-on-startup.xpi] +[toolkit-require-reload.xpi] +[translators.xpi] +[unsafe-content-script.xpi] diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/application.ini b/addon-sdk/source/test/addons/l10n-properties/app-extension/application.ini new file mode 100644 index 000000000..6cec69a16 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/application.ini @@ -0,0 +1,11 @@ +[App] +Vendor=Varma +Name=Test App +Version=1.0 +BuildID=20060101 +Copyright=Copyright (c) 2009 Atul Varma +ID=xulapp@toolness.com + +[Gecko] +MinVersion=1.9.2.0 +MaxVersion=2.0.* diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/bootstrap.js b/addon-sdk/source/test/addons/l10n-properties/app-extension/bootstrap.js new file mode 100644 index 000000000..fbb9b5186 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/bootstrap.js @@ -0,0 +1,339 @@ +/* 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/. */ + +// @see http://dxr.mozilla.org/mozilla-central/source/js/src/xpconnect/loader/mozJSComponentLoader.cpp + +'use strict'; + +// IMPORTANT: Avoid adding any initialization tasks here, if you need to do +// something before add-on is loaded consider addon/runner module instead! + +const { classes: Cc, Constructor: CC, interfaces: Ci, utils: Cu, + results: Cr, manager: Cm } = Components; +const ioService = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const resourceHandler = ioService.getProtocolHandler('resource'). + QueryInterface(Ci.nsIResProtocolHandler); +const systemPrincipal = CC('@mozilla.org/systemprincipal;1', 'nsIPrincipal')(); +const scriptLoader = Cc['@mozilla.org/moz/jssubscript-loader;1']. + getService(Ci.mozIJSSubScriptLoader); +const prefService = Cc['@mozilla.org/preferences-service;1']. + getService(Ci.nsIPrefService). + QueryInterface(Ci.nsIPrefBranch); +const appInfo = Cc["@mozilla.org/xre/app-info;1"]. + getService(Ci.nsIXULAppInfo); +const vc = Cc["@mozilla.org/xpcom/version-comparator;1"]. + getService(Ci.nsIVersionComparator); + +const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm"); + +const REASON = [ 'unknown', 'startup', 'shutdown', 'enable', 'disable', + 'install', 'uninstall', 'upgrade', 'downgrade' ]; + +const bind = Function.call.bind(Function.bind); + +var loader = null; +var unload = null; +var cuddlefishSandbox = null; +var nukeTimer = null; + +// Utility function that synchronously reads local resource from the given +// `uri` and returns content string. +function readURI(uri) { + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(uri, "UTF-8"), + loadUsingSystemPrincipal: true + }); + let stream = channel.open2(); + + let cstream = Cc['@mozilla.org/intl/converter-input-stream;1']. + createInstance(Ci.nsIConverterInputStream); + cstream.init(stream, 'UTF-8', 0, 0); + + let str = {}; + let data = ''; + let read = 0; + do { + read = cstream.readString(0xffffffff, str); + data += str.value; + } while (read != 0); + + cstream.close(); + + return data; +} + +// We don't do anything on install & uninstall yet, but in a future +// we should allow add-ons to cleanup after uninstall. +function install(data, reason) {} +function uninstall(data, reason) {} + +function startup(data, reasonCode) { + try { + let reason = REASON[reasonCode]; + // URI for the root of the XPI file. + // 'jar:' URI if the addon is packed, 'file:' URI otherwise. + // (Used by l10n module in order to fetch `locale` folder) + let rootURI = data.resourceURI.spec; + + // TODO: Maybe we should perform read harness-options.json asynchronously, + // since we can't do anything until 'sessionstore-windows-restored' anyway. + let options = JSON.parse(readURI(rootURI + './harness-options.json')); + + let id = options.jetpackID; + let name = options.name; + + // Clean the metadata + options.metadata[name]['permissions'] = options.metadata[name]['permissions'] || {}; + + // freeze the permissionss + Object.freeze(options.metadata[name]['permissions']); + // freeze the metadata + Object.freeze(options.metadata[name]); + + // Register a new resource 'domain' for this addon which is mapping to + // XPI's `resources` folder. + // Generate the domain name by using jetpack ID, which is the extension ID + // by stripping common characters that doesn't work as a domain name: + let uuidRe = + /^\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}$/; + + let domain = id. + toLowerCase(). + replace(/@/g, '-at-'). + replace(/\./g, '-dot-'). + replace(uuidRe, '$1'); + + let prefixURI = 'resource://' + domain + '/'; + let resourcesURI = ioService.newURI(rootURI + '/resources/', null, null); + resourceHandler.setSubstitution(domain, resourcesURI); + + // Create path to URLs mapping supported by loader. + let paths = { + // Relative modules resolve to add-on package lib + './': prefixURI + name + '/lib/', + './tests/': prefixURI + name + '/tests/', + '': 'resource://gre/modules/commonjs/' + }; + + // Maps addon lib and tests ressource folders for each package + paths = Object.keys(options.metadata).reduce(function(result, name) { + result[name + '/'] = prefixURI + name + '/lib/' + result[name + '/tests/'] = prefixURI + name + '/tests/' + return result; + }, paths); + + // We need to map tests folder when we run sdk tests whose package name + // is stripped + if (name == 'addon-sdk') + paths['tests/'] = prefixURI + name + '/tests/'; + + let useBundledSDK = options['force-use-bundled-sdk']; + if (!useBundledSDK) { + try { + useBundledSDK = prefService.getBoolPref("extensions.addon-sdk.useBundledSDK"); + } + catch (e) { + // Pref doesn't exist, allow using Firefox shipped SDK + } + } + + // Starting with Firefox 21.0a1, we start using modules shipped into firefox + // Still allow using modules from the xpi if the manifest tell us to do so. + // And only try to look for sdk modules in xpi if the xpi actually ship them + if (options['is-sdk-bundled'] && + (vc.compare(appInfo.version, '21.0a1') < 0 || useBundledSDK)) { + // Maps sdk module folders to their resource folder + paths[''] = prefixURI + 'addon-sdk/lib/'; + // test.js is usually found in root commonjs or SDK_ROOT/lib/ folder, + // so that it isn't shipped in the xpi. Keep a copy of it in sdk/ folder + // until we no longer support SDK modules in XPI: + paths['test'] = prefixURI + 'addon-sdk/lib/sdk/test.js'; + } + + // Retrieve list of module folder overloads based on preferences in order to + // eventually used a local modules instead of files shipped into Firefox. + let branch = prefService.getBranch('extensions.modules.' + id + '.path'); + paths = branch.getChildList('', {}).reduce(function (result, name) { + // Allows overloading of any sub folder by replacing . by / in pref name + let path = name.substr(1).split('.').join('/'); + // Only accept overloading folder by ensuring always ending with `/` + if (path) path += '/'; + let fileURI = branch.getCharPref(name); + + // On mobile, file URI has to end with a `/` otherwise, setSubstitution + // takes the parent folder instead. + if (fileURI[fileURI.length-1] !== '/') + fileURI += '/'; + + // Maps the given file:// URI to a resource:// in order to avoid various + // failure that happens with file:// URI and be close to production env + let resourcesURI = ioService.newURI(fileURI, null, null); + let resName = 'extensions.modules.' + domain + '.commonjs.path' + name; + resourceHandler.setSubstitution(resName, resourcesURI); + + result[path] = 'resource://' + resName + '/'; + return result; + }, paths); + + // Make version 2 of the manifest + let manifest = options.manifest; + + // Import `cuddlefish.js` module using a Sandbox and bootstrap loader. + let cuddlefishPath = 'loader/cuddlefish.js'; + let cuddlefishURI = 'resource://gre/modules/commonjs/sdk/' + cuddlefishPath; + if (paths['sdk/']) { // sdk folder has been overloaded + // (from pref, or cuddlefish is still in the xpi) + cuddlefishURI = paths['sdk/'] + cuddlefishPath; + } + else if (paths['']) { // root modules folder has been overloaded + cuddlefishURI = paths[''] + 'sdk/' + cuddlefishPath; + } + + cuddlefishSandbox = loadSandbox(cuddlefishURI); + let cuddlefish = cuddlefishSandbox.exports; + + // Normalize `options.mainPath` so that it looks like one that will come + // in a new version of linker. + let main = options.mainPath; + + unload = cuddlefish.unload; + loader = cuddlefish.Loader({ + paths: paths, + // modules manifest. + manifest: manifest, + + // Add-on ID used by different APIs as a unique identifier. + id: id, + // Add-on name. + name: name, + // Add-on version. + version: options.metadata[name].version, + // Add-on package descriptor. + metadata: options.metadata[name], + // Add-on load reason. + loadReason: reason, + + prefixURI: prefixURI, + // Add-on URI. + rootURI: rootURI, + // options used by system module. + // File to write 'OK' or 'FAIL' (exit code emulation). + resultFile: options.resultFile, + // Arguments passed as --static-args + staticArgs: options.staticArgs, + + // Arguments related to test runner. + modules: { + '@test/options': { + allTestModules: options.allTestModules, + iterations: options.iterations, + filter: options.filter, + profileMemory: options.profileMemory, + stopOnError: options.stopOnError, + verbose: options.verbose, + parseable: options.parseable, + checkMemory: options.check_memory, + } + } + }); + + let module = cuddlefish.Module('sdk/loader/cuddlefish', cuddlefishURI); + let require = cuddlefish.Require(loader, module); + + require('sdk/addon/runner').startup(reason, { + loader: loader, + main: main, + prefsURI: rootURI + 'defaults/preferences/prefs.js' + }); + } catch (error) { + dump('Bootstrap error: ' + + (error.message ? error.message : String(error)) + '\n' + + (error.stack || error.fileName + ': ' + error.lineNumber) + '\n'); + throw error; + } +}; + +function loadSandbox(uri) { + let proto = { + sandboxPrototype: { + loadSandbox: loadSandbox, + ChromeWorker: ChromeWorker + } + }; + let sandbox = Cu.Sandbox(systemPrincipal, proto); + // Create a fake commonjs environnement just to enable loading loader.js + // correctly + sandbox.exports = {}; + sandbox.module = { uri: uri, exports: sandbox.exports }; + sandbox.require = function (id) { + if (id !== "chrome") + throw new Error("Bootstrap sandbox `require` method isn't implemented."); + + return Object.freeze({ Cc: Cc, Ci: Ci, Cu: Cu, Cr: Cr, Cm: Cm, + CC: bind(CC, Components), components: Components, + ChromeWorker: ChromeWorker }); + }; + scriptLoader.loadSubScript(uri, sandbox, 'UTF-8'); + return sandbox; +} + +function unloadSandbox(sandbox) { + if (Cu.getClassName(sandbox, true) == "Sandbox") + Cu.nukeSandbox(sandbox); +} + +function setTimeout(callback, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback({ notify: callback }, delay, + Ci.nsITimer.TYPE_ONE_SHOT); + return timer; +} + +function shutdown(data, reasonCode) { + let reason = REASON[reasonCode]; + if (loader) { + unload(loader, reason); + unload = null; + + // Don't waste time cleaning up if the application is shutting down + if (reason != "shutdown") { + // Avoid leaking all modules when something goes wrong with one particular + // module. Do not clean it up immediatly in order to allow executing some + // actions on addon disabling. + // We need to keep a reference to the timer, otherwise it is collected + // and won't ever fire. + nukeTimer = setTimeout(nukeModules, 1000); + } + } +}; + +function nukeModules() { + nukeTimer = null; + // module objects store `exports` which comes from sandboxes + // We should avoid keeping link to these object to avoid leaking sandboxes + for (let key in loader.modules) { + delete loader.modules[key]; + } + // Direct links to sandboxes should be removed too + for (let key in loader.sandboxes) { + let sandbox = loader.sandboxes[key]; + delete loader.sandboxes[key]; + // Bug 775067: From FF17 we can kill all CCW from a given sandbox + unloadSandbox(sandbox); + } + loader = null; + + // both `toolkit/loader` and `system/xul-app` are loaded as JSM's via + // `cuddlefish.js`, and needs to be unloaded to avoid memory leaks, when + // the addon is unload. + + unloadSandbox(cuddlefishSandbox.loaderSandbox); + unloadSandbox(cuddlefishSandbox.xulappSandbox); + + // Bug 764840: We need to unload cuddlefish otherwise it will stay alive + // and keep a reference to this compartment. + unloadSandbox(cuddlefishSandbox); + cuddlefishSandbox = null; +} diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/install.rdf b/addon-sdk/source/test/addons/l10n-properties/app-extension/install.rdf new file mode 100644 index 000000000..8fc710557 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/install.rdf @@ -0,0 +1,33 @@ +<?xml version="1.0"?> +<!-- 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/. --> + + +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>xulapp@toolness.com</em:id> + <em:version>1.0</em:version> + <em:type>2</em:type> + <em:bootstrap>true</em:bootstrap> + <em:unpack>false</em:unpack> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>21.0</em:minVersion> + <em:maxVersion>25.0a1</em:maxVersion> + </Description> + </em:targetApplication> + + <!-- Front End MetaData --> + <em:name>Test App</em:name> + <em:description>Harness for tests.</em:description> + <em:creator>Mozilla Corporation</em:creator> + <em:homepageURL></em:homepageURL> + <em:optionsType></em:optionsType> + <em:updateURL></em:updateURL> + </Description> +</RDF> diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-GB.properties b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-GB.properties new file mode 100644 index 000000000..d14e6de0b --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-GB.properties @@ -0,0 +1,28 @@ +# 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/. + +Translated= Yes + +text-content=no <b>HTML</b> injection + +downloadsCount=%d downloads +downloadsCount[one]=one download + +pluralTest=fallback to other +pluralTest[zero]=optional zero form + +explicitPlural[one]=one +explicitPlural[other]=other + +# You can use unicode char escaping in order to inject space at the beginning/ +# end of your string. (Regular spaces are automatically ignore by .properties +# file parser) +unicodeEscape = \u0020\u0040\u0020 +# this string equals to " @ " + +# bug 1033309 plurals with multiple placeholders +first_identifier[one]=first entry is %s and the second one is %s. +first_identifier=the entries are %s and %s. +second_identifier[other]=first entry is %s and the second one is %s. +third_identifier=first entry is %s and the second one is %s. diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-US.properties b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-US.properties new file mode 100644 index 000000000..487fceb1d --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/en-US.properties @@ -0,0 +1,22 @@ +# 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/. + +Translated= Yes + +text-content=no <b>HTML</b> injection + +downloadsCount=%d downloads +downloadsCount[one]=one download + +pluralTest=fallback to other +pluralTest[zero]=optional zero form + +explicitPlural[one]=one +explicitPlural[other]=other + +# You can use unicode char escaping in order to inject space at the beginning/ +# end of your string. (Regular spaces are automatically ignore by .properties +# file parser) +unicodeEscape = \u0020\u0040\u0020 +# this string equals to " @ " diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/eo.properties b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/eo.properties new file mode 100644 index 000000000..a979fca1a --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/eo.properties @@ -0,0 +1,5 @@ +# 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/. + +Translated= jes diff --git a/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/fr-FR.properties b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/fr-FR.properties new file mode 100644 index 000000000..2c5ffbb17 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/app-extension/locale/fr-FR.properties @@ -0,0 +1,14 @@ +# 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/. + +Translated= Oui + +placeholderString= Placeholder %s + +# Plural forms +%d downloads=%d téléchargements +%d downloads[one]=%d téléchargement + +downloadsCount=%d téléchargements +downloadsCount[one]=%d téléchargement diff --git a/addon-sdk/source/test/addons/l10n-properties/data/test-localization.html b/addon-sdk/source/test/addons/l10n-properties/data/test-localization.html new file mode 100644 index 000000000..5428863ad --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/data/test-localization.html @@ -0,0 +1,24 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>HTML Localization</title> + </head> + <body> + <div data-l10n-id="Not translated">Kept as-is</div> + <ul data-l10n-id="Translated"> + <li>Inner html content is replaced,</li> + <li data-l10n-id="text-content"> + Elements with data-l10n-id attribute whose parent element is translated + will be replaced by the content of the translation. + </li> + </ul> + <div data-l10n-id="text-content">No</div> + <div data-l10n-id="Translated"> + A data-l10n-id value can be used in multiple elements + </div> + </body> +</html diff --git a/addon-sdk/source/test/addons/l10n-properties/main.js b/addon-sdk/source/test/addons/l10n-properties/main.js new file mode 100644 index 000000000..b2ca0b191 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/main.js @@ -0,0 +1,202 @@ +/* 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/. */ +"use strict"; + +const prefs = require("sdk/preferences/service"); +const { Loader } = require('sdk/test/loader'); +const { resolveURI } = require('toolkit/loader'); +const { rootURI } = require("@loader/options"); +const { usingJSON } = require('sdk/l10n/json/core'); + +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +function setLocale(locale) { + prefs.set(PREF_MATCH_OS_LOCALE, false); + prefs.set(PREF_SELECTED_LOCALE, locale); +} + +function resetLocale() { + prefs.reset(PREF_MATCH_OS_LOCALE); + prefs.reset(PREF_SELECTED_LOCALE); +} + +function definePseudo(loader, id, exports) { + let uri = resolveURI(id, loader.mapping); + loader.modules[uri] = { exports: exports }; +} + +function createTest(locale, testFunction) { + return function (assert, done) { + let loader = Loader(module); + // Change the locale before loading new l10n modules in order to load + // the right .json file + setLocale(locale); + // Initialize main l10n module in order to load new locale files + loader.require("sdk/l10n/loader"). + load(rootURI). + then(function success(data) { + definePseudo(loader, '@l10n/data', data); + // Execute the given test function + try { + testFunction(assert, loader, function onDone() { + loader.unload(); + resetLocale(); + done(); + }); + } + catch(e) { + console.exception(e); + } + }, + function failure(error) { + assert.fail("Unable to load locales: " + error); + }); + }; +} + +exports.testExactMatching = createTest("fr-FR", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "Key not translated"); + assert.equal(_("Translated"), "Oui", + "Simple key translated"); + + // Placeholders + assert.equal(_("placeholderString", "works"), "Placeholder works", + "Value with placeholder"); + assert.equal(_("Placeholder %s", "works"), "Placeholder works", + "Key without value but with placeholder"); + assert.equal(_("Placeholders %2s %1s %s.", "working", "are", "correctly"), + "Placeholders are working correctly.", + "Multiple placeholders"); + + // Plurals + assert.equal(_("downloadsCount", 0), + "0 téléchargement", + "PluralForm form 'one' for 0 in french"); + assert.equal(_("downloadsCount", 1), + "1 téléchargement", + "PluralForm form 'one' for 1 in french"); + assert.equal(_("downloadsCount", 2), + "2 téléchargements", + "PluralForm form 'other' for n > 1 in french"); + + done(); +}); + +exports.testHtmlLocalization = createTest("en-GB", function(assert, loader, done) { + // Ensure initing html component that watch document creations + // Note that this module is automatically initialized in + // cuddlefish.js:Loader.main in regular addons. But it isn't for unit tests. + let loaderHtmlL10n = loader.require("sdk/l10n/html"); + loaderHtmlL10n.enable(); + + let uri = require("sdk/self").data.url("test-localization.html"); + let worker = loader.require("sdk/page-worker").Page({ + contentURL: uri, + contentScript: "new " + function ContentScriptScope() { + let nodes = document.body.querySelectorAll("*[data-l10n-id]"); + self.postMessage([nodes[0].innerHTML, + nodes[1].innerHTML, + nodes[2].innerHTML, + nodes[3].innerHTML]); + }, + onMessage: function (data) { + assert.equal( + data[0], + "Kept as-is", + "Nodes with unknown id in .properties are kept 'as-is'" + ); + assert.equal(data[1], "Yes", "HTML is translated"); + assert.equal( + data[2], + "no <b>HTML</b> injection", + "Content from .properties is text content; HTML can't be injected." + ); + assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted."); + + done(); + } + }); +}); + +exports.testEnUsLocaleName = createTest("en-GB", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "Yes", + "String with translation is correctly translated"); + + // Check Unicode char escaping sequences + assert.equal(_("unicodeEscape"), " @ ", + "Unicode escaped sequances are correctly converted"); + + // Check plural forms regular matching + assert.equal(_("downloadsCount", 0), + "0 downloads", + "PluralForm form 'other' for 0 in english"); + assert.equal(_("downloadsCount", 1), + "one download", + "PluralForm form 'one' for 1 in english"); + assert.equal(_("downloadsCount", 2), + "2 downloads", + "PluralForm form 'other' for n != 1 in english"); + + // Check optional plural forms + assert.equal(_("pluralTest", 0), + "optional zero form", + "PluralForm form 'zero' can be optionaly specified. (Isn't mandatory in english)"); + assert.equal(_("pluralTest", 1), + "fallback to other", + "If the specific plural form is missing, we fallback to 'other'"); + + // Ensure that we can omit specifying the generic key without [other] + // key[one] = ... + // key[other] = ... # Instead of `key = ...` + assert.equal(_("explicitPlural", 1), + "one", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + assert.equal(_("explicitPlural", 10), + "other", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + + assert.equal(_("first_identifier", "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier no count"); + assert.equal(_("first_identifier", 0, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 0"); + assert.equal(_("first_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "first_identifier with count = 1"); + assert.equal(_("first_identifier", 2, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 2"); + + assert.equal(_("second_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with no count"); + assert.equal(_("second_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 0"); + assert.equal(_("second_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 1"); + assert.equal(_("second_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 2"); + + assert.equal(_("third_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with no count"); + assert.equal(_("third_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 0"); + assert.equal(_("third_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 2"); + + done(); +}); + +exports.testUsingJSON = function(assert) { + assert.equal(usingJSON, false, 'not using json'); +} + +exports.testShortLocaleName = createTest("eo", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "jes", + "String with translation is correctly translated"); + + done(); +}); + + +// Before running tests, disable HTML service which is automatially enabled +// in api-utils/addon/runner.js +require('sdk/l10n/html').disable(); + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/l10n-properties/package.json b/addon-sdk/source/test/addons/l10n-properties/package.json new file mode 100644 index 000000000..1747298cb --- /dev/null +++ b/addon-sdk/source/test/addons/l10n-properties/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-l10n@jetpack", + "title": "Test L10n", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/l10n/data/test-localization.html b/addon-sdk/source/test/addons/l10n/data/test-localization.html new file mode 100644 index 000000000..5646946da --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/data/test-localization.html @@ -0,0 +1,29 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>HTML Localization</title> + </head> + <body> + <div data-l10n-id="Not translated">Kept as-is</div> + <ul data-l10n-id="Translated"> + <li>Inner html content is replaced,</li> + <li data-l10n-id="text-content"> + Elements with data-l10n-id attribute whose parent element is translated + will be replaced by the content of the translation. + </li> + </ul> + <div data-l10n-id="text-content">No</div> + <div data-l10n-id="Translated"> + A data-l10n-id value can be used in multiple elements + </div> + <a data-l10n-id="link-attributes" title="Certain whitelisted attributes get translated too" alt="No" accesskey="A"></a> + <input data-l10n-id="input" type="text" placeholder="Form placeholders are translateable"> + <menu> + <menuitem data-l10n-id="contextitem" label="Labels of select options and context menus are translateable"> + </menu> + </body> +</html diff --git a/addon-sdk/source/test/addons/l10n/locale/en.properties b/addon-sdk/source/test/addons/l10n/locale/en.properties new file mode 100644 index 000000000..c9e53ecb3 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/locale/en.properties @@ -0,0 +1,38 @@ +# 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/. + +Translated= Yes + +text-content=no <b>HTML</b> injection + +downloadsCount=%d downloads +downloadsCount[one]=one download + +pluralTest=fallback to other +pluralTest[zero]=optional zero form + +explicitPlural[one]=one +explicitPlural[other]=other + +# You can use unicode char escaping in order to inject space at the beginning/ +# end of your string. (Regular spaces are automatically ignore by .properties +# file parser) +unicodeEscape = \u0020\u0040\u0020 +# this string equals to " @ " + +# bug 1033309 plurals with multiple placeholders +first_identifier[one]=first entry is %s and the second one is %s. +first_identifier=the entries are %s and %s. +second_identifier[other]=first entry is %s and the second one is %s. +third_identifier=first entry is %s and the second one is %s. + +# bug 824489 allow translation of element attributes +link-attributes.title=Yes +link-attributes.alt=Yes +link-attributes.accesskey=B +input.placeholder=Yes +contextitem.label=Yes +link-attributes.ariaLabel=Yes +link-attributes.ariaValueText=Value +link-attributes.ariaMozHint=Hint diff --git a/addon-sdk/source/test/addons/l10n/locale/eo.properties b/addon-sdk/source/test/addons/l10n/locale/eo.properties new file mode 100644 index 000000000..a979fca1a --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/locale/eo.properties @@ -0,0 +1,5 @@ +# 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/. + +Translated= jes diff --git a/addon-sdk/source/test/addons/l10n/locale/fr-FR.properties b/addon-sdk/source/test/addons/l10n/locale/fr-FR.properties new file mode 100644 index 000000000..2c5ffbb17 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/locale/fr-FR.properties @@ -0,0 +1,14 @@ +# 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/. + +Translated= Oui + +placeholderString= Placeholder %s + +# Plural forms +%d downloads=%d téléchargements +%d downloads[one]=%d téléchargement + +downloadsCount=%d téléchargements +downloadsCount[one]=%d téléchargement diff --git a/addon-sdk/source/test/addons/l10n/main.js b/addon-sdk/source/test/addons/l10n/main.js new file mode 100644 index 000000000..9409df7ef --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/main.js @@ -0,0 +1,289 @@ +/* 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/. */ +"use strict"; + +const prefs = require("sdk/preferences/service"); +const { Loader } = require('sdk/test/loader'); +const { resolveURI } = require('toolkit/loader'); +const { rootURI, isNative } = require("@loader/options"); +const { usingJSON } = require('sdk/l10n/json/core'); + +const PREF_MATCH_OS_LOCALE = "intl.locale.matchOS"; +const PREF_SELECTED_LOCALE = "general.useragent.locale"; + +function setLocale(locale) { + prefs.set(PREF_MATCH_OS_LOCALE, false); + prefs.set(PREF_SELECTED_LOCALE, locale); +} + +function resetLocale() { + prefs.reset(PREF_MATCH_OS_LOCALE); + prefs.reset(PREF_SELECTED_LOCALE); +} + +function definePseudo(loader, id, exports) { + let uri = resolveURI(id, loader.mapping); + loader.modules[uri] = { exports: exports }; +} + +function createTest(locale, testFunction) { + return function (assert, done) { + let loader = Loader(module); + // Change the locale before loading new l10n modules in order to load + // the right .json file + setLocale(locale); + // Initialize main l10n module in order to load new locale files + loader.require("sdk/l10n/loader"). + load(rootURI). + then(null, function failure(error) { + if (!isNative) + assert.fail("Unable to load locales: " + error); + }). + then(function success(data) { + definePseudo(loader, '@l10n/data', data ? data : null); + // Execute the given test function + try { + testFunction(assert, loader, function onDone() { + loader.unload(); + resetLocale(); + done(); + }); + } + catch(e) { + console.exception(e); + } + }, + function failure(error) { + assert.fail("Unable to load locales: " + error); + }); + }; +} + +exports.testExactMatching = createTest("fr-FR", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "Key not translated"); + assert.equal(_("Translated"), "Oui", + "Simple key translated"); + + // Placeholders + assert.equal(_("placeholderString", "works"), "Placeholder works", + "Value with placeholder"); + assert.equal(_("Placeholder %s", "works"), "Placeholder works", + "Key without value but with placeholder"); + assert.equal(_("Placeholders %2s %1s %s.", "working", "are", "correctly"), + "Placeholders are working correctly.", + "Multiple placeholders"); + + // Plurals + assert.equal(_("downloadsCount", 0), + "0 téléchargement", + "PluralForm form 'one' for 0 in french"); + assert.equal(_("downloadsCount", 1), + "1 téléchargement", + "PluralForm form 'one' for 1 in french"); + assert.equal(_("downloadsCount", 2), + "2 téléchargements", + "PluralForm form 'other' for n > 1 in french"); + + done(); +}); + +exports.testHtmlLocalizationPageWorker = createTest("en-GB", function(assert, loader, done) { + // Ensure initing html component that watch document creations + // Note that this module is automatically initialized in + // cuddlefish.js:Loader.main in regular addons. But it isn't for unit tests. + let loaderHtmlL10n = loader.require("sdk/l10n/html"); + loaderHtmlL10n.enable(); + + let uri = require("sdk/self").data.url("test-localization.html"); + let worker = loader.require("sdk/page-worker").Page({ + contentURL: uri, + contentScript: "new " + function ContentScriptScope() { + let nodes = document.body.querySelectorAll("*[data-l10n-id]"); + self.postMessage([nodes[0].innerHTML, + nodes[1].innerHTML, + nodes[2].innerHTML, + nodes[3].innerHTML, + nodes[4].title, + nodes[4].getAttribute("alt"), + nodes[4].getAttribute("accesskey"), + nodes[4].getAttribute("aria-label"), + nodes[4].getAttribute("aria-valuetext"), + nodes[4].getAttribute("aria-moz-hint"), + nodes[5].placeholder, + nodes[6].label]); + }, + onMessage: function (data) { + assert.equal( + data[0], + "Kept as-is", + "Nodes with unknown id in .properties are kept 'as-is'" + ); + assert.equal(data[1], "Yes", "HTML is translated"); + assert.equal( + data[2], + "no <b>HTML</b> injection", + "Content from .properties is text content; HTML can't be injected." + ); + assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted."); + + // Attribute translation tests + assert.equal(data[4], "Yes", "Title attributes gets translated."); + assert.equal(data[5], "Yes", "Alt attributes gets translated."); + assert.equal(data[6], "B", "Accesskey gets translated."); + + assert.equal(data[7], "Yes", "Aria-Label gets translated."); + assert.equal(data[8], "Value", "Aria-valuetext gets translated."); + assert.equal(data[9], "Hint", "Aria-moz-hint gets translated."); + + assert.equal(data[10], "Yes", "Form placeholders are translateable."); + + assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable."); + + done(); + } + }); +}); + +exports.testHtmlLocalization = createTest("en-GB", function(assert, loader, done) { + // Ensure initing html component that watch document creations + // Note that this module is automatically initialized in + // cuddlefish.js:Loader.main in regular addons. But it isn't for unit tests. + let loaderHtmlL10n = loader.require("sdk/l10n/html"); + loaderHtmlL10n.enable(); + + let uri = require("sdk/self").data.url("test-localization.html"); + loader.require("sdk/tabs").open({ + url: uri, + onReady: function(tab) { + tab.attach({ + contentURL: uri, + contentScript: "new " + function ContentScriptScope() { + let nodes = document.body.querySelectorAll("*[data-l10n-id]"); + self.postMessage([nodes[0].innerHTML, + nodes[1].innerHTML, + nodes[2].innerHTML, + nodes[3].innerHTML, + nodes[4].title, + nodes[4].getAttribute("alt"), + nodes[4].getAttribute("accesskey"), + nodes[4].getAttribute("aria-label"), + nodes[4].getAttribute("aria-valuetext"), + nodes[4].getAttribute("aria-moz-hint"), + nodes[5].placeholder, + nodes[6].label]); + }, + onMessage: function (data) { + assert.equal( + data[0], + "Kept as-is", + "Nodes with unknown id in .properties are kept 'as-is'" + ); + assert.equal(data[1], "Yes", "HTML is translated"); + assert.equal( + data[2], + "no <b>HTML</b> injection", + "Content from .properties is text content; HTML can't be injected." + ); + assert.equal(data[3], "Yes", "Multiple elements with same data-l10n-id are accepted."); + + // Attribute translation tests + assert.equal(data[4], "Yes", "Title attributes gets translated."); + assert.equal(data[5], "Yes", "Alt attributes gets translated."); + assert.equal(data[6], "B", "Accesskey gets translated."); + + assert.equal(data[7], "Yes", "Aria-Label gets translated."); + assert.equal(data[8], "Value", "Aria-valuetext gets translated."); + assert.equal(data[9], "Hint", "Aria-moz-hint gets translated."); + + assert.equal(data[10], "Yes", "Form placeholders are translateable."); + + assert.equal(data[11], "Yes", "Labels of select options and context menus are translateable."); + + tab.close(done); + } + }); + } + }); +}); + +exports.testEnUsLocaleName = createTest("en-US", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "Yes", + "String with translation is correctly translated"); + + // Check Unicode char escaping sequences + assert.equal(_("unicodeEscape"), " @ ", + "Unicode escaped sequances are correctly converted"); + + // Check plural forms regular matching + assert.equal(_("downloadsCount", 0), + "0 downloads", + "PluralForm form 'other' for 0 in english"); + assert.equal(_("downloadsCount", 1), + "one download", + "PluralForm form 'one' for 1 in english"); + assert.equal(_("downloadsCount", 2), + "2 downloads", + "PluralForm form 'other' for n != 1 in english"); + + // Check optional plural forms + assert.equal(_("pluralTest", 0), + "optional zero form", + "PluralForm form 'zero' can be optionaly specified. (Isn't mandatory in english)"); + assert.equal(_("pluralTest", 1), + "fallback to other", + "If the specific plural form is missing, we fallback to 'other'"); + + // Ensure that we can omit specifying the generic key without [other] + // key[one] = ... + // key[other] = ... # Instead of `key = ...` + assert.equal(_("explicitPlural", 1), + "one", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + assert.equal(_("explicitPlural", 10), + "other", + "PluralForm form can be omitting generic key [i.e. without ...[other] at end of key)"); + + assert.equal(_("first_identifier", "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier no count"); + assert.equal(_("first_identifier", 0, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 0"); + assert.equal(_("first_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "first_identifier with count = 1"); + assert.equal(_("first_identifier", 2, "ONE", "TWO"), "the entries are ONE and TWO.", "first_identifier with count = 2"); + + assert.equal(_("second_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with no count"); + assert.equal(_("second_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 0"); + assert.equal(_("second_identifier", 1, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 1"); + assert.equal(_("second_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "second_identifier with count = 2"); + + assert.equal(_("third_identifier", "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with no count"); + assert.equal(_("third_identifier", 0, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 0"); + assert.equal(_("third_identifier", 2, "ONE", "TWO"), "first entry is ONE and the second one is TWO.", "third_identifier with count = 2"); + + done(); +}); + +exports.testUsingJSON = function(assert) { + assert.equal(usingJSON, !isNative, 'using json'); +} + +exports.testShortLocaleName = createTest("eo", function(assert, loader, done) { + let _ = loader.require("sdk/l10n").get; + assert.equal(_("Not translated"), "Not translated", + "String w/o translation is kept as-is"); + assert.equal(_("Translated"), "jes", + "String with translation is correctly translated"); + + done(); +}); + + +// Before running tests, disable HTML service which is automatially enabled +// in api-utils/addon/runner.js +require('sdk/l10n/html').disable(); + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/l10n/package.json b/addon-sdk/source/test/addons/l10n/package.json new file mode 100644 index 000000000..4847e1471 --- /dev/null +++ b/addon-sdk/source/test/addons/l10n/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-l10n@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/layout-change/lib/main.js b/addon-sdk/source/test/addons/layout-change/lib/main.js new file mode 100644 index 000000000..9cae9ab31 --- /dev/null +++ b/addon-sdk/source/test/addons/layout-change/lib/main.js @@ -0,0 +1,15 @@ +/* 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/. */ +"use strict"; + +const { isNative } = require("@loader/options"); + +if (isNative) { + module.exports = require("./test-toolkit-loader"); +} +else { + module.exports = require("./test-cuddlefish-loader"); +} + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/layout-change/lib/test-cuddlefish-loader.js b/addon-sdk/source/test/addons/layout-change/lib/test-cuddlefish-loader.js new file mode 100644 index 000000000..8b9f8dc56 --- /dev/null +++ b/addon-sdk/source/test/addons/layout-change/lib/test-cuddlefish-loader.js @@ -0,0 +1,164 @@ +/* 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/. */ +"use strict"; + +const { LoaderWithHookedConsole } = require('sdk/test/loader'); +const { loader } = LoaderWithHookedConsole(module); +const app = require("sdk/system/xul-app"); + +// This test makes sure that require statements used by all AMO hosted +// add-ons will be able to use old require statements. +// Tests are based on following usage data: +// https://docs.google.com/spreadsheet/ccc?key=0ApEBy-GRnGxzdHlRMHJ5RXN1aWJ4RGhINkxSd0FCQXc#gid=0 + +exports["test compatibility"] = function(assert) { + let { require } = loader; + + assert.equal(require("self"), + require("sdk/self"), "sdk/self -> self"); + + assert.equal(require("tabs"), + require("sdk/tabs"), "sdk/tabs -> tabs"); + + if (app.is("Firefox")) { + assert.throws(() => require("widget"), + /Module `widget` is not found at/, + "There is no widget module"); + + assert.throws(() => require("sdk/widget"), + /Module `sdk\/widget` is not found at/, + "There is no sdk/widget module"); + } + + assert.equal(require("page-mod"), + require("sdk/page-mod"), "sdk/page-mod -> page-mod"); + + if (app.is("Firefox")) { + assert.equal(require("panel"), + require("sdk/panel"), "sdk/panel -> panel"); + } + + assert.equal(require("request"), + require("sdk/request"), "sdk/request -> request"); + + assert.equal(require("chrome"), + require("chrome"), "chrome -> chrome"); + + assert.equal(require("simple-storage"), + require("sdk/simple-storage"), "sdk/simple-storage -> simple-storage"); + + if (app.is("Firefox")) { + assert.equal(require("context-menu"), + require("sdk/context-menu"), "sdk/context-menu -> context-menu"); + } + + assert.equal(require("notifications"), + require("sdk/notifications"), "sdk/notifications -> notifications"); + + assert.equal(require("preferences-service"), + require("sdk/preferences/service"), "sdk/preferences/service -> preferences-service"); + + assert.equal(require("window-utils"), + require("sdk/deprecated/window-utils"), "sdk/deprecated/window-utils -> window-utils"); + + assert.equal(require("url"), + require("sdk/url"), "sdk/url -> url"); + + if (app.is("Firefox")) { + assert.equal(require("selection"), + require("sdk/selection"), "sdk/selection -> selection"); + } + + assert.equal(require("timers"), + require("sdk/timers"), "sdk/timers -> timers"); + + assert.equal(require("simple-prefs"), + require("sdk/simple-prefs"), "sdk/simple-prefs -> simple-prefs"); + + assert.equal(require("traceback"), + require("sdk/console/traceback"), "sdk/console/traceback -> traceback"); + + assert.equal(require("unload"), + require("sdk/system/unload"), "sdk/system/unload -> unload"); + + assert.equal(require("hotkeys"), + require("sdk/hotkeys"), "sdk/hotkeys -> hotkeys"); + + if (app.is("Firefox")) { + assert.equal(require("clipboard"), + require("sdk/clipboard"), "sdk/clipboard -> clipboard"); + } + + assert.equal(require("windows"), + require("sdk/windows"), "sdk/windows -> windows"); + + assert.equal(require("page-worker"), + require("sdk/page-worker"), "sdk/page-worker -> page-worker"); + + assert.equal(require("timer"), + require("sdk/timers"), "sdk/timers -> timer"); + + assert.equal(require("xhr"), + require("sdk/net/xhr"), "sdk/io/xhr -> xhr"); + + assert.equal(require("private-browsing"), + require("sdk/private-browsing"), "sdk/private-browsing -> private-browsing"); + + assert.equal(require("passwords"), + require("sdk/passwords"), "sdk/passwords -> passwords"); + + assert.equal(require("match-pattern"), + require("sdk/util/match-pattern"), "sdk/util/match-pattern -> match-pattern"); + + assert.equal(require("file"), + require("sdk/io/file"), "sdk/io/file -> file"); + + assert.equal(require("xul-app"), + require("sdk/system/xul-app"), "sdk/system/xul-app -> xul-app"); + + assert.equal(require("api-utils"), + require("sdk/deprecated/api-utils"), "sdk/deprecated/api-utils -> api-utils"); + + assert.equal(require("runtime"), + require("sdk/system/runtime"), "sdk/system/runtime -> runtime"); + + assert.equal(require("base64"), + require("sdk/base64"), "sdk/base64 -> base64"); + + assert.equal(require("xpcom"), + require("sdk/platform/xpcom"), "sdk/platform/xpcom -> xpcom"); + + assert.equal(require("keyboard/utils"), + require("sdk/keyboard/utils"), "sdk/keyboard/utils -> keyboard/utils"); + + assert.equal(require("system"), + require("sdk/system"), "sdk/system -> system"); + + assert.equal(require("querystring"), + require("sdk/querystring"), "sdk/querystring -> querystring"); + + assert.equal(require("tabs/utils"), + require("sdk/tabs/utils"), "sdk/tabs/utils -> tabs/utils"); + + assert.equal(require("dom/events"), + require("sdk/dom/events-shimmed"), "sdk/dom/events-shimmed -> dom/events"); + + assert.equal(require("tabs/tab.js"), + require("sdk/tabs/tab"), "sdk/tabs/tab -> tabs/tab.js"); + + assert.equal(require("environment"), + require("sdk/system/environment"), "sdk/system/environment -> environment"); + + assert.equal(require("test/assert"), + require("sdk/test/assert"), "sdk/test/assert -> test/assert"); + + assert.equal(require("hidden-frame"), + require("sdk/frame/hidden-frame"), "sdk/frame/hidden-frame -> hidden-frame"); + + assert.equal(require("collection"), + require("sdk/util/collection"), "sdk/util/collection -> collection"); + + assert.equal(require("array"), + require("sdk/util/array"), "sdk/util/array -> array"); +}; diff --git a/addon-sdk/source/test/addons/layout-change/lib/test-toolkit-loader.js b/addon-sdk/source/test/addons/layout-change/lib/test-toolkit-loader.js new file mode 100644 index 000000000..3f8123471 --- /dev/null +++ b/addon-sdk/source/test/addons/layout-change/lib/test-toolkit-loader.js @@ -0,0 +1,10 @@ +/* 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/. */ +"use strict"; + +exports["test compatibility"] = function(assert) { + assert.throws(() => require("self"), + /^Module `self` is not found/, + "sdk/self -> self"); +}; diff --git a/addon-sdk/source/test/addons/layout-change/package.json b/addon-sdk/source/test/addons/layout-change/package.json new file mode 100644 index 000000000..fe370f5a6 --- /dev/null +++ b/addon-sdk/source/test/addons/layout-change/package.json @@ -0,0 +1,7 @@ +{ + "id": "test-layout-change@jetpack", + "name": "test-layout-change", + "ignore-deprecated-path": true, + "version": "0.0.2", + "main": "./lib/main.js" +} diff --git a/addon-sdk/source/test/addons/main/main.js b/addon-sdk/source/test/addons/main/main.js new file mode 100644 index 000000000..6cab5d005 --- /dev/null +++ b/addon-sdk/source/test/addons/main/main.js @@ -0,0 +1,37 @@ +/* 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/. */ +'use strict'; + +const { setTimeout } = require('sdk/timers'); + +var mainStarted = false; + +exports.main = function main(options, callbacks) { + mainStarted = true; + + let tests = {}; + + tests.testMainArguments = function(assert) { + assert.ok(!!options, 'options argument provided to main'); + assert.ok('loadReason' in options, 'loadReason is in options provided by main'); + assert.equal(typeof callbacks.print, 'function', 'callbacks.print is a function'); + assert.equal(typeof callbacks.quit, 'function', 'callbacks.quit is a function'); + + // Re-enable when bug 1251664 is fixed + //assert.equal(options.loadReason, 'install', 'options.loadReason is install'); + } + + require('sdk/test/runner').runTestsFromModule({exports: tests}); +} + +// this causes a fail if main does not start +setTimeout(function() { + if (mainStarted) + return; + + // main didn't start, fail.. + require("sdk/test/runner").runTestsFromModule({exports: { + testFail: assert => assert.fail('Main did not start..') + }}); +}, 500); diff --git a/addon-sdk/source/test/addons/main/package.json b/addon-sdk/source/test/addons/main/package.json new file mode 100644 index 000000000..9698810b2 --- /dev/null +++ b/addon-sdk/source/test/addons/main/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-main@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/name-in-numbers-plus/index.js b/addon-sdk/source/test/addons/name-in-numbers-plus/index.js new file mode 100644 index 000000000..461652dbe --- /dev/null +++ b/addon-sdk/source/test/addons/name-in-numbers-plus/index.js @@ -0,0 +1,12 @@ +/* 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/. */ +"use strict"; + +const { name } = require("sdk/self"); + +exports["test self.name"] = (assert) => { + assert.equal(name, "0-0", "using '0-0' works."); +} + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/name-in-numbers-plus/package.json b/addon-sdk/source/test/addons/name-in-numbers-plus/package.json new file mode 100644 index 000000000..f43793b27 --- /dev/null +++ b/addon-sdk/source/test/addons/name-in-numbers-plus/package.json @@ -0,0 +1,6 @@ +{ + "id": "name-in-numbers-plus@jetpack", + "name": "0-0", + "main": "./index.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/name-in-numbers/index.js b/addon-sdk/source/test/addons/name-in-numbers/index.js new file mode 100644 index 000000000..e3b31243d --- /dev/null +++ b/addon-sdk/source/test/addons/name-in-numbers/index.js @@ -0,0 +1,12 @@ +/* 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/. */ +"use strict"; + +const { name } = require("sdk/self"); + +exports["test self.name"] = (assert) => { + assert.equal(name, "5", "using '5' works."); +} + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/name-in-numbers/package.json b/addon-sdk/source/test/addons/name-in-numbers/package.json new file mode 100644 index 000000000..e2be0b628 --- /dev/null +++ b/addon-sdk/source/test/addons/name-in-numbers/package.json @@ -0,0 +1,6 @@ +{ + "id": "name-in-numbers@jetpack", + "name": "5", + "main": "./index.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/packaging/main.js b/addon-sdk/source/test/addons/packaging/main.js new file mode 100644 index 000000000..87b2a0347 --- /dev/null +++ b/addon-sdk/source/test/addons/packaging/main.js @@ -0,0 +1,57 @@ +/* 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/. */ +"use strict"; + +var options = require("@loader/options"); +var metadata = {}; +if (options.isNative) { + metadata = require("./package.json"); + metadata.permissions = {}; + Object.freeze(metadata); +} +else { + metadata = options.metadata; +} + +exports.testPackaging = function(assert) { + assert.equal(metadata.description, + "Add-on development made easy.", + "packaging metadata should be available"); + try { + metadata.description = 'new description'; + assert.fail('should not have been able to set options.metadata property'); + } + catch (e) {} + + assert.equal(metadata.description, + "Add-on development made easy.", + "packaging metadata should be frozen"); + + assert.equal(metadata.permissions['private-browsing'], undefined, + "private browsing metadata should be undefined"); + + assert.equal(metadata['private-browsing'], undefined, + "private browsing metadata should be be frozen"); + + assert.equal(options['private-browsing'], undefined, + "private browsing metadata should be be frozen"); + + try { + metadata['private-browsing'] = true; + assert.fail('should not have been able to set options.metadata property'); + } + catch(e) {} + assert.equal(metadata['private-browsing'], undefined, + "private browsing metadata should be be frozen"); + + try { + options.permissions['private-browsing'] = true; + assert.fail('should not have been able to set options.metadata.permissions property'); + } + catch (e) {} + assert.equal(metadata.permissions['private-browsing'], undefined, + "private browsing metadata should be be frozen"); +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/packaging/package.json b/addon-sdk/source/test/addons/packaging/package.json new file mode 100644 index 000000000..1514f387e --- /dev/null +++ b/addon-sdk/source/test/addons/packaging/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-packaging@jetpack", + "description": "Add-on development made easy.", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/packed/main.js b/addon-sdk/source/test/addons/packed/main.js new file mode 100644 index 000000000..9be0cc4b0 --- /dev/null +++ b/addon-sdk/source/test/addons/packed/main.js @@ -0,0 +1,20 @@ +/* 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/. */ +"use strict"; + +const { packed } = require("sdk/self"); +const url = require("sdk/url"); + +exports["test self.packed"] = function (assert) { + assert.ok(packed, "require('sdk/self').packed is correct"); +} + +exports["test url.toFilename"] = function (assert) { + assert.throws( + function() { url.toFilename(module.uri); }, + /cannot map to filename: /, + "url.toFilename() can fail for packed XPIs"); +} + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/packed/package.json b/addon-sdk/source/test/addons/packed/package.json new file mode 100644 index 000000000..1c6556e44 --- /dev/null +++ b/addon-sdk/source/test/addons/packed/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-url@jetpack", + "unpack": false, + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/page-mod-debugger-post/data/index.html b/addon-sdk/source/test/addons/page-mod-debugger-post/data/index.html new file mode 100644 index 000000000..4128d6de2 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-post/data/index.html @@ -0,0 +1,11 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>Page Mod Debugger Test</title> + </head> + <body></body> +</html> diff --git a/addon-sdk/source/test/addons/page-mod-debugger-post/data/script.js b/addon-sdk/source/test/addons/page-mod-debugger-post/data/script.js new file mode 100644 index 000000000..ee248d461 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-post/data/script.js @@ -0,0 +1,16 @@ +/* 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/. */ +'use strict'; + +function runDebuggerStatement () { + window.document.body.setAttribute('style', 'background-color: red'); + debugger; + window.document.body.setAttribute('style', 'background-color: green'); +} + +exportFunction( + runDebuggerStatement, + document.defaultView, + { defineAs: "runDebuggerStatement" } +); diff --git a/addon-sdk/source/test/addons/page-mod-debugger-post/main.js b/addon-sdk/source/test/addons/page-mod-debugger-post/main.js new file mode 100644 index 000000000..703399c21 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-post/main.js @@ -0,0 +1,136 @@ +/* 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/. */ +"use strict"; + +const { Cu } = require('chrome'); +const { PageMod } = require('sdk/page-mod'); +const tabs = require('sdk/tabs'); +const { closeTab } = require('sdk/tabs/utils'); +const promise = require('sdk/core/promise') +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { data } = require('sdk/self'); +const { set } = require('sdk/preferences/service'); + +const { require: devtoolsRequire } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { DebuggerServer } = devtoolsRequire("devtools/server/main"); +const { DebuggerClient } = devtoolsRequire("devtools/shared/client/main"); + +var gClient; +var ok; +var testName = 'testDebugger'; +var iframeURL = 'data:text/html;charset=utf-8,' + testName; +var TAB_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent('<iframe src="' + iframeURL + '" />'); +TAB_URL = data.url('index.html'); +var mod; + +exports.testDebugger = function(assert, done) { + ok = assert.ok.bind(assert); + assert.pass('starting test'); + set('devtools.debugger.log', true); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect((aType, aTraits) => { + tabs.open({ + url: TAB_URL, + onLoad: function(tab) { + assert.pass('tab loaded'); + + attachTabActorForUrl(gClient, TAB_URL). + then(_ => { assert.pass('attachTabActorForUrl called'); return _; }). + then(attachThread). + then(testDebuggerStatement). + then(_ => { assert.pass('testDebuggerStatement called') }). + then(closeConnection). + then(_ => { assert.pass('closeConnection called') }). + then(_ => { tab.close() }). + then(done). + then(null, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + }); + } + }); + }); +} + +function attachThread([aGrip, aResponse]) { + let deferred = promise.defer(); + + // Now attach and resume... + gClient.request({ to: aResponse.threadActor, type: "attach" }, () => { + gClient.request({ to: aResponse.threadActor, type: "resume" }, () => { + ok(true, "Pause wasn't called before we've attached."); + deferred.resolve([aGrip, aResponse]); + }); + }); + + return deferred.promise; +} + +function testDebuggerStatement([aGrip, aResponse]) { + let deferred = promise.defer(); + ok(aGrip, 'aGrip existss') + + gClient.addListener("paused", (aEvent, aPacket) => { + ok(true, 'there was a pause event'); + gClient.request({ to: aResponse.threadActor, type: "resume" }, () => { + ok(true, "The pause handler was triggered on a debugger statement."); + deferred.resolve(); + }); + }); + + mod = PageMod({ + include: TAB_URL, + attachTo: ['existing', 'top', 'frame'], + contentScriptFile: data.url('script.js'), + onAttach: function(mod) { + ok(true, 'the page-mod was attached to ' + mod.tab.url); + + require('sdk/timers').setTimeout(function() { + let debuggee = getMostRecentBrowserWindow().gBrowser.selectedBrowser.contentWindow.wrappedJSObject; + debuggee.runDebuggerStatement(); + ok(true, 'called runDebuggerStatement'); + }, 500) + } + }); + ok(true, 'PageMod was created'); + + return deferred.promise; +} + +function getTabActorForUrl(aClient, aUrl) { + let deferred = promise.defer(); + + aClient.listTabs(aResponse => { + let tabActor = aResponse.tabs.filter(aGrip => aGrip.url == aUrl).pop(); + deferred.resolve(tabActor); + }); + + return deferred.promise; +} + +function attachTabActorForUrl(aClient, aUrl) { + let deferred = promise.defer(); + + getTabActorForUrl(aClient, aUrl).then(aGrip => { + aClient.attachTab(aGrip.actor, aResponse => { + deferred.resolve([aGrip, aResponse]); + }); + }); + + return deferred.promise; +} + +function closeConnection() { + let deferred = promise.defer(); + gClient.close(deferred.resolve); + return deferred.promise; +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/page-mod-debugger-post/package.json b/addon-sdk/source/test/addons/page-mod-debugger-post/package.json new file mode 100644 index 000000000..c687ce0ab --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-post/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-page-mod-debugger@jetpack", + "author": "Erik Vold", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/page-mod-debugger-pre/data/index.html b/addon-sdk/source/test/addons/page-mod-debugger-pre/data/index.html new file mode 100644 index 000000000..4128d6de2 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-pre/data/index.html @@ -0,0 +1,11 @@ +<!-- 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/. --> + +<html> + <head> + <meta charset="UTF-8"> + <title>Page Mod Debugger Test</title> + </head> + <body></body> +</html> diff --git a/addon-sdk/source/test/addons/page-mod-debugger-pre/data/script.js b/addon-sdk/source/test/addons/page-mod-debugger-pre/data/script.js new file mode 100644 index 000000000..ee248d461 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-pre/data/script.js @@ -0,0 +1,16 @@ +/* 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/. */ +'use strict'; + +function runDebuggerStatement () { + window.document.body.setAttribute('style', 'background-color: red'); + debugger; + window.document.body.setAttribute('style', 'background-color: green'); +} + +exportFunction( + runDebuggerStatement, + document.defaultView, + { defineAs: "runDebuggerStatement" } +); diff --git a/addon-sdk/source/test/addons/page-mod-debugger-pre/main.js b/addon-sdk/source/test/addons/page-mod-debugger-pre/main.js new file mode 100644 index 000000000..366e9e437 --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-pre/main.js @@ -0,0 +1,134 @@ +/* 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/. */ +"use strict"; + +const { Cu } = require('chrome'); +const { PageMod } = require('sdk/page-mod'); +const tabs = require('sdk/tabs'); +const { closeTab } = require('sdk/tabs/utils'); +const promise = require('sdk/core/promise') +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { data } = require('sdk/self'); +const { set } = require('sdk/preferences/service'); + +const { require: devtoolsRequire } = Cu.import("resource://devtools/shared/Loader.jsm", {}); +const { DebuggerServer } = devtoolsRequire("devtools/server/main"); +const { DebuggerClient } = devtoolsRequire("devtools/shared/client/main"); + +var gClient; +var ok; +var testName = 'testDebugger'; +var iframeURL = 'data:text/html;charset=utf-8,' + testName; +var TAB_URL = 'data:text/html;charset=utf-8,' + encodeURIComponent('<iframe src="' + iframeURL + '" />'); +TAB_URL = data.url('index.html'); +var mod; + +exports.testDebugger = function(assert, done) { + ok = assert.ok.bind(assert); + assert.pass('starting test'); + set('devtools.debugger.log', true); + + mod = PageMod({ + include: TAB_URL, + attachTo: ['existing', 'top', 'frame'], + contentScriptFile: data.url('script.js'), + }); + ok(true, 'PageMod was created'); + + if (!DebuggerServer.initialized) { + DebuggerServer.init(); + DebuggerServer.addBrowserActors(); + } + + let transport = DebuggerServer.connectPipe(); + gClient = new DebuggerClient(transport); + gClient.connect((aType, aTraits) => { + tabs.open({ + url: TAB_URL, + onLoad: function(tab) { + assert.pass('tab loaded'); + + attachTabActorForUrl(gClient, TAB_URL). + then(_ => { assert.pass('attachTabActorForUrl called'); return _; }). + then(attachThread). + then(testDebuggerStatement). + then(_ => { assert.pass('testDebuggerStatement called') }). + then(closeConnection). + then(_ => { assert.pass('closeConnection called') }). + then(_ => { tab.close() }). + then(done). + then(null, aError => { + ok(false, "Got an error: " + aError.message + "\n" + aError.stack); + }); + } + }); + }); +} + +function attachThread([aGrip, aResponse]) { + let deferred = promise.defer(); + + // Now attach and resume... + gClient.request({ to: aResponse.threadActor, type: "attach" }, () => { + gClient.request({ to: aResponse.threadActor, type: "resume" }, () => { + ok(true, "Pause wasn't called before we've attached."); + deferred.resolve([aGrip, aResponse]); + }); + }); + + return deferred.promise; +} + +function testDebuggerStatement([aGrip, aResponse]) { + let deferred = promise.defer(); + ok(aGrip, 'aGrip existss') + + gClient.addListener("paused", (aEvent, aPacket) => { + ok(true, 'there was a pause event'); + gClient.request({ to: aResponse.threadActor, type: "resume" }, () => { + ok(true, "The pause handler was triggered on a debugger statement."); + deferred.resolve(); + }); + }); + + let debuggee = getMostRecentBrowserWindow().gBrowser.selectedBrowser.contentWindow.wrappedJSObject; + debuggee.runDebuggerStatement(); + ok(true, 'called runDebuggerStatement'); + + return deferred.promise; +} + +function getTabActorForUrl(aClient, aUrl) { + let deferred = promise.defer(); + + aClient.listTabs(aResponse => { + let tabActor = aResponse.tabs.filter(aGrip => aGrip.url == aUrl).pop(); + deferred.resolve(tabActor); + }); + + return deferred.promise; +} + +function attachTabActorForUrl(aClient, aUrl) { + let deferred = promise.defer(); + + getTabActorForUrl(aClient, aUrl).then(aGrip => { + aClient.attachTab(aGrip.actor, aResponse => { + deferred.resolve([aGrip, aResponse]); + }); + }); + + return deferred.promise; +} + +function closeConnection() { + let deferred = promise.defer(); + gClient.close(deferred.resolve); + return deferred.promise; +} + +// bug 1042976 - temporary test disable +module.exports = {}; + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/page-mod-debugger-pre/package.json b/addon-sdk/source/test/addons/page-mod-debugger-pre/package.json new file mode 100644 index 000000000..c687ce0ab --- /dev/null +++ b/addon-sdk/source/test/addons/page-mod-debugger-pre/package.json @@ -0,0 +1,6 @@ +{ + "id": "test-page-mod-debugger@jetpack", + "author": "Erik Vold", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/page-worker/data/page.html b/addon-sdk/source/test/addons/page-worker/data/page.html new file mode 100644 index 000000000..3b59d3963 --- /dev/null +++ b/addon-sdk/source/test/addons/page-worker/data/page.html @@ -0,0 +1,9 @@ +<!DOCTYPE html> + +<html> +<head> +<script type="text/javascript" src="page.js"></script> +</head> +<body> +</body> +</html> diff --git a/addon-sdk/source/test/addons/page-worker/data/page.js b/addon-sdk/source/test/addons/page-worker/data/page.js new file mode 100644 index 000000000..04d062497 --- /dev/null +++ b/addon-sdk/source/test/addons/page-worker/data/page.js @@ -0,0 +1,13 @@ +window.addEventListener("load", function() { + addon.port.emit("load", "ok"); +}); + +addon.postMessage("first message"); +addon.on("message", function(msg) { + if (msg == "ping") + addon.postMessage("pong"); +}); + +addon.port.on("ping", function() { + addon.port.emit("pong"); +}); diff --git a/addon-sdk/source/test/addons/page-worker/main.js b/addon-sdk/source/test/addons/page-worker/main.js new file mode 100644 index 000000000..46b203d2e --- /dev/null +++ b/addon-sdk/source/test/addons/page-worker/main.js @@ -0,0 +1,53 @@ +/* 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/. */ + +"use strict"; + +const { Page } = require("sdk/page-worker"); +const { data } = require("sdk/self"); + +exports["test page load"] = function(assert, done) { + let page = Page({ + contentURL: data.url("page.html") + }); + + page.port.on("load", function(check) { + assert.equal(check, "ok", "saw the load message"); + page.destroy(); + done(); + }); +}; + +exports["test postMessage"] = function(assert, done) { + let page = Page({ + contentURL: data.url("page.html"), + onMessage: function(msg) { + if (msg == "pong") { + assert.ok(true, "saw the second message"); + page.destroy(); + done(); + return; + } + + assert.equal(msg, "first message", "saw the first message"); + this.postMessage("ping"); + } + }); +}; + +exports["test port"] = function(assert, done) { + let page = Page({ + contentURL: data.url("page.html") + }); + + page.port.on("pong", function() { + assert.ok(true, "saw the response"); + page.destroy(); + done(); + }); + + page.port.emit("ping"); +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/page-worker/package.json b/addon-sdk/source/test/addons/page-worker/package.json new file mode 100644 index 000000000..d7856fac5 --- /dev/null +++ b/addon-sdk/source/test/addons/page-worker/package.json @@ -0,0 +1,3 @@ +{ + "id": "test-page-worker" +} diff --git a/addon-sdk/source/test/addons/places/lib/favicon-helpers.js b/addon-sdk/source/test/addons/places/lib/favicon-helpers.js new file mode 100644 index 000000000..d6b387bca --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/favicon-helpers.js @@ -0,0 +1,54 @@ +/* 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/. */ + +const { Cc, Ci, Cu } = require('chrome'); +const { Loader } = require('sdk/test/loader'); +const loader = Loader(module); +const httpd = loader.require('./httpd'); +const { pathFor } = require('sdk/system'); +const { startServerAsync } = httpd; +const basePath = pathFor('ProfD'); +const { atob } = Cu.import("resource://gre/modules/Services.jsm", {}); +const historyService = Cc["@mozilla.org/browser/nav-history-service;1"] + .getService(Ci.nsINavHistoryService); +const { events } = require('sdk/places/events'); +const { OS } = Cu.import("resource://gre/modules/osfile.jsm", {}); + +function onFaviconChange (url) { + return new Promise(resolve => { + function handler ({data, type}) { + if (type !== 'history-page-changed' || + data.url !== url || + data.property !== Ci.nsINavHistoryObserver.ATTRIBUTE_FAVICON) + return; + events.off('data', handler); + resolve(data.value); + } + + events.on('data', handler); + }); +} +exports.onFaviconChange = onFaviconChange; + +/* + * Takes page content, a page path, and favicon binary data + */ +function serve ({name, favicon, port, host}) { + let faviconTag = '<link rel="icon" type="image/x-icon" href="/'+ name +'.ico"/>'; + let content = '<html><head>' + faviconTag + '<title>'+name+'</title></head><body></body></html>'; + let srv = startServerAsync(port, basePath); + + let pagePath = OS.Path.join(basePath, name + '.html'); + let iconPath = OS.Path.join(basePath, name + '.ico'); + + + return OS.File.writeAtomic(iconPath, favicon). + then(() => { + return OS.File.writeAtomic(pagePath, content); + }). + then(() => srv); +} +exports.serve = serve; + +var binFavicon = exports.binFavicon = atob('AAABAAEAEBAAAAAAAABoBQAAFgAAACgAAAAQAAAAIAAAAAEACAAAAAAAAAEAAAAAAAAAAAAAAAEAAAABAAAAAAAAAACAAACAAAAAgIAAgAAAAIAAgACAgAAAwMDAAMDcwADwyqYABAQEAAgICAAMDAwAERERABYWFgAcHBwAIiIiACkpKQBVVVUATU1NAEJCQgA5OTkAgHz/AFBQ/wCTANYA/+zMAMbW7wDW5+cAkKmtAAAAMwAAAGYAAACZAAAAzAAAMwAAADMzAAAzZgAAM5kAADPMAAAz/wAAZgAAAGYzAABmZgAAZpkAAGbMAABm/wAAmQAAAJkzAACZZgAAmZkAAJnMAACZ/wAAzAAAAMwzAADMZgAAzJkAAMzMAADM/wAA/2YAAP+ZAAD/zAAzAAAAMwAzADMAZgAzAJkAMwDMADMA/wAzMwAAMzMzADMzZgAzM5kAMzPMADMz/wAzZgAAM2YzADNmZgAzZpkAM2bMADNm/wAzmQAAM5kzADOZZgAzmZkAM5nMADOZ/wAzzAAAM8wzADPMZgAzzJkAM8zMADPM/wAz/zMAM/9mADP/mQAz/8wAM///AGYAAABmADMAZgBmAGYAmQBmAMwAZgD/AGYzAABmMzMAZjNmAGYzmQBmM8wAZjP/AGZmAABmZjMAZmZmAGZmmQBmZswAZpkAAGaZMwBmmWYAZpmZAGaZzABmmf8AZswAAGbMMwBmzJkAZszMAGbM/wBm/wAAZv8zAGb/mQBm/8wAzAD/AP8AzACZmQAAmTOZAJkAmQCZAMwAmQAAAJkzMwCZAGYAmTPMAJkA/wCZZgAAmWYzAJkzZgCZZpkAmWbMAJkz/wCZmTMAmZlmAJmZmQCZmcwAmZn/AJnMAACZzDMAZsxmAJnMmQCZzMwAmcz/AJn/AACZ/zMAmcxmAJn/mQCZ/8wAmf//AMwAAACZADMAzABmAMwAmQDMAMwAmTMAAMwzMwDMM2YAzDOZAMwzzADMM/8AzGYAAMxmMwCZZmYAzGaZAMxmzACZZv8AzJkAAMyZMwDMmWYAzJmZAMyZzADMmf8AzMwAAMzMMwDMzGYAzMyZAMzMzADMzP8AzP8AAMz/MwCZ/2YAzP+ZAMz/zADM//8AzAAzAP8AZgD/AJkAzDMAAP8zMwD/M2YA/zOZAP8zzAD/M/8A/2YAAP9mMwDMZmYA/2aZAP9mzADMZv8A/5kAAP+ZMwD/mWYA/5mZAP+ZzAD/mf8A/8wAAP/MMwD/zGYA/8yZAP/MzAD/zP8A//8zAMz/ZgD//5kA///MAGZm/wBm/2YAZv//AP9mZgD/Zv8A//9mACEApQBfX18Ad3d3AIaGhgCWlpYAy8vLALKysgDX19cA3d3dAOPj4wDq6uoA8fHxAPj4+ADw+/8ApKCgAICAgAAAAP8AAP8AAAD//wD/AAAA/wD/AP//AAD///8ACgoKCgoKCgoKCgoKCgoKCgoKCgoHAQEMbQoKCgoKCgoAAAdDH/kgHRIAAAAAAAAAAADrHfn5ASQQAAAAAAAAAArsBx0B+fkgHesAAAAAAAD/Cgwf+fn5IA4dEus/IvcACgcMAfkg+QEB+SABHushbf8QHR/5HQH5+QEdHetEHx4K7B/5+QH5+fkdDBL5+SBE/wwdJfkf+fn5AR8g+fkfEArsCh/5+QEeJR/5+SAeBwAACgoe+SAlHwFAEhAfAAAAAPcKHh8eASYBHhAMAAAAAAAA9EMdIB8gHh0dBwAAAAAAAAAA7BAdQ+wHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP//AADwfwAAwH8AAMB/AAAAPwAAAAEAAAAAAAAAAAAAAAAAAAAAAAAAAQAAgAcAAIAPAADADwAA8D8AAP//AAA'); diff --git a/addon-sdk/source/test/addons/places/lib/httpd.js b/addon-sdk/source/test/addons/places/lib/httpd.js new file mode 100644 index 000000000..964dc9bbd --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/httpd.js @@ -0,0 +1,5211 @@ +/* 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: do not edit this file, this is copied from: +* https://github.com/mozilla/addon-sdk/blob/master/test/lib/httpd.js +*/ + +module.metadata = { + "stability": "experimental" +}; + +const { components, CC, Cc, Ci, Cr, Cu } = require("chrome"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + + +const PR_UINT32_MAX = Math.pow(2, 32) - 1; + +/** True if debugging output is enabled, false otherwise. */ +var DEBUG = false; // non-const *only* so tweakable in server tests + +/** True if debugging output should be timestamped. */ +var DEBUG_TIMESTAMP = false; // non-const so tweakable in server tests + +var gGlobalObject = Cc["@mozilla.org/systemprincipal;1"].createInstance(); + +/** +* Asserts that the given condition holds. If it doesn't, the given message is +* dumped, a stack trace is printed, and an exception is thrown to attempt to +* stop execution (which unfortunately must rely upon the exception not being +* accidentally swallowed by the code that uses it). +*/ +function NS_ASSERT(cond, msg) +{ + if (DEBUG && !cond) + { + dumpn("###!!!"); + dumpn("###!!! ASSERTION" + (msg ? ": " + msg : "!")); + dumpn("###!!! Stack follows:"); + + var stack = new Error().stack.split(/\n/); + dumpn(stack.map(function(val) { return "###!!! " + val; }).join("\n")); + + throw Cr.NS_ERROR_ABORT; + } +} + +/** Constructs an HTTP error object. */ +function HttpError(code, description) +{ + this.code = code; + this.description = description; +} +HttpError.prototype = +{ + toString: function() + { + return this.code + " " + this.description; + } +}; + +/** +* Errors thrown to trigger specific HTTP server responses. +*/ +const HTTP_400 = new HttpError(400, "Bad Request"); +const HTTP_401 = new HttpError(401, "Unauthorized"); +const HTTP_402 = new HttpError(402, "Payment Required"); +const HTTP_403 = new HttpError(403, "Forbidden"); +const HTTP_404 = new HttpError(404, "Not Found"); +const HTTP_405 = new HttpError(405, "Method Not Allowed"); +const HTTP_406 = new HttpError(406, "Not Acceptable"); +const HTTP_407 = new HttpError(407, "Proxy Authentication Required"); +const HTTP_408 = new HttpError(408, "Request Timeout"); +const HTTP_409 = new HttpError(409, "Conflict"); +const HTTP_410 = new HttpError(410, "Gone"); +const HTTP_411 = new HttpError(411, "Length Required"); +const HTTP_412 = new HttpError(412, "Precondition Failed"); +const HTTP_413 = new HttpError(413, "Request Entity Too Large"); +const HTTP_414 = new HttpError(414, "Request-URI Too Long"); +const HTTP_415 = new HttpError(415, "Unsupported Media Type"); +const HTTP_417 = new HttpError(417, "Expectation Failed"); + +const HTTP_500 = new HttpError(500, "Internal Server Error"); +const HTTP_501 = new HttpError(501, "Not Implemented"); +const HTTP_502 = new HttpError(502, "Bad Gateway"); +const HTTP_503 = new HttpError(503, "Service Unavailable"); +const HTTP_504 = new HttpError(504, "Gateway Timeout"); +const HTTP_505 = new HttpError(505, "HTTP Version Not Supported"); + +/** Creates a hash with fields corresponding to the values in arr. */ +function array2obj(arr) +{ + var obj = {}; + for (var i = 0; i < arr.length; i++) + obj[arr[i]] = arr[i]; + return obj; +} + +/** Returns an array of the integers x through y, inclusive. */ +function range(x, y) +{ + var arr = []; + for (var i = x; i <= y; i++) + arr.push(i); + return arr; +} + +/** An object (hash) whose fields are the numbers of all HTTP error codes. */ +const HTTP_ERROR_CODES = array2obj(range(400, 417).concat(range(500, 505))); + + +/** +* The character used to distinguish hidden files from non-hidden files, a la +* the leading dot in Apache. Since that mechanism also hides files from +* easy display in LXR, ls output, etc. however, we choose instead to use a +* suffix character. If a requested file ends with it, we append another +* when getting the file on the server. If it doesn't, we just look up that +* file. Therefore, any file whose name ends with exactly one of the character +* is "hidden" and available for use by the server. +*/ +const HIDDEN_CHAR = "^"; + +/** +* The file name suffix indicating the file containing overridden headers for +* a requested file. +*/ +const HEADERS_SUFFIX = HIDDEN_CHAR + "headers" + HIDDEN_CHAR; + +/** Type used to denote SJS scripts for CGI-like functionality. */ +const SJS_TYPE = "sjs"; + +/** Base for relative timestamps produced by dumpn(). */ +var firstStamp = 0; + +/** dump(str) with a trailing "\n" -- only outputs if DEBUG. */ +function dumpn(str) +{ + if (DEBUG) + { + var prefix = "HTTPD-INFO | "; + if (DEBUG_TIMESTAMP) + { + if (firstStamp === 0) + firstStamp = Date.now(); + + var elapsed = Date.now() - firstStamp; // milliseconds + var min = Math.floor(elapsed / 60000); + var sec = (elapsed % 60000) / 1000; + + if (sec < 10) + prefix += min + ":0" + sec.toFixed(3) + " | "; + else + prefix += min + ":" + sec.toFixed(3) + " | "; + } + + dump(prefix + str + "\n"); + } +} + +/** Dumps the current JS stack if DEBUG. */ +function dumpStack() +{ + // peel off the frames for dumpStack() and Error() + var stack = new Error().stack.split(/\n/).slice(2); + stack.forEach(dumpn); +} + + +/** The XPCOM thread manager. */ +var gThreadManager = null; + +/** The XPCOM prefs service. */ +var gRootPrefBranch = null; +function getRootPrefBranch() +{ + if (!gRootPrefBranch) + { + gRootPrefBranch = Cc["@mozilla.org/preferences-service;1"] + .getService(Ci.nsIPrefBranch); + } + return gRootPrefBranch; +} + +/** +* JavaScript constructors for commonly-used classes; precreating these is a +* speedup over doing the same from base principles. See the docs at +* http://developer.mozilla.org/en/docs/components.Constructor for details. +*/ +const ServerSocket = CC("@mozilla.org/network/server-socket;1", + "nsIServerSocket", + "init"); +const ScriptableInputStream = CC("@mozilla.org/scriptableinputstream;1", + "nsIScriptableInputStream", + "init"); +const Pipe = CC("@mozilla.org/pipe;1", + "nsIPipe", + "init"); +const FileInputStream = CC("@mozilla.org/network/file-input-stream;1", + "nsIFileInputStream", + "init"); +const ConverterInputStream = CC("@mozilla.org/intl/converter-input-stream;1", + "nsIConverterInputStream", + "init"); +const WritablePropertyBag = CC("@mozilla.org/hash-property-bag;1", + "nsIWritablePropertyBag2"); +const SupportsString = CC("@mozilla.org/supports-string;1", + "nsISupportsString"); + +/* These two are non-const only so a test can overwrite them. */ +var BinaryInputStream = CC("@mozilla.org/binaryinputstream;1", + "nsIBinaryInputStream", + "setInputStream"); +var BinaryOutputStream = CC("@mozilla.org/binaryoutputstream;1", + "nsIBinaryOutputStream", + "setOutputStream"); + +/** +* Returns the RFC 822/1123 representation of a date. +* +* @param date : Number +* the date, in milliseconds from midnight (00:00:00), January 1, 1970 GMT +* @returns string +* the representation of the given date +*/ +function toDateString(date) +{ + // + // rfc1123-date = wkday "," SP date1 SP time SP "GMT" + // date1 = 2DIGIT SP month SP 4DIGIT + // ; day month year (e.g., 02 Jun 1982) + // time = 2DIGIT ":" 2DIGIT ":" 2DIGIT + // ; 00:00:00 - 23:59:59 + // wkday = "Mon" | "Tue" | "Wed" + // | "Thu" | "Fri" | "Sat" | "Sun" + // month = "Jan" | "Feb" | "Mar" | "Apr" + // | "May" | "Jun" | "Jul" | "Aug" + // | "Sep" | "Oct" | "Nov" | "Dec" + // + + const wkdayStrings = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const monthStrings = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + + /** +* Processes a date and returns the encoded UTC time as a string according to +* the format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toTime(date) + { + var hrs = date.getUTCHours(); + var rv = (hrs < 10) ? "0" + hrs : hrs; + + var mins = date.getUTCMinutes(); + rv += ":"; + rv += (mins < 10) ? "0" + mins : mins; + + var secs = date.getUTCSeconds(); + rv += ":"; + rv += (secs < 10) ? "0" + secs : secs; + + return rv; + } + + /** +* Processes a date and returns the encoded UTC date as a string according to +* the date1 format specified in RFC 2616. +* +* @param date : Date +* the date to process +* @returns string +* a string of the form "HH:MM:SS", ranging from "00:00:00" to "23:59:59" +*/ + function toDate1(date) + { + var day = date.getUTCDate(); + var month = date.getUTCMonth(); + var year = date.getUTCFullYear(); + + var rv = (day < 10) ? "0" + day : day; + rv += " " + monthStrings[month]; + rv += " " + year; + + return rv; + } + + date = new Date(date); + + const fmtString = "%wkday%, %date1% %time% GMT"; + var rv = fmtString.replace("%wkday%", wkdayStrings[date.getUTCDay()]); + rv = rv.replace("%time%", toTime(date)); + return rv.replace("%date1%", toDate1(date)); +} + +/** +* Prints out a human-readable representation of the object o and its fields, +* omitting those whose names begin with "_" if showMembers != true (to ignore +* "private" properties exposed via getters/setters). +*/ +function printObj(o, showMembers) +{ + var s = "******************************\n"; + s += "o = {\n"; + for (var i in o) + { + if (typeof(i) != "string" || + (showMembers || (i.length > 0 && i[0] != "_"))) + s+= " " + i + ": " + o[i] + ",\n"; + } + s += " };\n"; + s += "******************************"; + dumpn(s); +} + +/** +* Instantiates a new HTTP server. +*/ +function nsHttpServer() +{ + if (!gThreadManager) + gThreadManager = Cc["@mozilla.org/thread-manager;1"].getService(); + + /** The port on which this server listens. */ + this._port = undefined; + + /** The socket associated with this. */ + this._socket = null; + + /** The handler used to process requests to this server. */ + this._handler = new ServerHandler(this); + + /** Naming information for this server. */ + this._identity = new ServerIdentity(); + + /** +* Indicates when the server is to be shut down at the end of the request. +*/ + this._doQuit = false; + + /** +* True if the socket in this is closed (and closure notifications have been +* sent and processed if the socket was ever opened), false otherwise. +*/ + this._socketClosed = true; + + /** +* Used for tracking existing connections and ensuring that all connections +* are properly cleaned up before server shutdown; increases by 1 for every +* new incoming connection. +*/ + this._connectionGen = 0; + + /** +* Hash of all open connections, indexed by connection number at time of +* creation. +*/ + this._connections = {}; +} +nsHttpServer.prototype = +{ + classID: components.ID("{54ef6f81-30af-4b1d-ac55-8ba811293e41}"), + + // NSISERVERSOCKETLISTENER + + /** +* Processes an incoming request coming in on the given socket and contained +* in the given transport. +* +* @param socket : nsIServerSocket +* the socket through which the request was served +* @param trans : nsISocketTransport +* the transport for the request/response +* @see nsIServerSocketListener.onSocketAccepted +*/ + onSocketAccepted: function(socket, trans) + { + dumpn("*** onSocketAccepted(socket=" + socket + ", trans=" + trans + ")"); + + dumpn(">>> new connection on " + trans.host + ":" + trans.port); + + const SEGMENT_SIZE = 8192; + const SEGMENT_COUNT = 1024; + try + { + var input = trans.openInputStream(0, SEGMENT_SIZE, SEGMENT_COUNT) + .QueryInterface(Ci.nsIAsyncInputStream); + var output = trans.openOutputStream(0, 0, 0); + } + catch (e) + { + dumpn("*** error opening transport streams: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + var connectionNumber = ++this._connectionGen; + + try + { + var conn = new Connection(input, output, this, socket.port, trans.port, + connectionNumber); + var reader = new RequestReader(conn); + + // XXX add request timeout functionality here! + + // Note: must use main thread here, or we might get a GC that will cause + // threadsafety assertions. We really need to fix XPConnect so that + // you can actually do things in multi-threaded JS. :-( + input.asyncWait(reader, 0, 0, gThreadManager.mainThread); + } + catch (e) + { + // Assume this connection can't be salvaged and bail on it completely; + // don't attempt to close it so that we can assert that any connection + // being closed is in this._connections. + dumpn("*** error in initial request-processing stages: " + e); + trans.close(Cr.NS_BINDING_ABORTED); + return; + } + + this._connections[connectionNumber] = conn; + dumpn("*** starting connection " + connectionNumber); + }, + + /** +* Called when the socket associated with this is closed. +* +* @param socket : nsIServerSocket +* the socket being closed +* @param status : nsresult +* the reason the socket stopped listening (NS_BINDING_ABORTED if the server +* was stopped using nsIHttpServer.stop) +* @see nsIServerSocketListener.onStopListening +*/ + onStopListening: function(socket, status) + { + dumpn(">>> shutting down server on port " + socket.port); + this._socketClosed = true; + if (!this._hasOpenConnections()) + { + dumpn("*** no open connections, notifying async from onStopListening"); + + // Notify asynchronously so that any pending teardown in stop() has a + // chance to run first. + var self = this; + var stopEvent = + { + run: function() + { + dumpn("*** _notifyStopped async callback"); + self._notifyStopped(); + } + }; + gThreadManager.currentThread + .dispatch(stopEvent, Ci.nsIThread.DISPATCH_NORMAL); + } + }, + + // NSIHTTPSERVER + + // + // see nsIHttpServer.start + // + start: function(port) + { + this._start(port, "localhost") + }, + + _start: function(port, host) + { + if (this._socket) + throw Cr.NS_ERROR_ALREADY_INITIALIZED; + + this._port = port; + this._doQuit = this._socketClosed = false; + + this._host = host; + + // The listen queue needs to be long enough to handle + // network.http.max-persistent-connections-per-server concurrent connections, + // plus a safety margin in case some other process is talking to + // the server as well. + var prefs = getRootPrefBranch(); + var maxConnections; + try { + // Bug 776860: The original pref was removed in favor of this new one: + maxConnections = prefs.getIntPref("network.http.max-persistent-connections-per-server") + 5; + } + catch(e) { + maxConnections = prefs.getIntPref("network.http.max-connections-per-server") + 5; + } + + try + { + var loopback = true; + if (this._host != "127.0.0.1" && this._host != "localhost") { + var loopback = false; + } + + var socket = new ServerSocket(this._port, + loopback, // true = localhost, false = everybody + maxConnections); + dumpn(">>> listening on port " + socket.port + ", " + maxConnections + + " pending connections"); + socket.asyncListen(this); + this._identity._initialize(socket.port, host, true); + this._socket = socket; + } + catch (e) + { + dumpn("!!! could not start server on port " + port + ": " + e); + throw Cr.NS_ERROR_NOT_AVAILABLE; + } + }, + + // + // see nsIHttpServer.stop + // + stop: function(callback) + { + if (!callback) + throw Cr.NS_ERROR_NULL_POINTER; + if (!this._socket) + throw Cr.NS_ERROR_UNEXPECTED; + + this._stopCallback = typeof callback === "function" + ? callback + : function() { callback.onStopped(); }; + + dumpn(">>> stopping listening on port " + this._socket.port); + this._socket.close(); + this._socket = null; + + // We can't have this identity any more, and the port on which we're running + // this server now could be meaningless the next time around. + this._identity._teardown(); + + this._doQuit = false; + + // socket-close notification and pending request completion happen async + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (file && (!file.exists() || file.isDirectory())) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handler.registerFile(path, file); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // XXX true path validation! + if (path.charAt(0) != "/" || + path.charAt(path.length - 1) != "/" || + (directory && + (!directory.exists() || !directory.isDirectory()))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX determine behavior of nonexistent /foo/bar when a /foo/bar/ mapping + // exists! + + this._handler.registerDirectory(path, directory); + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + this._handler.registerPathHandler(path, handler); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + this._handler.registerPrefixHandler(prefix, handler); + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(code, handler) + { + this._handler.registerErrorHandler(code, handler); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + this._handler.setIndexHandler(handler); + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + this._handler.registerContentType(ext, type); + }, + + // + // see nsIHttpServer.serverIdentity + // + get identity() + { + return this._identity; + }, + + // + // see nsIHttpServer.getState + // + getState: function(path, k) + { + return this._handler._getState(path, k); + }, + + // + // see nsIHttpServer.setState + // + setState: function(path, k, v) + { + return this._handler._setState(path, k, v); + }, + + // + // see nsIHttpServer.getSharedState + // + getSharedState: function(k) + { + return this._handler._getSharedState(k); + }, + + // + // see nsIHttpServer.setSharedState + // + setSharedState: function(k, v) + { + return this._handler._setSharedState(k, v); + }, + + // + // see nsIHttpServer.getObjectState + // + getObjectState: function(k) + { + return this._handler._getObjectState(k); + }, + + // + // see nsIHttpServer.setObjectState + // + setObjectState: function(k, v) + { + return this._handler._setObjectState(k, v); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIServerSocketListener) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NON-XPCOM PUBLIC API + + /** +* Returns true iff this server is not running (and is not in the process of +* serving any requests still to be processed when the server was last +* stopped after being run). +*/ + isStopped: function() + { + return this._socketClosed && !this._hasOpenConnections(); + }, + + // PRIVATE IMPLEMENTATION + + /** True if this server has any open connections to it, false otherwise. */ + _hasOpenConnections: function() + { + // + // If we have any open connections, they're tracked as numeric properties on + // |this._connections|. The non-standard __count__ property could be used + // to check whether there are any properties, but standard-wise, even + // looking forward to ES5, there's no less ugly yet still O(1) way to do + // this. + // + for (var n in this._connections) + return true; + return false; + }, + + /** Calls the server-stopped callback provided when stop() was called. */ + _notifyStopped: function() + { + NS_ASSERT(this._stopCallback !== null, "double-notifying?"); + NS_ASSERT(!this._hasOpenConnections(), "should be done serving by now"); + + // + // NB: We have to grab this now, null out the member, *then* call the + // callback here, or otherwise the callback could (indirectly) futz with + // this._stopCallback by starting and immediately stopping this, at + // which point we'd be nulling out a field we no longer have a right to + // modify. + // + var callback = this._stopCallback; + this._stopCallback = null; + try + { + callback(); + } + catch (e) + { + // not throwing because this is specified as being usually (but not + // always) asynchronous + dump("!!! error running onStopped callback: " + e + "\n"); + } + }, + + /** +* Notifies this server that the given connection has been closed. +* +* @param connection : Connection +* the connection that was closed +*/ + _connectionClosed: function(connection) + { + NS_ASSERT(connection.number in this._connections, + "closing a connection " + this + " that we never added to the " + + "set of open connections?"); + NS_ASSERT(this._connections[connection.number] === connection, + "connection number mismatch? " + + this._connections[connection.number]); + delete this._connections[connection.number]; + + // Fire a pending server-stopped notification if it's our responsibility. + if (!this._hasOpenConnections() && this._socketClosed) + this._notifyStopped(); + }, + + /** +* Requests that the server be shut down when possible. +*/ + _requestQuit: function() + { + dumpn(">>> requesting a quit"); + dumpStack(); + this._doQuit = true; + } +}; + + +// +// RFC 2396 section 3.2.2: +// +// host = hostname | IPv4address +// hostname = *( domainlabel "." ) toplabel [ "." ] +// domainlabel = alphanum | alphanum *( alphanum | "-" ) alphanum +// toplabel = alpha | alpha *( alphanum | "-" ) alphanum +// IPv4address = 1*digit "." 1*digit "." 1*digit "." 1*digit +// + +const HOST_REGEX = + new RegExp("^(?:" + + // *( domainlabel "." ) + "(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\\.)*" + + // toplabel + "[a-z](?:[a-z0-9-]*[a-z0-9])?" + + "|" + + // IPv4 address + "\\d+\\.\\d+\\.\\d+\\.\\d+" + + ")$", + "i"); + + +/** +* Represents the identity of a server. An identity consists of a set of +* (scheme, host, port) tuples denoted as locations (allowing a single server to +* serve multiple sites or to be used behind both HTTP and HTTPS proxies for any +* host/port). Any incoming request must be to one of these locations, or it +* will be rejected with an HTTP 400 error. One location, denoted as the +* primary location, is the location assigned in contexts where a location +* cannot otherwise be endogenously derived, such as for HTTP/1.0 requests. +* +* A single identity may contain at most one location per unique host/port pair; +* other than that, no restrictions are placed upon what locations may +* constitute an identity. +*/ +function ServerIdentity() +{ + /** The scheme of the primary location. */ + this._primaryScheme = "http"; + + /** The hostname of the primary location. */ + this._primaryHost = "127.0.0.1" + + /** The port number of the primary location. */ + this._primaryPort = -1; + + /** +* The current port number for the corresponding server, stored so that a new +* primary location can always be set if the current one is removed. +*/ + this._defaultPort = -1; + + /** +* Maps hosts to maps of ports to schemes, e.g. the following would represent +* https://example.com:789/ and http://example.org/: +* +* { +* "xexample.com": { 789: "https" }, +* "xexample.org": { 80: "http" } +* } +* +* Note the "x" prefix on hostnames, which prevents collisions with special +* JS names like "prototype". +*/ + this._locations = { "xlocalhost": {} }; +} +ServerIdentity.prototype = +{ + // NSIHTTPSERVERIDENTITY + + // + // see nsIHttpServerIdentity.primaryScheme + // + get primaryScheme() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryScheme; + }, + + // + // see nsIHttpServerIdentity.primaryHost + // + get primaryHost() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryHost; + }, + + // + // see nsIHttpServerIdentity.primaryPort + // + get primaryPort() + { + if (this._primaryPort === -1) + throw Cr.NS_ERROR_NOT_INITIALIZED; + return this._primaryPort; + }, + + // + // see nsIHttpServerIdentity.add + // + add: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + this._locations["x" + host] = entry = {}; + + entry[port] = scheme; + }, + + // + // see nsIHttpServerIdentity.remove + // + remove: function(scheme, host, port) + { + this._validate(scheme, host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return false; + + var present = port in entry; + delete entry[port]; + + if (this._primaryScheme == scheme && + this._primaryHost == host && + this._primaryPort == port && + this._defaultPort !== -1) + { + // Always keep at least one identity in existence at any time, unless + // we're in the process of shutting down (the last condition above). + this._primaryPort = -1; + this._initialize(this._defaultPort, host, false); + } + + return present; + }, + + // + // see nsIHttpServerIdentity.has + // + has: function(scheme, host, port) + { + this._validate(scheme, host, port); + + return "x" + host in this._locations && + scheme === this._locations["x" + host][port]; + }, + + // + // see nsIHttpServerIdentity.has + // + getScheme: function(host, port) + { + this._validate("http", host, port); + + var entry = this._locations["x" + host]; + if (!entry) + return ""; + + return entry[port] || ""; + }, + + // + // see nsIHttpServerIdentity.setPrimary + // + setPrimary: function(scheme, host, port) + { + this._validate(scheme, host, port); + + this.add(scheme, host, port); + + this._primaryScheme = scheme; + this._primaryHost = host; + this._primaryPort = port; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpServerIdentity) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Initializes the primary name for the corresponding server, based on the +* provided port number. +*/ + _initialize: function(port, host, addSecondaryDefault) + { + this._host = host; + if (this._primaryPort !== -1) + this.add("http", host, port); + else + this.setPrimary("http", "localhost", port); + this._defaultPort = port; + + // Only add this if we're being called at server startup + if (addSecondaryDefault && host != "127.0.0.1") + this.add("http", "127.0.0.1", port); + }, + + /** +* Called at server shutdown time, unsets the primary location only if it was +* the default-assigned location and removes the default location from the +* set of locations used. +*/ + _teardown: function() + { + if (this._host != "127.0.0.1") { + // Not the default primary location, nothing special to do here + this.remove("http", "127.0.0.1", this._defaultPort); + } + + // This is a *very* tricky bit of reasoning here; make absolutely sure the + // tests for this code pass before you commit changes to it. + if (this._primaryScheme == "http" && + this._primaryHost == this._host && + this._primaryPort == this._defaultPort) + { + // Make sure we don't trigger the readding logic in .remove(), then remove + // the default location. + var port = this._defaultPort; + this._defaultPort = -1; + this.remove("http", this._host, port); + + // Ensure a server start triggers the setPrimary() path in ._initialize() + this._primaryPort = -1; + } + else + { + // No reason not to remove directly as it's not our primary location + this.remove("http", this._host, this._defaultPort); + } + }, + + /** +* Ensures scheme, host, and port are all valid with respect to RFC 2396. +* +* @throws NS_ERROR_ILLEGAL_VALUE +* if any argument doesn't match the corresponding production +*/ + _validate: function(scheme, host, port) + { + if (scheme !== "http" && scheme !== "https") + { + dumpn("*** server only supports http/https schemes: '" + scheme + "'"); + dumpStack(); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (!HOST_REGEX.test(host)) + { + dumpn("*** unexpected host: '" + host + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + if (port < 0 || port > 65535) + { + dumpn("*** unexpected port: '" + port + "'"); + throw Cr.NS_ERROR_ILLEGAL_VALUE; + } + } +}; + + +/** +* Represents a connection to the server (and possibly in the future the thread +* on which the connection is processed). +* +* @param input : nsIInputStream +* stream from which incoming data on the connection is read +* @param output : nsIOutputStream +* stream to write data out the connection +* @param server : nsHttpServer +* the server handling the connection +* @param port : int +* the port on which the server is running +* @param outgoingPort : int +* the outgoing port used by this connection +* @param number : uint +* a serial number used to uniquely identify this connection +*/ +function Connection(input, output, server, port, outgoingPort, number) +{ + dumpn("*** opening new connection " + number + " on port " + outgoingPort); + + /** Stream of incoming data. */ + this.input = input; + + /** Stream for outgoing data. */ + this.output = output; + + /** The server associated with this request. */ + this.server = server; + + /** The port on which the server is running. */ + this.port = port; + + /** The outgoing poort used by this connection. */ + this._outgoingPort = outgoingPort; + + /** The serial number of this connection. */ + this.number = number; + + /** +* The request for which a response is being generated, null if the +* incoming request has not been fully received or if it had errors. +*/ + this.request = null; + + /** State variables for debugging. */ + this._closed = this._processed = false; +} +Connection.prototype = +{ + /** Closes this connection's input/output streams. */ + close: function() + { + dumpn("*** closing connection " + this.number + + " on port " + this._outgoingPort); + + this.input.close(); + this.output.close(); + this._closed = true; + + var server = this.server; + server._connectionClosed(this); + + // If an error triggered a server shutdown, act on it now + if (server._doQuit) + server.stop(function() { /* not like we can do anything better */ }); + }, + + /** +* Initiates processing of this connection, using the data in the given +* request. +* +* @param request : Request +* the request which should be processed +*/ + process: function(request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + + this.request = request; + this.server._handler.handleResponse(this); + }, + + /** +* Initiates processing of this connection, generating a response with the +* given HTTP error code. +* +* @param code : uint +* an HTTP code, so in the range [0, 1000) +* @param request : Request +* incomplete data about the incoming request (since there were errors +* during its processing +*/ + processError: function(code, request) + { + NS_ASSERT(!this._closed && !this._processed); + + this._processed = true; + this.request = request; + this.server._handler.handleError(code, this); + }, + + /** Converts this to a string for debugging purposes. */ + toString: function() + { + return "<Connection(" + this.number + + (this.request ? ", " + this.request.path : "") +"): " + + (this._closed ? "closed" : "open") + ">"; + } +}; + + + +/** Returns an array of count bytes from the given input stream. */ +function readBytes(inputStream, count) +{ + return new BinaryInputStream(inputStream).readByteArray(count); +} + + + +/** Request reader processing states; see RequestReader for details. */ +const READER_IN_REQUEST_LINE = 0; +const READER_IN_HEADERS = 1; +const READER_IN_BODY = 2; +const READER_FINISHED = 3; + + +/** +* Reads incoming request data asynchronously, does any necessary preprocessing, +* and forwards it to the request handler. Processing occurs in three states: +* +* READER_IN_REQUEST_LINE Reading the request's status line +* READER_IN_HEADERS Reading headers in the request +* READER_IN_BODY Reading the body of the request +* READER_FINISHED Entire request has been read and processed +* +* During the first two stages, initial metadata about the request is gathered +* into a Request object. Once the status line and headers have been processed, +* we start processing the body of the request into the Request. Finally, when +* the entire body has been read, we create a Response and hand it off to the +* ServerHandler to be given to the appropriate request handler. +* +* @param connection : Connection +* the connection for the request being read +*/ +function RequestReader(connection) +{ + /** Connection metadata for this request. */ + this._connection = connection; + + /** +* A container providing line-by-line access to the raw bytes that make up the +* data which has been read from the connection but has not yet been acted +* upon (by passing it to the request handler or by extracting request +* metadata from it). +*/ + this._data = new LineData(); + + /** +* The amount of data remaining to be read from the body of this request. +* After all headers in the request have been read this is the value in the +* Content-Length header, but as the body is read its value decreases to zero. +*/ + this._contentLength = 0; + + /** The current state of parsing the incoming request. */ + this._state = READER_IN_REQUEST_LINE; + + /** Metadata constructed from the incoming request for the request handler. */ + this._metadata = new Request(connection.port); + + /** +* Used to preserve state if we run out of line data midway through a +* multi-line header. _lastHeaderName stores the name of the header, while +* _lastHeaderValue stores the value we've seen so far for the header. +* +* These fields are always either both undefined or both strings. +*/ + this._lastHeaderName = this._lastHeaderValue = undefined; +} +RequestReader.prototype = +{ + // NSIINPUTSTREAMCALLBACK + + /** +* Called when more data from the incoming request is available. This method +* then reads the available data from input and deals with that data as +* necessary, depending upon the syntax of already-downloaded data. +* +* @param input : nsIAsyncInputStream +* the stream of incoming data from the connection +*/ + onInputStreamReady: function(input) + { + dumpn("*** onInputStreamReady(input=" + input + ") on thread " + + gThreadManager.currentThread + " (main is " + + gThreadManager.mainThread + ")"); + dumpn("*** this._state == " + this._state); + + // Handle cases where we get more data after a request error has been + // discovered but *before* we can close the connection. + var data = this._data; + if (!data) + return; + + try + { + data.appendBytes(readBytes(input, input.available())); + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** WARNING: unexpected error when reading from socket; will " + + "be treated as if the input stream had been closed"); + dumpn("*** WARNING: actual error was: " + e); + } + + // We've lost a race -- input has been closed, but we're still expecting + // to read more data. available() will throw in this case, and since + // we're dead in the water now, destroy the connection. + dumpn("*** onInputStreamReady called on a closed input, destroying " + + "connection"); + this._connection.close(); + return; + } + + switch (this._state) + { + default: + NS_ASSERT(false, "invalid state: " + this._state); + break; + + case READER_IN_REQUEST_LINE: + if (!this._processRequestLine()) + break; + /* fall through */ + + case READER_IN_HEADERS: + if (!this._processHeaders()) + break; + /* fall through */ + + case READER_IN_BODY: + this._processBody(); + } + + if (this._state != READER_FINISHED) + input.asyncWait(this, 0, 0, gThreadManager.currentThread); + }, + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIInputStreamCallback) || + aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE API + + /** +* Processes unprocessed, downloaded data as a request line. +* +* @returns boolean +* true iff the request line has been fully processed +*/ + _processRequestLine: function() + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + // Servers SHOULD ignore any empty line(s) received where a Request-Line + // is expected (section 4.1). + var data = this._data; + var line = {}; + var readSuccess; + while ((readSuccess = data.readLine(line)) && line.value == "") + dumpn("*** ignoring beginning blank line..."); + + // if we don't have a full line, wait until we do + if (!readSuccess) + return false; + + // we have the first non-blank line + try + { + this._parseRequestLine(line.value); + this._state = READER_IN_HEADERS; + return true; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing request headers. +* +* @returns boolean +* true iff header data in the request has been fully processed +*/ + _processHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + // XXX things to fix here: + // + // - need to support RFC 2047-encoded non-US-ASCII characters + + try + { + var done = this._parseHeaders(); + if (done) + { + var request = this._metadata; + + // XXX this is wrong for requests with transfer-encodings applied to + // them, particularly chunked (which by its nature can have no + // meaningful Content-Length header)! + this._contentLength = request.hasHeader("Content-Length") + ? parseInt(request.getHeader("Content-Length"), 10) + : 0; + dumpn("_processHeaders, Content-length=" + this._contentLength); + + this._state = READER_IN_BODY; + } + return done; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Processes stored data, assuming it is either at the beginning or in +* the middle of processing the request body. +* +* @returns boolean +* true iff the request body has been fully processed +*/ + _processBody: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + // XXX handle chunked transfer-coding request bodies! + + try + { + if (this._contentLength > 0) + { + var data = this._data.purge(); + var count = Math.min(data.length, this._contentLength); + dumpn("*** loading data=" + data + " len=" + data.length + + " excess=" + (data.length - count)); + + var bos = new BinaryOutputStream(this._metadata._bodyOutputStream); + bos.writeByteArray(data, count); + this._contentLength -= count; + } + + dumpn("*** remaining body data len=" + this._contentLength); + if (this._contentLength == 0) + { + this._validateRequest(); + this._state = READER_FINISHED; + this._handleResponse(); + return true; + } + + return false; + } + catch (e) + { + this._handleError(e); + return false; + } + }, + + /** +* Does various post-header checks on the data in this request. +* +* @throws : HttpError +* if the request was malformed in some way +*/ + _validateRequest: function() + { + NS_ASSERT(this._state == READER_IN_BODY); + + dumpn("*** _validateRequest"); + + var metadata = this._metadata; + var headers = metadata._headers; + + // 19.6.1.1 -- servers MUST report 400 to HTTP/1.1 requests w/o Host header + var identity = this._connection.server.identity; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + { + if (!headers.hasHeader("Host")) + { + dumpn("*** malformed HTTP/1.1 or greater request with no Host header!"); + throw HTTP_400; + } + + // If the Request-URI wasn't absolute, then we need to determine our host. + // We have to determine what scheme was used to access us based on the + // server identity data at this point, because the request just doesn't + // contain enough data on its own to do this, sadly. + if (!metadata._host) + { + var host, port; + var hostPort = headers.getHeader("Host"); + var colon = hostPort.indexOf(":"); + if (colon < 0) + { + host = hostPort; + port = ""; + } + else + { + host = hostPort.substring(0, colon); + port = hostPort.substring(colon + 1); + } + + // NB: We allow an empty port here because, oddly, a colon may be + // present even without a port number, e.g. "example.com:"; in this + // case the default port applies. + if (!HOST_REGEX.test(host) || !/^\d*$/.test(port)) + { + dumpn("*** malformed hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + // If we're not given a port, we're stuck, because we don't know what + // scheme to use to look up the correct port here, in general. Since + // the HTTPS case requires a tunnel/proxy and thus requires that the + // requested URI be absolute (and thus contain the necessary + // information), let's assume HTTP will prevail and use that. + port = +port || 80; + + var scheme = identity.getScheme(host, port); + if (!scheme) + { + dumpn("*** unrecognized hostname (" + hostPort + ") in Host " + + "header, 400 time"); + throw HTTP_400; + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + } + } + else + { + NS_ASSERT(metadata._host === undefined, + "HTTP/1.0 doesn't allow absolute paths in the request line!"); + + metadata._scheme = identity.primaryScheme; + metadata._host = identity.primaryHost; + metadata._port = identity.primaryPort; + } + + NS_ASSERT(identity.has(metadata._scheme, metadata._host, metadata._port), + "must have a location we recognize by now!"); + }, + + /** +* Handles responses in case of error, either in the server or in the request. +* +* @param e +* the specific error encountered, which is an HttpError in the case where +* the request is in some way invalid or cannot be fulfilled; if this isn't +* an HttpError we're going to be paranoid and shut down, because that +* shouldn't happen, ever +*/ + _handleError: function(e) + { + // Don't fall back into normal processing! + this._state = READER_FINISHED; + + var server = this._connection.server; + if (e instanceof HttpError) + { + var code = e.code; + } + else + { + dumpn("!!! UNEXPECTED ERROR: " + e + + (e.lineNumber ? ", line " + e.lineNumber : "")); + + // no idea what happened -- be paranoid and shut down + code = 500; + server._requestQuit(); + } + + // make attempted reuse of data an error + this._data = null; + + this._connection.processError(code, this._metadata); + }, + + /** +* Now that we've read the request line and headers, we can actually hand off +* the request to be handled. +* +* This method is called once per request, after the request line and all +* headers and the body, if any, have been received. +*/ + _handleResponse: function() + { + NS_ASSERT(this._state == READER_FINISHED); + + // We don't need the line-based data any more, so make attempted reuse an + // error. + this._data = null; + + this._connection.process(this._metadata); + }, + + + // PARSING + + /** +* Parses the request line for the HTTP request associated with this. +* +* @param line : string +* the request line +*/ + _parseRequestLine: function(line) + { + NS_ASSERT(this._state == READER_IN_REQUEST_LINE); + + dumpn("*** _parseRequestLine('" + line + "')"); + + var metadata = this._metadata; + + // clients and servers SHOULD accept any amount of SP or HT characters + // between fields, even though only a single SP is required (section 19.3) + var request = line.split(/[ \t]+/); + if (!request || request.length != 3) + throw HTTP_400; + + metadata._method = request[0]; + + // get the HTTP version + var ver = request[2]; + var match = ver.match(/^HTTP\/(\d+\.\d+)$/); + if (!match) + throw HTTP_400; + + // determine HTTP version + try + { + metadata._httpVersion = new nsHttpVersion(match[1]); + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_0)) + throw "unsupported HTTP version"; + } + catch (e) + { + // we support HTTP/1.0 and HTTP/1.1 only + throw HTTP_501; + } + + + var fullPath = request[1]; + var serverIdentity = this._connection.server.identity; + + var scheme, host, port; + + if (fullPath.charAt(0) != "/") + { + // No absolute paths in the request line in HTTP prior to 1.1 + if (!metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1)) + throw HTTP_400; + + try + { + var uri = Cc["@mozilla.org/network/io-service;1"] + .getService(Ci.nsIIOService) + .newURI(fullPath, null, null); + fullPath = uri.path; + scheme = uri.scheme; + host = metadata._host = uri.asciiHost; + port = uri.port; + if (port === -1) + { + if (scheme === "http") + port = 80; + else if (scheme === "https") + port = 443; + else + throw HTTP_400; + } + } + catch (e) + { + // If the host is not a valid host on the server, the response MUST be a + // 400 (Bad Request) error message (section 5.2). Alternately, the URI + // is malformed. + throw HTTP_400; + } + + if (!serverIdentity.has(scheme, host, port) || fullPath.charAt(0) != "/") + throw HTTP_400; + } + + var splitter = fullPath.indexOf("?"); + if (splitter < 0) + { + // _queryString already set in ctor + metadata._path = fullPath; + } + else + { + metadata._path = fullPath.substring(0, splitter); + metadata._queryString = fullPath.substring(splitter + 1); + } + + metadata._scheme = scheme; + metadata._host = host; + metadata._port = port; + }, + + /** +* Parses all available HTTP headers in this until the header-ending CRLFCRLF, +* adding them to the store of headers in the request. +* +* @throws +* HTTP_400 if the headers are malformed +* @returns boolean +* true if all headers have now been processed, false otherwise +*/ + _parseHeaders: function() + { + NS_ASSERT(this._state == READER_IN_HEADERS); + + dumpn("*** _parseHeaders"); + + var data = this._data; + + var headers = this._metadata._headers; + var lastName = this._lastHeaderName; + var lastVal = this._lastHeaderValue; + + var line = {}; + while (true) + { + NS_ASSERT(!((lastVal === undefined) ^ (lastName === undefined)), + lastName === undefined ? + "lastVal without lastName? lastVal: '" + lastVal + "'" : + "lastName without lastVal? lastName: '" + lastName + "'"); + + if (!data.readLine(line)) + { + // save any data we have from the header we might still be processing + this._lastHeaderName = lastName; + this._lastHeaderValue = lastVal; + return false; + } + + var lineText = line.value; + var firstChar = lineText.charAt(0); + + // blank line means end of headers + if (lineText == "") + { + // we're finished with the previous header + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + else + { + // no headers in request -- valid for HTTP/1.0 requests + } + + // either way, we're done processing headers + this._state = READER_IN_BODY; + return true; + } + else if (firstChar == " " || firstChar == "\t") + { + // multi-line header if we've already seen a header line + if (!lastName) + { + // we don't have a header to continue! + throw HTTP_400; + } + + // append this line's text to the value; starts with SP/HT, so no need + // for separating whitespace + lastVal += lineText; + } + else + { + // we have a new header, so set the old one (if one existed) + if (lastName) + { + try + { + headers.setHeader(lastName, lastVal, true); + } + catch (e) + { + dumpn("*** e == " + e); + throw HTTP_400; + } + } + + var colon = lineText.indexOf(":"); // first colon must be splitter + if (colon < 1) + { + // no colon or missing header field-name + throw HTTP_400; + } + + // set header name, value (to be set in the next loop, usually) + lastName = lineText.substring(0, colon); + lastVal = lineText.substring(colon + 1); + } // empty, continuation, start of header + } // while (true) + } +}; + + +/** The character codes for CR and LF. */ +const CR = 0x0D, LF = 0x0A; + +/** +* Calculates the number of characters before the first CRLF pair in array, or +* -1 if the array contains no CRLF pair. +* +* @param array : Array +* an array of numbers in the range [0, 256), each representing a single +* character; the first CRLF is the lowest index i where +* |array[i] == "\r".charCodeAt(0)| and |array[i+1] == "\n".charCodeAt(0)|, +* if such an |i| exists, and -1 otherwise +* @returns int +* the index of the first CRLF if any were present, -1 otherwise +*/ +function findCRLF(array) +{ + for (var i = array.indexOf(CR); i >= 0; i = array.indexOf(CR, i + 1)) + { + if (array[i + 1] == LF) + return i; + } + return -1; +} + + +/** +* A container which provides line-by-line access to the arrays of bytes with +* which it is seeded. +*/ +function LineData() +{ + /** An array of queued bytes from which to get line-based characters. */ + this._data = []; +} +LineData.prototype = +{ + /** +* Appends the bytes in the given array to the internal data cache maintained +* by this. +*/ + appendBytes: function(bytes) + { + Array.prototype.push.apply(this._data, bytes); + }, + + /** +* Removes and returns a line of data, delimited by CRLF, from this. +* +* @param out +* an object whose "value" property will be set to the first line of text +* present in this, sans CRLF, if this contains a full CRLF-delimited line +* of text; if this doesn't contain enough data, the value of the property +* is undefined +* @returns boolean +* true if a full line of data could be read from the data in this, false +* otherwise +*/ + readLine: function(out) + { + var data = this._data; + var length = findCRLF(data); + if (length < 0) + return false; + + // + // We have the index of the CR, so remove all the characters, including + // CRLF, from the array with splice, and convert the removed array into the + // corresponding string, from which we then strip the trailing CRLF. + // + // Getting the line in this matter acknowledges that substring is an O(1) + // operation in SpiderMonkey because strings are immutable, whereas two + // splices, both from the beginning of the data, are less likely to be as + // cheap as a single splice plus two extra character conversions. + // + var line = String.fromCharCode.apply(null, data.splice(0, length + 2)); + out.value = line.substring(0, length); + + return true; + }, + + /** +* Removes the bytes currently within this and returns them in an array. +* +* @returns Array +* the bytes within this when this method is called +*/ + purge: function() + { + var data = this._data; + this._data = []; + return data; + } +}; + + + +/** +* Creates a request-handling function for an nsIHttpRequestHandler object. +*/ +function createHandlerFunc(handler) +{ + return function(metadata, response) { handler.handle(metadata, response); }; +} + + +/** +* The default handler for directories; writes an HTML response containing a +* slightly-formatted directory listing. +*/ +function defaultIndexHandler(metadata, response) +{ + response.setHeader("Content-Type", "text/html", false); + + var path = htmlEscape(decodeURI(metadata.path)); + + // + // Just do a very basic bit of directory listings -- no need for too much + // fanciness, especially since we don't have a style sheet in which we can + // stick rules (don't want to pollute the default path-space). + // + + var body = '<html>\ +<head>\ +<title>' + path + '</title>\ +</head>\ +<body>\ +<h1>' + path + '</h1>\ +<ol style="list-style-type: none">'; + + var directory = metadata.getProperty("directory").QueryInterface(Ci.nsILocalFile); + NS_ASSERT(directory && directory.isDirectory()); + + var fileList = []; + var files = directory.directoryEntries; + while (files.hasMoreElements()) + { + var f = files.getNext().QueryInterface(Ci.nsIFile); + var name = f.leafName; + if (!f.isHidden() && + (name.charAt(name.length - 1) != HIDDEN_CHAR || + name.charAt(name.length - 2) == HIDDEN_CHAR)) + fileList.push(f); + } + + fileList.sort(fileSort); + + for (var i = 0; i < fileList.length; i++) + { + var file = fileList[i]; + try + { + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + var sep = file.isDirectory() ? "/" : ""; + + // Note: using " to delimit the attribute here because encodeURIComponent + // passes through '. + var item = '<li><a href="' + encodeURIComponent(name) + sep + '">' + + htmlEscape(name) + sep + + '</a></li>'; + + body += item; + } + catch (e) { /* some file system error, ignore the file */ } + } + + body += ' </ol>\ +</body>\ +</html>'; + + response.bodyOutputStream.write(body, body.length); +} + +/** +* Sorts a and b (nsIFile objects) into an aesthetically pleasing order. +*/ +function fileSort(a, b) +{ + var dira = a.isDirectory(), dirb = b.isDirectory(); + + if (dira && !dirb) + return -1; + if (dirb && !dira) + return 1; + + var namea = a.leafName.toLowerCase(), nameb = b.leafName.toLowerCase(); + return nameb > namea ? -1 : 1; +} + + +/** +* Converts an externally-provided path into an internal path for use in +* determining file mappings. +* +* @param path +* the path to convert +* @param encoded +* true if the given path should be passed through decodeURI prior to +* conversion +* @throws URIError +* if path is incorrectly encoded +*/ +function toInternalPath(path, encoded) +{ + if (encoded) + path = decodeURI(path); + + var comps = path.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + if (comp.charAt(comp.length - 1) == HIDDEN_CHAR) + comps[i] = comp + HIDDEN_CHAR; + } + return comps.join("/"); +} + + +/** +* Adds custom-specified headers for the given file to the given response, if +* any such headers are specified. +* +* @param file +* the file on the disk which is to be written +* @param metadata +* metadata about the incoming request +* @param response +* the Response to which any specified headers/data should be written +* @throws HTTP_500 +* if an error occurred while processing custom-specified headers +*/ +function maybeAddHeaders(file, metadata, response) +{ + var name = file.leafName; + if (name.charAt(name.length - 1) == HIDDEN_CHAR) + name = name.substring(0, name.length - 1); + + var headerFile = file.parent; + headerFile.append(name + HEADERS_SUFFIX); + + if (!headerFile.exists()) + return; + + const PR_RDONLY = 0x01; + var fis = new FileInputStream(headerFile, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var lis = new ConverterInputStream(fis, "UTF-8", 1024, 0x0); + lis.QueryInterface(Ci.nsIUnicharLineInputStream); + + var line = {value: ""}; + var more = lis.readLine(line); + + if (!more && line.value == "") + return; + + + // request line + + var status = line.value; + if (status.indexOf("HTTP ") == 0) + { + status = status.substring(5); + var space = status.indexOf(" "); + var code, description; + if (space < 0) + { + code = status; + description = ""; + } + else + { + code = status.substring(0, space); + description = status.substring(space + 1, status.length); + } + + response.setStatusLine(metadata.httpVersion, parseInt(code, 10), description); + + line.value = ""; + more = lis.readLine(line); + } + + // headers + while (more || line.value != "") + { + var header = line.value; + var colon = header.indexOf(":"); + + response.setHeader(header.substring(0, colon), + header.substring(colon + 1, header.length), + false); // allow overriding server-set headers + + line.value = ""; + more = lis.readLine(line); + } + } + catch (e) + { + dumpn("WARNING: error in headers for " + metadata.path + ": " + e); + throw HTTP_500; + } + finally + { + fis.close(); + } +} + + +/** +* An object which handles requests for a server, executing default and +* overridden behaviors as instructed by the code which uses and manipulates it. +* Default behavior includes the paths / and /trace (diagnostics), with some +* support for HTTP error pages for various codes and fallback to HTTP 500 if +* those codes fail for any reason. +* +* @param server : nsHttpServer +* the server in which this handler is being used +*/ +function ServerHandler(server) +{ + // FIELDS + + /** +* The nsHttpServer instance associated with this handler. +*/ + this._server = server; + + /** +* A FileMap object containing the set of path->nsILocalFile mappings for +* all directory mappings set in the server (e.g., "/" for /var/www/html/, +* "/foo/bar/" for /local/path/, and "/foo/bar/baz/" for /local/path2). +* +* Note carefully: the leading and trailing "/" in each path (not file) are +* removed before insertion to simplify the code which uses this. You have +* been warned! +*/ + this._pathDirectoryMap = new FileMap(); + + /** +* Custom request handlers for the server in which this resides. Path-handler +* pairs are stored as property-value pairs in this property. +* +* @see ServerHandler.prototype._defaultPaths +*/ + this._overridePaths = {}; + + /** +* Custom request handlers for the server in which this resides. Prefix-handler +* pairs are stored as property-value pairs in this property. +*/ + this._overridePrefixes = {}; + + /** +* Custom request handlers for the error handlers in the server in which this +* resides. Path-handler pairs are stored as property-value pairs in this +* property. +* +* @see ServerHandler.prototype._defaultErrors +*/ + this._overrideErrors = {}; + + /** +* Maps file extensions to their MIME types in the server, overriding any +* mapping that might or might not exist in the MIME service. +*/ + this._mimeMappings = {}; + + /** +* The default handler for requests for directories, used to serve directories +* when no index file is present. +*/ + this._indexHandler = defaultIndexHandler; + + /** Per-path state storage for the server. */ + this._state = {}; + + /** Entire-server state storage. */ + this._sharedState = {}; + + /** Entire-server state storage for nsISupports values. */ + this._objectState = {}; +} +ServerHandler.prototype = +{ + // PUBLIC API + + /** +* Handles a request to this server, responding to the request appropriately +* and initiating server shutdown if necessary. +* +* This method never throws an exception. +* +* @param connection : Connection +* the connection for this request +*/ + handleResponse: function(connection) + { + var request = connection.request; + var response = new Response(connection); + + var path = request.path; + dumpn("*** path == " + path); + + try + { + try + { + if (path in this._overridePaths) + { + // explicit paths first, then files based on existing directory mappings, + // then (if the file doesn't exist) built-in server default paths + dumpn("calling override for " + path); + this._overridePaths[path](request, response); + } + else + { + let longestPrefix = ""; + for (let prefix in this._overridePrefixes) + { + if (prefix.length > longestPrefix.length && path.startsWith(prefix)) + { + longestPrefix = prefix; + } + } + if (longestPrefix.length > 0) + { + dumpn("calling prefix override for " + longestPrefix); + this._overridePrefixes[longestPrefix](request, response); + } + else + { + this._handleDefault(request, response); + } + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + if (!(e instanceof HttpError)) + { + dumpn("*** unexpected error: e == " + e); + throw HTTP_500; + } + if (e.code !== 404) + throw e; + + dumpn("*** default: " + (path in this._defaultPaths)); + + response = new Response(connection); + if (path in this._defaultPaths) + this._defaultPaths[path](request, response); + else + throw HTTP_404; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + var errorCode = "internal"; + + try + { + if (!(e instanceof HttpError)) + throw e; + + errorCode = e.code; + dumpn("*** errorCode == " + errorCode); + + response = new Response(connection); + if (e.customErrorHandling) + e.customErrorHandling(response); + this._handleError(errorCode, request, response); + return; + } + catch (e2) + { + dumpn("*** error handling " + errorCode + " error: " + + "e2 == " + e2 + ", shutting down server"); + + connection.server._requestQuit(); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // + // see nsIHttpServer.registerFile + // + registerFile: function(path, file) + { + if (!file) + { + dumpn("*** unregistering '" + path + "' mapping"); + delete this._overridePaths[path]; + return; + } + + dumpn("*** registering '" + path + "' as mapping to " + file.path); + file = file.clone(); + + var self = this; + this._overridePaths[path] = + function(request, response) + { + if (!file.exists()) + throw HTTP_404; + + response.setStatusLine(request.httpVersion, 200, "OK"); + self._writeFileResponse(request, file, response, 0, file.fileSize); + }; + }, + + // + // see nsIHttpServer.registerPathHandler + // + registerPathHandler: function(path, handler) + { + // XXX true path validation! + if (path.charAt(0) != "/") + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePaths, path); + }, + + // + // see nsIHttpServer.registerPrefixHandler + // + registerPrefixHandler: function(prefix, handler) + { + // XXX true prefix validation! + if (!(prefix.startsWith("/") && prefix.endsWith("/"))) + throw Cr.NS_ERROR_INVALID_ARG; + + this._handlerToField(handler, this._overridePrefixes, prefix); + }, + + // + // see nsIHttpServer.registerDirectory + // + registerDirectory: function(path, directory) + { + // strip off leading and trailing '/' so that we can use lastIndexOf when + // determining exactly how a path maps onto a mapped directory -- + // conditional is required here to deal with "/".substring(1, 0) being + // converted to "/".substring(0, 1) per the JS specification + var key = path.length == 1 ? "" : path.substring(1, path.length - 1); + + // the path-to-directory mapping code requires that the first character not + // be "/", or it will go into an infinite loop + if (key.charAt(0) == "/") + throw Cr.NS_ERROR_INVALID_ARG; + + key = toInternalPath(key, false); + + if (directory) + { + dumpn("*** mapping '" + path + "' to the location " + directory.path); + this._pathDirectoryMap.put(key, directory); + } + else + { + dumpn("*** removing mapping for '" + path + "'"); + this._pathDirectoryMap.put(key, null); + } + }, + + // + // see nsIHttpServer.registerErrorHandler + // + registerErrorHandler: function(err, handler) + { + if (!(err in HTTP_ERROR_CODES)) + dumpn("*** WARNING: registering non-HTTP/1.1 error code " + + "(" + err + ") handler -- was this intentional?"); + + this._handlerToField(handler, this._overrideErrors, err); + }, + + // + // see nsIHttpServer.setIndexHandler + // + setIndexHandler: function(handler) + { + if (!handler) + handler = defaultIndexHandler; + else if (typeof(handler) != "function") + handler = createHandlerFunc(handler); + + this._indexHandler = handler; + }, + + // + // see nsIHttpServer.registerContentType + // + registerContentType: function(ext, type) + { + if (!type) + delete this._mimeMappings[ext]; + else + this._mimeMappings[ext] = headerUtils.normalizeFieldValue(type); + }, + + // PRIVATE API + + /** +* Sets or remove (if handler is null) a handler in an object with a key. +* +* @param handler +* a handler, either function or an nsIHttpRequestHandler +* @param dict +* The object to attach the handler to. +* @param key +* The field name of the handler. +*/ + _handlerToField: function(handler, dict, key) + { + // for convenience, handler can be a function if this is run from xpcshell + if (typeof(handler) == "function") + dict[key] = handler; + else if (handler) + dict[key] = createHandlerFunc(handler); + else + delete dict[key]; + }, + + /** +* Handles a request which maps to a file in the local filesystem (if a base +* path has already been set; otherwise the 404 error is thrown). +* +* @param metadata : Request +* metadata for the incoming request +* @param response : Response +* an uninitialized Response to the given request, to be initialized by a +* request handler +* @throws HTTP_### +* if an HTTP error occurred (usually HTTP_404); note that in this case the +* calling code must handle post-processing of the response +*/ + _handleDefault: function(metadata, response) + { + dumpn("*** _handleDefault()"); + + response.setStatusLine(metadata.httpVersion, 200, "OK"); + + var path = metadata.path; + NS_ASSERT(path.charAt(0) == "/", "invalid path: <" + path + ">"); + + // determine the actual on-disk file; this requires finding the deepest + // path-to-directory mapping in the requested URL + var file = this._getFileForPath(path); + + // the "file" might be a directory, in which case we either serve the + // contained index.html or make the index handler write the response + if (file.exists() && file.isDirectory()) + { + file.append("index.html"); // make configurable? + if (!file.exists() || file.isDirectory()) + { + metadata._ensurePropertyBag(); + metadata._bag.setPropertyAsInterface("directory", file.parent); + this._indexHandler(metadata, response); + return; + } + } + + // alternately, the file might not exist + if (!file.exists()) + throw HTTP_404; + + var start, end; + if (metadata._httpVersion.atLeast(nsHttpVersion.HTTP_1_1) && + metadata.hasHeader("Range") && + this._getTypeFromFile(file) !== SJS_TYPE) + { + var rangeMatch = metadata.getHeader("Range").match(/^bytes=(\d+)?-(\d+)?$/); + if (!rangeMatch) + throw HTTP_400; + + if (rangeMatch[1] !== undefined) + start = parseInt(rangeMatch[1], 10); + + if (rangeMatch[2] !== undefined) + end = parseInt(rangeMatch[2], 10); + + if (start === undefined && end === undefined) + throw HTTP_400; + + // No start given, so the end is really the count of bytes from the + // end of the file. + if (start === undefined) + { + start = Math.max(0, file.fileSize - end); + end = file.fileSize - 1; + } + + // start and end are inclusive + if (end === undefined || end >= file.fileSize) + end = file.fileSize - 1; + + if (start !== undefined && start >= file.fileSize) { + var HTTP_416 = new HttpError(416, "Requested Range Not Satisfiable"); + HTTP_416.customErrorHandling = function(errorResponse) + { + maybeAddHeaders(file, metadata, errorResponse); + }; + throw HTTP_416; + } + + if (end < start) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + start = 0; + end = file.fileSize - 1; + } + else + { + response.setStatusLine(metadata.httpVersion, 206, "Partial Content"); + var contentRange = "bytes " + start + "-" + end + "/" + file.fileSize; + response.setHeader("Content-Range", contentRange); + } + } + else + { + start = 0; + end = file.fileSize - 1; + } + + // finally... + dumpn("*** handling '" + path + "' as mapping to " + file.path + " from " + + start + " to " + end + " inclusive"); + this._writeFileResponse(metadata, file, response, start, end - start + 1); + }, + + /** +* Writes an HTTP response for the given file, including setting headers for +* file metadata. +* +* @param metadata : Request +* the Request for which a response is being generated +* @param file : nsILocalFile +* the file which is to be sent in the response +* @param response : Response +* the response to which the file should be written +* @param offset: uint +* the byte offset to skip to when writing +* @param count: uint +* the number of bytes to write +*/ + _writeFileResponse: function(metadata, file, response, offset, count) + { + const PR_RDONLY = 0x01; + + var type = this._getTypeFromFile(file); + if (type === SJS_TYPE) + { + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + try + { + var sis = new ScriptableInputStream(fis); + var s = Cu.Sandbox(gGlobalObject); + s.importFunction(dump, "dump"); + + // Define a basic key-value state-preservation API across requests, with + // keys initially corresponding to the empty string. + var self = this; + var path = metadata.path; + s.importFunction(function getState(k) + { + return self._getState(path, k); + }); + s.importFunction(function setState(k, v) + { + self._setState(path, k, v); + }); + s.importFunction(function getSharedState(k) + { + return self._getSharedState(k); + }); + s.importFunction(function setSharedState(k, v) + { + self._setSharedState(k, v); + }); + s.importFunction(function getObjectState(k, callback) + { + callback(self._getObjectState(k)); + }); + s.importFunction(function setObjectState(k, v) + { + self._setObjectState(k, v); + }); + s.importFunction(function registerPathHandler(p, h) + { + self.registerPathHandler(p, h); + }); + + // Make it possible for sjs files to access their location + this._setState(path, "__LOCATION__", file.path); + + try + { + // Alas, the line number in errors dumped to console when calling the + // request handler is simply an offset from where we load the SJS file. + // Work around this in a reasonably non-fragile way by dynamically + // getting the line number where we evaluate the SJS file. Don't + // separate these two lines! + var line = new Error().lineNumber; + Cu.evalInSandbox(sis.read(file.fileSize), s); + } + catch (e) + { + dumpn("*** syntax error in SJS at " + file.path + ": " + e); + throw HTTP_500; + } + + try + { + s.handleRequest(metadata, response); + } + catch (e) + { + dump("*** error running SJS at " + file.path + ": " + + e + " on line " + + (e instanceof Error + ? e.lineNumber + " in httpd.js" + : (e.lineNumber - line)) + "\n"); + throw HTTP_500; + } + } + finally + { + fis.close(); + } + } + else + { + try + { + response.setHeader("Last-Modified", + toDateString(file.lastModifiedTime), + false); + } + catch (e) { /* lastModifiedTime threw, ignore */ } + + response.setHeader("Content-Type", type, false); + maybeAddHeaders(file, metadata, response); + response.setHeader("Content-Length", "" + count, false); + + var fis = new FileInputStream(file, PR_RDONLY, 0o444, + Ci.nsIFileInputStream.CLOSE_ON_EOF); + + offset = offset || 0; + count = count || file.fileSize; + NS_ASSERT(offset === 0 || offset < file.fileSize, "bad offset"); + NS_ASSERT(count >= 0, "bad count"); + NS_ASSERT(offset + count <= file.fileSize, "bad total data size"); + + try + { + if (offset !== 0) + { + // Seek (or read, if seeking isn't supported) to the correct offset so + // the data sent to the client matches the requested range. + if (fis instanceof Ci.nsISeekableStream) + fis.seek(Ci.nsISeekableStream.NS_SEEK_SET, offset); + else + new ScriptableInputStream(fis).read(offset); + } + } + catch (e) + { + fis.close(); + throw e; + } + + let writeMore = function writeMore() + { + gThreadManager.currentThread + .dispatch(writeData, Ci.nsIThread.DISPATCH_NORMAL); + } + + var input = new BinaryInputStream(fis); + var output = new BinaryOutputStream(response.bodyOutputStream); + var writeData = + { + run: function() + { + var chunkSize = Math.min(65536, count); + count -= chunkSize; + NS_ASSERT(count >= 0, "underflow"); + + try + { + var data = input.readByteArray(chunkSize); + NS_ASSERT(data.length === chunkSize, + "incorrect data returned? got " + data.length + + ", expected " + chunkSize); + output.writeByteArray(data, data.length); + if (count === 0) + { + fis.close(); + response.finish(); + } + else + { + writeMore(); + } + } + catch (e) + { + try + { + fis.close(); + } + finally + { + response.finish(); + } + throw e; + } + } + }; + + writeMore(); + + // Now that we know copying will start, flag the response as async. + response.processAsync(); + } + }, + + /** +* Get the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getState: function(path, k) + { + var state = this._state; + if (path in state && k in state[path]) + return state[path][k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for the given path for SJS state +* preservation across requests. +* +* @param path : string +* the path from which the given state is to be retrieved +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setState: function(path, k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + var state = this._state; + if (!(path in state)) + state[path] = {}; + state[path][k] = v; + }, + + /** +* Get the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be returned +* @returns string +* the corresponding value, which is initially the empty string +*/ + _getSharedState: function(k) + { + var state = this._sharedState; + if (k in state) + return state[k]; + return ""; + }, + + /** +* Set the value corresponding to a given key for SJS state preservation +* across requests. +* +* @param k : string +* the key whose corresponding value is to be set +* @param v : string +* the value to be set +*/ + _setSharedState: function(k, v) + { + if (typeof v !== "string") + throw new Error("non-string value passed"); + this._sharedState[k] = v; + }, + + /** +* Returns the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be returned +* @returns nsISupports +* the corresponding object, or null if none was present +*/ + _getObjectState: function(k) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + return this._objectState[k] || null; + }, + + /** +* Sets the object associated with the given key in the server for SJS +* state preservation across requests. +* +* @param k : string +* the key whose corresponding object is to be set +* @param v : nsISupports +* the object to be associated with the given key; may be null +*/ + _setObjectState: function(k, v) + { + if (typeof k !== "string") + throw new Error("non-string key passed"); + if (typeof v !== "object") + throw new Error("non-object value passed"); + if (v && !("QueryInterface" in v)) + { + throw new Error("must pass an nsISupports; use wrappedJSObject to ease " + + "pain when using the server from JS"); + } + + this._objectState[k] = v; + }, + + /** +* Gets a content-type for the given file, first by checking for any custom +* MIME-types registered with this handler for the file's extension, second by +* asking the global MIME service for a content-type, and finally by failing +* over to application/octet-stream. +* +* @param file : nsIFile +* the nsIFile for which to get a file type +* @returns string +* the best content-type which can be determined for the file +*/ + _getTypeFromFile: function(file) + { + try + { + var name = file.leafName; + var dot = name.lastIndexOf("."); + if (dot > 0) + { + var ext = name.slice(dot + 1); + if (ext in this._mimeMappings) + return this._mimeMappings[ext]; + } + return Cc["@mozilla.org/uriloader/external-helper-app-service;1"] + .getService(Ci.nsIMIMEService) + .getTypeFromFile(file); + } + catch (e) + { + return "application/octet-stream"; + } + }, + + /** +* Returns the nsILocalFile which corresponds to the path, as determined using +* all registered path->directory mappings and any paths which are explicitly +* overridden. +* +* @param path : string +* the server path for which a file should be retrieved, e.g. "/foo/bar" +* @throws HttpError +* when the correct action is the corresponding HTTP error (i.e., because no +* mapping was found for a directory in path, the referenced file doesn't +* exist, etc.) +* @returns nsILocalFile +* the file to be sent as the response to a request for the path +*/ + _getFileForPath: function(path) + { + // decode and add underscores as necessary + try + { + path = toInternalPath(path, true); + } + catch (e) + { + throw HTTP_400; // malformed path + } + + // next, get the directory which contains this path + var pathMap = this._pathDirectoryMap; + + // An example progression of tmp for a path "/foo/bar/baz/" might be: + // "foo/bar/baz/", "foo/bar/baz", "foo/bar", "foo", "" + var tmp = path.substring(1); + while (true) + { + // do we have a match for current head of the path? + var file = pathMap.get(tmp); + if (file) + { + // XXX hack; basically disable showing mapping for /foo/bar/ when the + // requested path was /foo/bar, because relative links on the page + // will all be incorrect -- we really need the ability to easily + // redirect here instead + if (tmp == path.substring(1) && + tmp.length != 0 && + tmp.charAt(tmp.length - 1) != "/") + file = null; + else + break; + } + + // if we've finished trying all prefixes, exit + if (tmp == "") + break; + + tmp = tmp.substring(0, tmp.lastIndexOf("/")); + } + + // no mapping applies, so 404 + if (!file) + throw HTTP_404; + + + // last, get the file for the path within the determined directory + var parentFolder = file.parent; + var dirIsRoot = (parentFolder == null); + + // Strategy here is to append components individually, making sure we + // never move above the given directory; this allows paths such as + // "<file>/foo/../bar" but prevents paths such as "<file>/../base-sibling"; + // this component-wise approach also means the code works even on platforms + // which don't use "/" as the directory separator, such as Windows + var leafPath = path.substring(tmp.length + 1); + var comps = leafPath.split("/"); + for (var i = 0, sz = comps.length; i < sz; i++) + { + var comp = comps[i]; + + if (comp == "..") + file = file.parent; + else if (comp == "." || comp == "") + continue; + else + file.append(comp); + + if (!dirIsRoot && file.equals(parentFolder)) + throw HTTP_403; + } + + return file; + }, + + /** +* Writes the error page for the given HTTP error code over the given +* connection. +* +* @param errorCode : uint +* the HTTP error code to be used +* @param connection : Connection +* the connection on which the error occurred +*/ + handleError: function(errorCode, connection) + { + var response = new Response(connection); + + dumpn("*** error in request: " + errorCode); + + this._handleError(errorCode, new Request(connection.port), response); + }, + + /** +* Handles a request which generates the given error code, using the +* user-defined error handler if one has been set, gracefully falling back to +* the x00 status code if the code has no handler, and failing to status code +* 500 if all else fails. +* +* @param errorCode : uint +* the HTTP error which is to be returned +* @param metadata : Request +* metadata for the request, which will often be incomplete since this is an +* error +* @param response : Response +* an uninitialized Response should be initialized when this method +* completes with information which represents the desired error code in the +* ideal case or a fallback code in abnormal circumstances (i.e., 500 is a +* fallback for 505, per HTTP specs) +*/ + _handleError: function(errorCode, metadata, response) + { + if (!metadata) + throw Cr.NS_ERROR_NULL_POINTER; + + var errorX00 = errorCode - (errorCode % 100); + + try + { + if (!(errorCode in HTTP_ERROR_CODES)) + dumpn("*** WARNING: requested invalid error: " + errorCode); + + // RFC 2616 says that we should try to handle an error by its class if we + // can't otherwise handle it -- if that fails, we revert to handling it as + // a 500 internal server error, and if that fails we throw and shut down + // the server + + // actually handle the error + try + { + if (errorCode in this._overrideErrors) + this._overrideErrors[errorCode](metadata, response); + else + this._defaultErrors[errorCode](metadata, response); + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(e); + return; + } + + // don't retry the handler that threw + if (errorX00 == errorCode) + throw HTTP_500; + + dumpn("*** error in handling for error code " + errorCode + ", " + + "falling back to " + errorX00 + "..."); + response = new Response(response._connection); + if (errorX00 in this._overrideErrors) + this._overrideErrors[errorX00](metadata, response); + else if (errorX00 in this._defaultErrors) + this._defaultErrors[errorX00](metadata, response); + else + throw HTTP_500; + } + } + catch (e) + { + if (response.partiallySent()) + { + response.abort(); + return; + } + + // we've tried everything possible for a meaningful error -- now try 500 + dumpn("*** error in handling for error code " + errorX00 + ", falling " + + "back to 500..."); + + try + { + response = new Response(response._connection); + if (500 in this._overrideErrors) + this._overrideErrors[500](metadata, response); + else + this._defaultErrors[500](metadata, response); + } + catch (e2) + { + dumpn("*** multiple errors in default error handlers!"); + dumpn("*** e == " + e + ", e2 == " + e2); + response.abort(e2); + return; + } + } + + response.complete(); + }, + + // FIELDS + + /** +* This object contains the default handlers for the various HTTP error codes. +*/ + _defaultErrors: + { + 400: function(metadata, response) + { + // none of the data in metadata is reliable, so hard-code everything here + response.setStatusLine("1.1", 400, "Bad Request"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Bad request\n"; + response.bodyOutputStream.write(body, body.length); + }, + 403: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 403, "Forbidden"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>403 Forbidden</title></head>\ +<body>\ +<h1>403 Forbidden</h1>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 404: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 404, "Not Found"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>404 Not Found</title></head>\ +<body>\ +<h1>404 Not Found</h1>\ +<p>\ +<span style='font-family: monospace;'>" + + htmlEscape(metadata.path) + + "</span> was not found.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 416: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 416, + "Requested Range Not Satisfiable"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head>\ +<title>416 Requested Range Not Satisfiable</title></head>\ +<body>\ +<h1>416 Requested Range Not Satisfiable</h1>\ +<p>The byte range was not valid for the\ +requested resource.\ +</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 500: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, + 500, + "Internal Server Error"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>500 Internal Server Error</title></head>\ +<body>\ +<h1>500 Internal Server Error</h1>\ +<p>Something's broken in this server and\ +needs to be fixed.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 501: function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 501, "Not Implemented"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>501 Not Implemented</title></head>\ +<body>\ +<h1>501 Not Implemented</h1>\ +<p>This server is not (yet) Apache.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + }, + 505: function(metadata, response) + { + response.setStatusLine("1.1", 505, "HTTP Version Not Supported"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>505 HTTP Version Not Supported</title></head>\ +<body>\ +<h1>505 HTTP Version Not Supported</h1>\ +<p>This server only supports HTTP/1.0 and HTTP/1.1\ +connections.</p>\ +</body>\ +</html>"; + response.bodyOutputStream.write(body, body.length); + } + }, + + /** +* Contains handlers for the default set of URIs contained in this server. +*/ + _defaultPaths: + { + "/": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/html", false); + + var body = "<html>\ +<head><title>httpd.js</title></head>\ +<body>\ +<h1>httpd.js</h1>\ +<p>If you're seeing this page, httpd.js is up and\ +serving requests! Now set a base path and serve some\ +files!</p>\ +</body>\ +</html>"; + + response.bodyOutputStream.write(body, body.length); + }, + + "/trace": function(metadata, response) + { + response.setStatusLine(metadata.httpVersion, 200, "OK"); + response.setHeader("Content-Type", "text/plain", false); + + var body = "Request-URI: " + + metadata.scheme + "://" + metadata.host + ":" + metadata.port + + metadata.path + "\n\n"; + body += "Request (semantically equivalent, slightly reformatted):\n\n"; + body += metadata.method + " " + metadata.path; + + if (metadata.queryString) + body += "?" + metadata.queryString; + + body += " HTTP/" + metadata.httpVersion + "\r\n"; + + var headEnum = metadata.headers; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + body += fieldName + ": " + metadata.getHeader(fieldName) + "\r\n"; + } + + response.bodyOutputStream.write(body, body.length); + } + } +}; + + +/** +* Maps absolute paths to files on the local file system (as nsILocalFiles). +*/ +function FileMap() +{ + /** Hash which will map paths to nsILocalFiles. */ + this._map = {}; +} +FileMap.prototype = +{ + // PUBLIC API + + /** +* Maps key to a clone of the nsILocalFile value if value is non-null; +* otherwise, removes any extant mapping for key. +* +* @param key : string +* string to which a clone of value is mapped +* @param value : nsILocalFile +* the file to map to key, or null to remove a mapping +*/ + put: function(key, value) + { + if (value) + this._map[key] = value.clone(); + else + delete this._map[key]; + }, + + /** +* Returns a clone of the nsILocalFile mapped to key, or null if no such +* mapping exists. +* +* @param key : string +* key to which the returned file maps +* @returns nsILocalFile +* a clone of the mapped file, or null if no mapping exists +*/ + get: function(key) + { + var val = this._map[key]; + return val ? val.clone() : null; + } +}; + + +// Response CONSTANTS + +// token = *<any CHAR except CTLs or separators> +// CHAR = <any US-ASCII character (0-127)> +// CTL = <any US-ASCII control character (0-31) and DEL (127)> +// separators = "(" | ")" | "<" | ">" | "@" +// | "," | ";" | ":" | "\" | <"> +// | "/" | "[" | "]" | "?" | "=" +// | "{" | "}" | SP | HT +const IS_TOKEN_ARRAY = + [0, 0, 0, 0, 0, 0, 0, 0, // 0 + 0, 0, 0, 0, 0, 0, 0, 0, // 8 + 0, 0, 0, 0, 0, 0, 0, 0, // 16 + 0, 0, 0, 0, 0, 0, 0, 0, // 24 + + 0, 1, 0, 1, 1, 1, 1, 1, // 32 + 0, 0, 1, 1, 0, 1, 1, 0, // 40 + 1, 1, 1, 1, 1, 1, 1, 1, // 48 + 1, 1, 0, 0, 0, 0, 0, 0, // 56 + + 0, 1, 1, 1, 1, 1, 1, 1, // 64 + 1, 1, 1, 1, 1, 1, 1, 1, // 72 + 1, 1, 1, 1, 1, 1, 1, 1, // 80 + 1, 1, 1, 0, 0, 0, 1, 1, // 88 + + 1, 1, 1, 1, 1, 1, 1, 1, // 96 + 1, 1, 1, 1, 1, 1, 1, 1, // 104 + 1, 1, 1, 1, 1, 1, 1, 1, // 112 + 1, 1, 1, 0, 1, 0, 1]; // 120 + + +/** +* Determines whether the given character code is a CTL. +* +* @param code : uint +* the character code +* @returns boolean +* true if code is a CTL, false otherwise +*/ +function isCTL(code) +{ + return (code >= 0 && code <= 31) || (code == 127); +} + +/** +* Represents a response to an HTTP request, encapsulating all details of that +* response. This includes all headers, the HTTP version, status code and +* explanation, and the entity itself. +* +* @param connection : Connection +* the connection over which this response is to be written +*/ +function Response(connection) +{ + /** The connection over which this response will be written. */ + this._connection = connection; + + /** +* The HTTP version of this response; defaults to 1.1 if not set by the +* handler. +*/ + this._httpVersion = nsHttpVersion.HTTP_1_1; + + /** +* The HTTP code of this response; defaults to 200. +*/ + this._httpCode = 200; + + /** +* The description of the HTTP code in this response; defaults to "OK". +*/ + this._httpDescription = "OK"; + + /** +* An nsIHttpHeaders object in which the headers in this response should be +* stored. This property is null after the status line and headers have been +* written to the network, and it may be modified up until it is cleared, +* except if this._finished is set first (in which case headers are written +* asynchronously in response to a finish() call not preceded by +* flushHeaders()). +*/ + this._headers = new nsHttpHeaders(); + + /** +* Set to true when this response is ended (completely constructed if possible +* and the connection closed); further actions on this will then fail. +*/ + this._ended = false; + + /** +* A stream used to hold data written to the body of this response. +*/ + this._bodyOutputStream = null; + + /** +* A stream containing all data that has been written to the body of this +* response so far. (Async handlers make the data contained in this +* unreliable as a way of determining content length in general, but auxiliary +* saved information can sometimes be used to guarantee reliability.) +*/ + this._bodyInputStream = null; + + /** +* A stream copier which copies data to the network. It is initially null +* until replaced with a copier for response headers; when headers have been +* fully sent it is replaced with a copier for the response body, remaining +* so for the duration of response processing. +*/ + this._asyncCopier = null; + + /** +* True if this response has been designated as being processed +* asynchronously rather than for the duration of a single call to +* nsIHttpRequestHandler.handle. +*/ + this._processAsync = false; + + /** +* True iff finish() has been called on this, signaling that no more changes +* to this may be made. +*/ + this._finished = false; + + /** +* True iff powerSeized() has been called on this, signaling that this +* response is to be handled manually by the response handler (which may then +* send arbitrary data in response, even non-HTTP responses). +*/ + this._powerSeized = false; +} +Response.prototype = +{ + // PUBLIC CONSTRUCTION API + + // + // see nsIHttpResponse.bodyOutputStream + // + get bodyOutputStream() + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + if (!this._bodyOutputStream) + { + var pipe = new Pipe(true, false, Response.SEGMENT_SIZE, PR_UINT32_MAX, + null); + this._bodyOutputStream = pipe.outputStream; + this._bodyInputStream = pipe.inputStream; + if (this._processAsync || this._powerSeized) + this._startAsyncProcessor(); + } + + return this._bodyOutputStream; + }, + + // + // see nsIHttpResponse.write + // + write: function(data) + { + if (this._finished) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + var dataAsString = String(data); + this.bodyOutputStream.write(dataAsString, dataAsString.length); + }, + + // + // see nsIHttpResponse.setStatusLine + // + setStatusLine: function(httpVersion, code, description) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + if (!(code >= 0 && code < 1000)) + throw Cr.NS_ERROR_INVALID_ARG; + + try + { + var httpVer; + // avoid version construction for the most common cases + if (!httpVersion || httpVersion == "1.1") + httpVer = nsHttpVersion.HTTP_1_1; + else if (httpVersion == "1.0") + httpVer = nsHttpVersion.HTTP_1_0; + else + httpVer = new nsHttpVersion(httpVersion); + } + catch (e) + { + throw Cr.NS_ERROR_INVALID_ARG; + } + + // Reason-Phrase = *<TEXT, excluding CR, LF> + // TEXT = <any OCTET except CTLs, but including LWS> + // + // XXX this ends up disallowing octets which aren't Unicode, I think -- not + // much to do if description is IDL'd as string + if (!description) + description = ""; + for (var i = 0; i < description.length; i++) + if (isCTL(description.charCodeAt(i)) && description.charAt(i) != "\t") + throw Cr.NS_ERROR_INVALID_ARG; + + // set the values only after validation to preserve atomicity + this._httpDescription = description; + this._httpCode = code; + this._httpVersion = httpVer; + }, + + // + // see nsIHttpResponse.setHeader + // + setHeader: function(name, value, merge) + { + if (!this._headers || this._finished || this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + this._ensureAlive(); + + this._headers.setHeader(name, value, merge); + }, + + // + // see nsIHttpResponse.processAsync + // + processAsync: function() + { + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._processAsync) + return; + this._ensureAlive(); + + dumpn("*** processing connection " + this._connection.number + " async"); + this._processAsync = true; + + /* +* Either the bodyOutputStream getter or this method is responsible for +* starting the asynchronous processor and catching writes of data to the +* response body of async responses as they happen, for the purpose of +* forwarding those writes to the actual connection's output stream. +* If bodyOutputStream is accessed first, calling this method will create +* the processor (when it first is clear that body data is to be written +* immediately, not buffered). If this method is called first, accessing +* bodyOutputStream will create the processor. If only this method is +* called, we'll write nothing, neither headers nor the nonexistent body, +* until finish() is called. Since that delay is easily avoided by simply +* getting bodyOutputStream or calling write(""), we don't worry about it. +*/ + if (this._bodyOutputStream && !this._asyncCopier) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.seizePower + // + seizePower: function() + { + if (this._processAsync) + throw Cr.NS_ERROR_NOT_AVAILABLE; + if (this._finished) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._powerSeized) + return; + this._ensureAlive(); + + dumpn("*** forcefully seizing power over connection " + + this._connection.number + "..."); + + // Purge any already-written data without sending it. We could as easily + // swap out the streams entirely, but that makes it possible to acquire and + // unknowingly use a stale reference, so we require there only be one of + // each stream ever for any response to avoid this complication. + if (this._asyncCopier) + this._asyncCopier.cancel(Cr.NS_BINDING_ABORTED); + this._asyncCopier = null; + if (this._bodyOutputStream) + { + var input = new BinaryInputStream(this._bodyInputStream); + var avail; + while ((avail = input.available()) > 0) + input.readByteArray(avail); + } + + this._powerSeized = true; + if (this._bodyOutputStream) + this._startAsyncProcessor(); + }, + + // + // see nsIHttpResponse.finish + // + finish: function() + { + if (!this._processAsync && !this._powerSeized) + throw Cr.NS_ERROR_UNEXPECTED; + if (this._finished) + return; + + dumpn("*** finishing connection " + this._connection.number); + this._startAsyncProcessor(); // in case bodyOutputStream was never accessed + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + this._finished = true; + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpResponse) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // POST-CONSTRUCTION API (not exposed externally) + + /** +* The HTTP version number of this, as a string (e.g. "1.1"). +*/ + get httpVersion() + { + this._ensureAlive(); + return this._httpVersion.toString(); + }, + + /** +* The HTTP status code of this response, as a string of three characters per +* RFC 2616. +*/ + get httpCode() + { + this._ensureAlive(); + + var codeString = (this._httpCode < 10 ? "0" : "") + + (this._httpCode < 100 ? "0" : "") + + this._httpCode; + return codeString; + }, + + /** +* The description of the HTTP status code of this response, or "" if none is +* set. +*/ + get httpDescription() + { + this._ensureAlive(); + + return this._httpDescription; + }, + + /** +* The headers in this response, as an nsHttpHeaders object. +*/ + get headers() + { + this._ensureAlive(); + + return this._headers; + }, + + // + // see nsHttpHeaders.getHeader + // + getHeader: function(name) + { + this._ensureAlive(); + + return this._headers.getHeader(name); + }, + + /** +* Determines whether this response may be abandoned in favor of a newly +* constructed response. A response may be abandoned only if it is not being +* sent asynchronously and if raw control over it has not been taken from the +* server. +* +* @returns boolean +* true iff no data has been written to the network +*/ + partiallySent: function() + { + dumpn("*** partiallySent()"); + return this._processAsync || this._powerSeized; + }, + + /** +* If necessary, kicks off the remaining request processing needed to be done +* after a request handler performs its initial work upon this response. +*/ + complete: function() + { + dumpn("*** complete()"); + if (this._processAsync || this._powerSeized) + { + NS_ASSERT(this._processAsync ^ this._powerSeized, + "can't both send async and relinquish power"); + return; + } + + NS_ASSERT(!this.partiallySent(), "completing a partially-sent response?"); + + this._startAsyncProcessor(); + + // Now make sure we finish processing this request! + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + }, + + /** +* Abruptly ends processing of this response, usually due to an error in an +* incoming request but potentially due to a bad error handler. Since we +* cannot handle the error in the usual way (giving an HTTP error page in +* response) because data may already have been sent (or because the response +* might be expected to have been generated asynchronously or completely from +* scratch by the handler), we stop processing this response and abruptly +* close the connection. +* +* @param e : Error +* the exception which precipitated this abort, or null if no such exception +* was generated +*/ + abort: function(e) + { + dumpn("*** abort(<" + e + ">)"); + + // This response will be ended by the processor if one was created. + var copier = this._asyncCopier; + if (copier) + { + // We dispatch asynchronously here so that any pending writes of data to + // the connection will be deterministically written. This makes it easier + // to specify exact behavior, and it makes observable behavior more + // predictable for clients. Note that the correctness of this depends on + // callbacks in response to _waitToReadData in WriteThroughCopier + // happening asynchronously with respect to the actual writing of data to + // bodyOutputStream, as they currently do; if they happened synchronously, + // an event which ran before this one could write more data to the + // response body before we get around to canceling the copier. We have + // tests for this in test_seizepower.js, however, and I can't think of a + // way to handle both cases without removing bodyOutputStream access and + // moving its effective write(data, length) method onto Response, which + // would be slower and require more code than this anyway. + gThreadManager.currentThread.dispatch({ + run: function() + { + dumpn("*** canceling copy asynchronously..."); + copier.cancel(Cr.NS_ERROR_UNEXPECTED); + } + }, Ci.nsIThread.DISPATCH_NORMAL); + } + else + { + this.end(); + } + }, + + /** +* Closes this response's network connection, marks the response as finished, +* and notifies the server handler that the request is done being processed. +*/ + end: function() + { + NS_ASSERT(!this._ended, "ending this response twice?!?!"); + + this._connection.close(); + if (this._bodyOutputStream) + this._bodyOutputStream.close(); + + this._finished = true; + this._ended = true; + }, + + // PRIVATE IMPLEMENTATION + + /** +* Sends the status line and headers of this response if they haven't been +* sent and initiates the process of copying data written to this response's +* body to the network. +*/ + _startAsyncProcessor: function() + { + dumpn("*** _startAsyncProcessor()"); + + // Handle cases where we're being called a second time. The former case + // happens when this is triggered both by complete() and by processAsync(), + // while the latter happens when processAsync() in conjunction with sent + // data causes abort() to be called. + if (this._asyncCopier || this._ended) + { + dumpn("*** ignoring second call to _startAsyncProcessor"); + return; + } + + // Send headers if they haven't been sent already and should be sent, then + // asynchronously continue to send the body. + if (this._headers && !this._powerSeized) + { + this._sendHeaders(); + return; + } + + this._headers = null; + this._sendBody(); + }, + + /** +* Signals that all modifications to the response status line and headers are +* complete and then sends that data over the network to the client. Once +* this method completes, a different response to the request that resulted +* in this response cannot be sent -- the only possible action in case of +* error is to abort the response and close the connection. +*/ + _sendHeaders: function() + { + dumpn("*** _sendHeaders()"); + + NS_ASSERT(this._headers); + NS_ASSERT(!this._powerSeized); + + // request-line + var statusLine = "HTTP/" + this.httpVersion + " " + + this.httpCode + " " + + this.httpDescription + "\r\n"; + + // header post-processing + + var headers = this._headers; + headers.setHeader("Connection", "close", false); + headers.setHeader("Server", "httpd.js", false); + if (!headers.hasHeader("Date")) + headers.setHeader("Date", toDateString(Date.now()), false); + + // Any response not being processed asynchronously must have an associated + // Content-Length header for reasons of backwards compatibility with the + // initial server, which fully buffered every response before sending it. + // Beyond that, however, it's good to do this anyway because otherwise it's + // impossible to test behaviors that depend on the presence or absence of a + // Content-Length header. + if (!this._processAsync) + { + dumpn("*** non-async response, set Content-Length"); + + var bodyStream = this._bodyInputStream; + var avail = bodyStream ? bodyStream.available() : 0; + + // XXX assumes stream will always report the full amount of data available + headers.setHeader("Content-Length", "" + avail, false); + } + + + // construct and send response + dumpn("*** header post-processing completed, sending response head..."); + + // request-line + var preambleData = [statusLine]; + + // headers + var headEnum = headers.enumerator; + while (headEnum.hasMoreElements()) + { + var fieldName = headEnum.getNext() + .QueryInterface(Ci.nsISupportsString) + .data; + var values = headers.getHeaderValues(fieldName); + for (var i = 0, sz = values.length; i < sz; i++) + preambleData.push(fieldName + ": " + values[i] + "\r\n"); + } + + // end request-line/headers + preambleData.push("\r\n"); + + var preamble = preambleData.join(""); + + var responseHeadPipe = new Pipe(true, false, 0, PR_UINT32_MAX, null); + responseHeadPipe.outputStream.write(preamble, preamble.length); + + var response = this; + var copyObserver = + { + onStartRequest: function(request, cx) + { + dumpn("*** preamble copying started"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** preamble copying complete " + + "[status=0x" + statusCode.toString(16) + "]"); + + if (!components.isSuccessCode(statusCode)) + { + dumpn("!!! header copying problems: non-success statusCode, " + + "ending response"); + + response.end(); + } + else + { + response._sendBody(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + var headerCopier = this._asyncCopier = + new WriteThroughCopier(responseHeadPipe.inputStream, + this._connection.output, + copyObserver, null); + + responseHeadPipe.outputStream.close(); + + // Forbid setting any more headers or modifying the request line. + this._headers = null; + }, + + /** +* Asynchronously writes the body of the response (or the entire response, if +* seizePower() has been called) to the network. +*/ + _sendBody: function() + { + dumpn("*** _sendBody"); + + NS_ASSERT(!this._headers, "still have headers around but sending body?"); + + // If no body data was written, we're done + if (!this._bodyInputStream) + { + dumpn("*** empty body, response finished"); + this.end(); + return; + } + + var response = this; + var copyObserver = + { + onStartRequest: function(request, context) + { + dumpn("*** onStartRequest"); + }, + + onStopRequest: function(request, cx, statusCode) + { + dumpn("*** onStopRequest [status=0x" + statusCode.toString(16) + "]"); + + if (statusCode === Cr.NS_BINDING_ABORTED) + { + dumpn("*** terminating copy observer without ending the response"); + } + else + { + if (!components.isSuccessCode(statusCode)) + dumpn("*** WARNING: non-success statusCode in onStopRequest"); + + response.end(); + } + }, + + QueryInterface: function(aIID) + { + if (aIID.equals(Ci.nsIRequestObserver) || aIID.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } + }; + + dumpn("*** starting async copier of body data..."); + this._asyncCopier = + new WriteThroughCopier(this._bodyInputStream, this._connection.output, + copyObserver, null); + }, + + /** Ensures that this hasn't been ended. */ + _ensureAlive: function() + { + NS_ASSERT(!this._ended, "not handling response lifetime correctly"); + } +}; + +/** +* Size of the segments in the buffer used in storing response data and writing +* it to the socket. +*/ +Response.SEGMENT_SIZE = 8192; + +/** Serves double duty in WriteThroughCopier implementation. */ +function notImplemented() +{ + throw Cr.NS_ERROR_NOT_IMPLEMENTED; +} + +/** Returns true iff the given exception represents stream closure. */ +function streamClosed(e) +{ + return e === Cr.NS_BASE_STREAM_CLOSED || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_CLOSED); +} + +/** Returns true iff the given exception represents a blocked stream. */ +function wouldBlock(e) +{ + return e === Cr.NS_BASE_STREAM_WOULD_BLOCK || + (typeof e === "object" && e.result === Cr.NS_BASE_STREAM_WOULD_BLOCK); +} + +/** +* Copies data from source to sink as it becomes available, when that data can +* be written to sink without blocking. +* +* @param source : nsIAsyncInputStream +* the stream from which data is to be read +* @param sink : nsIAsyncOutputStream +* the stream to which data is to be copied +* @param observer : nsIRequestObserver +* an observer which will be notified when the copy starts and finishes +* @param context : nsISupports +* context passed to observer when notified of start/stop +* @throws NS_ERROR_NULL_POINTER +* if source, sink, or observer are null +*/ +function WriteThroughCopier(source, sink, observer, context) +{ + if (!source || !sink || !observer) + throw Cr.NS_ERROR_NULL_POINTER; + + /** Stream from which data is being read. */ + this._source = source; + + /** Stream to which data is being written. */ + this._sink = sink; + + /** Observer watching this copy. */ + this._observer = observer; + + /** Context for the observer watching this. */ + this._context = context; + + /** +* True iff this is currently being canceled (cancel has been called, the +* callback may not yet have been made). +*/ + this._canceled = false; + + /** +* False until all data has been read from input and written to output, at +* which point this copy is completed and cancel() is asynchronously called. +*/ + this._completed = false; + + /** Required by nsIRequest, meaningless. */ + this.loadFlags = 0; + /** Required by nsIRequest, meaningless. */ + this.loadGroup = null; + /** Required by nsIRequest, meaningless. */ + this.name = "response-body-copy"; + + /** Status of this request. */ + this.status = Cr.NS_OK; + + /** Arrays of byte strings waiting to be written to output. */ + this._pendingData = []; + + // start copying + try + { + observer.onStartRequest(this, context); + this._waitToReadData(); + this._waitForSinkClosure(); + } + catch (e) + { + dumpn("!!! error starting copy: " + e + + ("lineNumber" in e ? ", line " + e.lineNumber : "")); + dumpn(e.stack); + this.cancel(Cr.NS_ERROR_UNEXPECTED); + } +} +WriteThroughCopier.prototype = +{ + /* nsISupports implementation */ + + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIInputStreamCallback) || + iid.equals(Ci.nsIOutputStreamCallback) || + iid.equals(Ci.nsIRequest) || + iid.equals(Ci.nsISupports)) + { + return this; + } + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // NSIINPUTSTREAMCALLBACK + + /** +* Receives a more-data-in-input notification and writes the corresponding +* data to the output. +* +* @param input : nsIAsyncInputStream +* the input stream on whose data we have been waiting +*/ + onInputStreamReady: function(input) + { + if (this._source === null) + return; + + dumpn("*** onInputStreamReady"); + + // + // Ordinarily we'll read a non-zero amount of data from input, queue it up + // to be written and then wait for further callbacks. The complications in + // this method are the cases where we deviate from that behavior when errors + // occur or when copying is drawing to a finish. + // + // The edge cases when reading data are: + // + // Zero data is read + // If zero data was read, we're at the end of available data, so we can + // should stop reading and move on to writing out what we have (or, if + // we've already done that, onto notifying of completion). + // A stream-closed exception is thrown + // This is effectively a less kind version of zero data being read; the + // only difference is that we notify of completion with that result + // rather than with NS_OK. + // Some other exception is thrown + // This is the least kind result. We don't know what happened, so we + // act as though the stream closed except that we notify of completion + // with the result NS_ERROR_UNEXPECTED. + // + + var bytesWanted = 0, bytesConsumed = -1; + try + { + input = new BinaryInputStream(input); + + bytesWanted = Math.min(input.available(), Response.SEGMENT_SIZE); + dumpn("*** input wanted: " + bytesWanted); + + if (bytesWanted > 0) + { + var data = input.readByteArray(bytesWanted); + bytesConsumed = data.length; + this._pendingData.push(String.fromCharCode.apply(String, data)); + } + + dumpn("*** " + bytesConsumed + " bytes read"); + + // Handle the zero-data edge case in the same place as all other edge + // cases are handled. + if (bytesWanted === 0) + throw Cr.NS_BASE_STREAM_CLOSED; + } + catch (e) + { + if (streamClosed(e)) + { + dumpn("*** input stream closed"); + e = bytesWanted === 0 ? Cr.NS_OK : Cr.NS_ERROR_UNEXPECTED; + } + else + { + dumpn("!!! unexpected error reading from input, canceling: " + e); + e = Cr.NS_ERROR_UNEXPECTED; + } + + this._doneReadingSource(e); + return; + } + + var pendingData = this._pendingData; + + NS_ASSERT(bytesConsumed > 0); + NS_ASSERT(pendingData.length > 0, "no pending data somehow?"); + NS_ASSERT(pendingData[pendingData.length - 1].length > 0, + "buffered zero bytes of data?"); + + NS_ASSERT(this._source !== null); + + // Reading has gone great, and we've gotten data to write now. What if we + // don't have a place to write that data, because output went away just + // before this read? Drop everything on the floor, including new data, and + // cancel at this point. + if (this._sink === null) + { + pendingData.length = 0; + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we've read the data, and we know we have a place to write it. We + // need to queue up the data to be written, but *only* if none is queued + // already -- if data's already queued, the code that actually writes the + // data will make sure to wait on unconsumed pending data. + try + { + if (pendingData.length === 1) + this._waitToWriteData(); + } + catch (e) + { + dumpn("!!! error waiting to write data just read, swallowing and " + + "writing only what we already have: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Whee! We successfully read some data, and it's successfully queued up to + // be written. All that remains now is to wait for more data to read. + try + { + this._waitToReadData(); + } + catch (e) + { + dumpn("!!! error waiting to read more data: " + e); + this._doneReadingSource(Cr.NS_ERROR_UNEXPECTED); + } + }, + + + // NSIOUTPUTSTREAMCALLBACK + + /** +* Callback when data may be written to the output stream without blocking, or +* when the output stream has been closed. +* +* @param output : nsIAsyncOutputStream +* the output stream on whose writability we've been waiting, also known as +* this._sink +*/ + onOutputStreamReady: function(output) + { + if (this._sink === null) + return; + + dumpn("*** onOutputStreamReady"); + + var pendingData = this._pendingData; + if (pendingData.length === 0) + { + // There's no pending data to write. The only way this can happen is if + // we're waiting on the output stream's closure, so we can respond to a + // copying failure as quickly as possible (rather than waiting for data to + // be available to read and then fail to be copied). Therefore, we must + // be done now -- don't bother to attempt to write anything and wrap + // things up. + dumpn("!!! output stream closed prematurely, ending copy"); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + + NS_ASSERT(pendingData[0].length > 0, "queued up an empty quantum?"); + + // + // Write out the first pending quantum of data. The possible errors here + // are: + // + // The write might fail because we can't write that much data + // Okay, we've written what we can now, so re-queue what's left and + // finish writing it out later. + // The write failed because the stream was closed + // Discard pending data that we can no longer write, stop reading, and + // signal that copying finished. + // Some other error occurred. + // Same as if the stream were closed, but notify with the status + // NS_ERROR_UNEXPECTED so the observer knows something was wonky. + // + + try + { + var quantum = pendingData[0]; + + // XXX |quantum| isn't guaranteed to be ASCII, so we're relying on + // undefined behavior! We're only using this because writeByteArray + // is unusably broken for asynchronous output streams; see bug 532834 + // for details. + var bytesWritten = output.write(quantum, quantum.length); + if (bytesWritten === quantum.length) + pendingData.shift(); + else + pendingData[0] = quantum.substring(bytesWritten); + + dumpn("*** wrote " + bytesWritten + " bytes of data"); + } + catch (e) + { + if (wouldBlock(e)) + { + NS_ASSERT(pendingData.length > 0, + "stream-blocking exception with no data to write?"); + NS_ASSERT(pendingData[0].length > 0, + "stream-blocking exception with empty quantum?"); + this._waitToWriteData(); + return; + } + + if (streamClosed(e)) + dumpn("!!! output stream prematurely closed, signaling error..."); + else + dumpn("!!! unknown error: " + e + ", quantum=" + quantum); + + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // The day is ours! Quantum written, now let's see if we have more data + // still to write. + try + { + if (pendingData.length > 0) + { + this._waitToWriteData(); + return; + } + } + catch (e) + { + dumpn("!!! unexpected error waiting to write pending data: " + e); + this._doneWritingToSink(Cr.NS_ERROR_UNEXPECTED); + return; + } + + // Okay, we have no more pending data to write -- but might we get more in + // the future? + if (this._source !== null) + { + /* +* If we might, then wait for the output stream to be closed. (We wait +* only for closure because we have no data to write -- and if we waited +* for a specific amount of data, we would get repeatedly notified for no +* reason if over time the output stream permitted more and more data to +* be written to it without blocking.) +*/ + this._waitForSinkClosure(); + } + else + { + /* +* On the other hand, if we can't have more data because the input +* stream's gone away, then it's time to notify of copy completion. +* Victory! +*/ + this._sink = null; + this._cancelOrDispatchCancelCallback(Cr.NS_OK); + } + }, + + + // NSIREQUEST + + /** Returns true if the cancel observer hasn't been notified yet. */ + isPending: function() + { + return !this._completed; + }, + + /** Not implemented, don't use! */ + suspend: notImplemented, + /** Not implemented, don't use! */ + resume: notImplemented, + + /** +* Cancels data reading from input, asynchronously writes out any pending +* data, and causes the observer to be notified with the given error code when +* all writing has finished. +* +* @param status : nsresult +* the status to pass to the observer when data copying has been canceled +*/ + cancel: function(status) + { + dumpn("*** cancel(" + status.toString(16) + ")"); + + if (this._canceled) + { + dumpn("*** suppressing a late cancel"); + return; + } + + this._canceled = true; + this.status = status; + + // We could be in the middle of absolutely anything at this point. Both + // input and output might still be around, we might have pending data to + // write, and in general we know nothing about the state of the world. We + // therefore must assume everything's in progress and take everything to its + // final steady state (or so far as it can go before we need to finish + // writing out remaining data). + + this._doneReadingSource(status); + }, + + + // PRIVATE IMPLEMENTATION + + /** +* Stop reading input if we haven't already done so, passing e as the status +* when closing the stream, and kick off a copy-completion notice if no more +* data remains to be written. +* +* @param e : nsresult +* the status to be used when closing the input stream +*/ + _doneReadingSource: function(e) + { + dumpn("*** _doneReadingSource(0x" + e.toString(16) + ")"); + + this._finishSource(e); + if (this._pendingData.length === 0) + this._sink = null; + else + NS_ASSERT(this._sink !== null, "null output?"); + + // If we've written out all data read up to this point, then it's time to + // signal completion. + if (this._sink === null) + { + NS_ASSERT(this._pendingData.length === 0, "pending data still?"); + this._cancelOrDispatchCancelCallback(e); + } + }, + + /** +* Stop writing output if we haven't already done so, discard any data that +* remained to be sent, close off input if it wasn't already closed, and kick +* off a copy-completion notice. +* +* @param e : nsresult +* the status to be used when closing input if it wasn't already closed +*/ + _doneWritingToSink: function(e) + { + dumpn("*** _doneWritingToSink(0x" + e.toString(16) + ")"); + + this._pendingData.length = 0; + this._sink = null; + this._doneReadingSource(e); + }, + + /** +* Completes processing of this copy: either by canceling the copy if it +* hasn't already been canceled using the provided status, or by dispatching +* the cancel callback event (with the originally provided status, of course) +* if it already has been canceled. +* +* @param status : nsresult +* the status code to use to cancel this, if this hasn't already been +* canceled +*/ + _cancelOrDispatchCancelCallback: function(status) + { + dumpn("*** _cancelOrDispatchCancelCallback(" + status + ")"); + + NS_ASSERT(this._source === null, "should have finished input"); + NS_ASSERT(this._sink === null, "should have finished output"); + NS_ASSERT(this._pendingData.length === 0, "should have no pending data"); + + if (!this._canceled) + { + this.cancel(status); + return; + } + + var self = this; + var event = + { + run: function() + { + dumpn("*** onStopRequest async callback"); + + self._completed = true; + try + { + self._observer.onStopRequest(self, self._context, self.status); + } + catch (e) + { + NS_ASSERT(false, + "how are we throwing an exception here? we control " + + "all the callers! " + e); + } + } + }; + + gThreadManager.currentThread.dispatch(event, Ci.nsIThread.DISPATCH_NORMAL); + }, + + /** +* Kicks off another wait for more data to be available from the input stream. +*/ + _waitToReadData: function() + { + dumpn("*** _waitToReadData"); + this._source.asyncWait(this, 0, Response.SEGMENT_SIZE, + gThreadManager.mainThread); + }, + + /** +* Kicks off another wait until data can be written to the output stream. +*/ + _waitToWriteData: function() + { + dumpn("*** _waitToWriteData"); + + var pendingData = this._pendingData; + NS_ASSERT(pendingData.length > 0, "no pending data to write?"); + NS_ASSERT(pendingData[0].length > 0, "buffered an empty write?"); + + this._sink.asyncWait(this, 0, pendingData[0].length, + gThreadManager.mainThread); + }, + + /** +* Kicks off a wait for the sink to which data is being copied to be closed. +* We wait for stream closure when we don't have any data to be copied, rather +* than waiting to write a specific amount of data. We can't wait to write +* data because the sink might be infinitely writable, and if no data appears +* in the source for a long time we might have to spin quite a bit waiting to +* write, waiting to write again, &c. Waiting on stream closure instead means +* we'll get just one notification if the sink dies. Note that when data +* starts arriving from the sink we'll resume waiting for data to be written, +* dropping this closure-only callback entirely. +*/ + _waitForSinkClosure: function() + { + dumpn("*** _waitForSinkClosure"); + + this._sink.asyncWait(this, Ci.nsIAsyncOutputStream.WAIT_CLOSURE_ONLY, 0, + gThreadManager.mainThread); + }, + + /** +* Closes input with the given status, if it hasn't already been closed; +* otherwise a no-op. +* +* @param status : nsresult +* status code use to close the source stream if necessary +*/ + _finishSource: function(status) + { + dumpn("*** _finishSource(" + status.toString(16) + ")"); + + if (this._source !== null) + { + this._source.closeWithStatus(status); + this._source = null; + } + } +}; + + +/** +* A container for utility functions used with HTTP headers. +*/ +const headerUtils = +{ + /** +* Normalizes fieldName (by converting it to lowercase) and ensures it is a +* valid header field name (although not necessarily one specified in RFC +* 2616). +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not match the field-name production in RFC 2616 +* @returns string +* fieldName converted to lowercase if it is a valid header, for characters +* where case conversion is possible +*/ + normalizeFieldName: function(fieldName) + { + if (fieldName == "") + throw Cr.NS_ERROR_INVALID_ARG; + + for (var i = 0, sz = fieldName.length; i < sz; i++) + { + if (!IS_TOKEN_ARRAY[fieldName.charCodeAt(i)]) + { + dumpn(fieldName + " is not a valid header field name!"); + throw Cr.NS_ERROR_INVALID_ARG; + } + } + + return fieldName.toLowerCase(); + }, + + /** +* Ensures that fieldValue is a valid header field value (although not +* necessarily as specified in RFC 2616 if the corresponding field name is +* part of the HTTP protocol), normalizes the value if it is, and +* returns the normalized value. +* +* @param fieldValue : string +* a value to be normalized as an HTTP header field value +* @throws NS_ERROR_INVALID_ARG +* if fieldValue does not match the field-value production in RFC 2616 +* @returns string +* fieldValue as a normalized HTTP header field value +*/ + normalizeFieldValue: function(fieldValue) + { + // field-value = *( field-content | LWS ) + // field-content = <the OCTETs making up the field-value + // and consisting of either *TEXT or combinations + // of token, separators, and quoted-string> + // TEXT = <any OCTET except CTLs, + // but including LWS> + // LWS = [CRLF] 1*( SP | HT ) + // + // quoted-string = ( <"> *(qdtext | quoted-pair ) <"> ) + // qdtext = <any TEXT except <">> + // quoted-pair = "\" CHAR + // CHAR = <any US-ASCII character (octets 0 - 127)> + + // Any LWS that occurs between field-content MAY be replaced with a single + // SP before interpreting the field value or forwarding the message + // downstream (section 4.2); we replace 1*LWS with a single SP + var val = fieldValue.replace(/(?:(?:\r\n)?[ \t]+)+/g, " "); + + // remove leading/trailing LWS (which has been converted to SP) + val = val.replace(/^ +/, "").replace(/ +$/, ""); + + // that should have taken care of all CTLs, so val should contain no CTLs + for (var i = 0, len = val.length; i < len; i++) + if (isCTL(val.charCodeAt(i))) + throw Cr.NS_ERROR_INVALID_ARG; + + // XXX disallows quoted-pair where CHAR is a CTL -- will not invalidly + // normalize, however, so this can be construed as a tightening of the + // spec and not entirely as a bug + return val; + } +}; + + + +/** +* Converts the given string into a string which is safe for use in an HTML +* context. +* +* @param str : string +* the string to make HTML-safe +* @returns string +* an HTML-safe version of str +*/ +function htmlEscape(str) +{ + // this is naive, but it'll work + var s = ""; + for (var i = 0; i < str.length; i++) + s += "&#" + str.charCodeAt(i) + ";"; + return s; +} + + +/** +* Constructs an object representing an HTTP version (see section 3.1). +* +* @param versionString +* a string of the form "#.#", where # is an non-negative decimal integer with +* or without leading zeros +* @throws +* if versionString does not specify a valid HTTP version number +*/ +function nsHttpVersion(versionString) +{ + var matches = /^(\d+)\.(\d+)$/.exec(versionString); + if (!matches) + throw "Not a valid HTTP version!"; + + /** The major version number of this, as a number. */ + this.major = parseInt(matches[1], 10); + + /** The minor version number of this, as a number. */ + this.minor = parseInt(matches[2], 10); + + if (isNaN(this.major) || isNaN(this.minor) || + this.major < 0 || this.minor < 0) + throw "Not a valid HTTP version!"; +} +nsHttpVersion.prototype = +{ + /** +* Returns the standard string representation of the HTTP version represented +* by this (e.g., "1.1"). +*/ + toString: function () + { + return this.major + "." + this.minor; + }, + + /** +* Returns true if this represents the same HTTP version as otherVersion, +* false otherwise. +* +* @param otherVersion : nsHttpVersion +* the version to compare against this +*/ + equals: function (otherVersion) + { + return this.major == otherVersion.major && + this.minor == otherVersion.minor; + }, + + /** True if this >= otherVersion, false otherwise. */ + atLeast: function(otherVersion) + { + return this.major > otherVersion.major || + (this.major == otherVersion.major && + this.minor >= otherVersion.minor); + } +}; + +nsHttpVersion.HTTP_1_0 = new nsHttpVersion("1.0"); +nsHttpVersion.HTTP_1_1 = new nsHttpVersion("1.1"); + + +/** +* An object which stores HTTP headers for a request or response. +* +* Note that since headers are case-insensitive, this object converts headers to +* lowercase before storing them. This allows the getHeader and hasHeader +* methods to work correctly for any case of a header, but it means that the +* values returned by .enumerator may not be equal case-sensitively to the +* values passed to setHeader when adding headers to this. +*/ +function nsHttpHeaders() +{ + /** +* A hash of headers, with header field names as the keys and header field +* values as the values. Header field names are case-insensitive, but upon +* insertion here they are converted to lowercase. Header field values are +* normalized upon insertion to contain no leading or trailing whitespace. +* +* Note also that per RFC 2616, section 4.2, two headers with the same name in +* a message may be treated as one header with the same field name and a field +* value consisting of the separate field values joined together with a "," in +* their original order. This hash stores multiple headers with the same name +* in this manner. +*/ + this._headers = {}; +} +nsHttpHeaders.prototype = +{ + /** +* Sets the header represented by name and value in this. +* +* @param name : string +* the header name +* @param value : string +* the header value +* @throws NS_ERROR_INVALID_ARG +* if name or value is not a valid header component +*/ + setHeader: function(fieldName, fieldValue, merge) + { + var name = headerUtils.normalizeFieldName(fieldName); + var value = headerUtils.normalizeFieldValue(fieldValue); + + // The following three headers are stored as arrays because their real-world + // syntax prevents joining individual headers into a single header using + // ",". See also <http://hg.mozilla.org/mozilla-central/diff/9b2a99adc05e/netwerk/protocol/http/src/nsHttpHeaderArray.cpp#l77> + if (merge && name in this._headers) + { + if (name === "www-authenticate" || + name === "proxy-authenticate" || + name === "set-cookie") + { + this._headers[name].push(value); + } + else + { + this._headers[name][0] += "," + value; + NS_ASSERT(this._headers[name].length === 1, + "how'd a non-special header have multiple values?") + } + } + else + { + this._headers[name] = [value]; + } + }, + + /** +* Returns the value for the header specified by this. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns string +* the field value for the given header, possibly with non-semantic changes +* (i.e., leading/trailing whitespace stripped, whitespace runs replaced +* with spaces, etc.) at the option of the implementation; multiple +* instances of the header will be combined with a comma, except for +* the three headers noted in the description of getHeaderValues +*/ + getHeader: function(fieldName) + { + return this.getHeaderValues(fieldName).join("\n"); + }, + + /** +* Returns the value for the header specified by fieldName as an array. +* +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @throws NS_ERROR_NOT_AVAILABLE +* if the given header does not exist in this +* @returns [string] +* an array of all the header values in this for the given +* header name. Header values will generally be collapsed +* into a single header by joining all header values together +* with commas, but certain headers (Proxy-Authenticate, +* WWW-Authenticate, and Set-Cookie) violate the HTTP spec +* and cannot be collapsed in this manner. For these headers +* only, the returned array may contain multiple elements if +* that header has been added more than once. +*/ + getHeaderValues: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + + if (name in this._headers) + return this._headers[name]; + else + throw Cr.NS_ERROR_NOT_AVAILABLE; + }, + + /** +* Returns true if a header with the given field name exists in this, false +* otherwise. +* +* @param fieldName : string +* the field name whose existence is to be determined in this +* @throws NS_ERROR_INVALID_ARG +* if fieldName does not constitute a valid header field name +* @returns boolean +* true if the header's present, false otherwise +*/ + hasHeader: function(fieldName) + { + var name = headerUtils.normalizeFieldName(fieldName); + return (name in this._headers); + }, + + /** +* Returns a new enumerator over the field names of the headers in this, as +* nsISupportsStrings. The names returned will be in lowercase, regardless of +* how they were input using setHeader (header names are case-insensitive per +* RFC 2616). +*/ + get enumerator() + { + var headers = []; + for (var i in this._headers) + { + var supports = new SupportsString(); + supports.data = i; + headers.push(supports); + } + + return new nsSimpleEnumerator(headers); + } +}; + + +/** +* Constructs an nsISimpleEnumerator for the given array of items. +* +* @param items : Array +* the items, which must all implement nsISupports +*/ +function nsSimpleEnumerator(items) +{ + this._items = items; + this._nextIndex = 0; +} +nsSimpleEnumerator.prototype = +{ + hasMoreElements: function() + { + return this._nextIndex < this._items.length; + }, + getNext: function() + { + if (!this.hasMoreElements()) + throw Cr.NS_ERROR_NOT_AVAILABLE; + + return this._items[this._nextIndex++]; + }, + QueryInterface: function(aIID) + { + if (Ci.nsISimpleEnumerator.equals(aIID) || + Ci.nsISupports.equals(aIID)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + } +}; + + +/** +* A representation of the data in an HTTP request. +* +* @param port : uint +* the port on which the server receiving this request runs +*/ +function Request(port) +{ + /** Method of this request, e.g. GET or POST. */ + this._method = ""; + + /** Path of the requested resource; empty paths are converted to '/'. */ + this._path = ""; + + /** Query string, if any, associated with this request (not including '?'). */ + this._queryString = ""; + + /** Scheme of requested resource, usually http, always lowercase. */ + this._scheme = "http"; + + /** Hostname on which the requested resource resides. */ + this._host = undefined; + + /** Port number over which the request was received. */ + this._port = port; + + var bodyPipe = new Pipe(false, false, 0, PR_UINT32_MAX, null); + + /** Stream from which data in this request's body may be read. */ + this._bodyInputStream = bodyPipe.inputStream; + + /** Stream to which data in this request's body is written. */ + this._bodyOutputStream = bodyPipe.outputStream; + + /** +* The headers in this request. +*/ + this._headers = new nsHttpHeaders(); + + /** +* For the addition of ad-hoc properties and new functionality without having +* to change nsIHttpRequest every time; currently lazily created, as its only +* use is in directory listings. +*/ + this._bag = null; +} +Request.prototype = +{ + // SERVER METADATA + + // + // see nsIHttpRequest.scheme + // + get scheme() + { + return this._scheme; + }, + + // + // see nsIHttpRequest.host + // + get host() + { + return this._host; + }, + + // + // see nsIHttpRequest.port + // + get port() + { + return this._port; + }, + + // REQUEST LINE + + // + // see nsIHttpRequest.method + // + get method() + { + return this._method; + }, + + // + // see nsIHttpRequest.httpVersion + // + get httpVersion() + { + return this._httpVersion.toString(); + }, + + // + // see nsIHttpRequest.path + // + get path() + { + return this._path; + }, + + // + // see nsIHttpRequest.queryString + // + get queryString() + { + return this._queryString; + }, + + // HEADERS + + // + // see nsIHttpRequest.getHeader + // + getHeader: function(name) + { + return this._headers.getHeader(name); + }, + + // + // see nsIHttpRequest.hasHeader + // + hasHeader: function(name) + { + return this._headers.hasHeader(name); + }, + + // + // see nsIHttpRequest.headers + // + get headers() + { + return this._headers.enumerator; + }, + + // + // see nsIPropertyBag.enumerator + // + get enumerator() + { + this._ensurePropertyBag(); + return this._bag.enumerator; + }, + + // + // see nsIHttpRequest.headers + // + get bodyInputStream() + { + return this._bodyInputStream; + }, + + // + // see nsIPropertyBag.getProperty + // + getProperty: function(name) + { + this._ensurePropertyBag(); + return this._bag.getProperty(name); + }, + + + // NSISUPPORTS + + // + // see nsISupports.QueryInterface + // + QueryInterface: function(iid) + { + if (iid.equals(Ci.nsIHttpRequest) || iid.equals(Ci.nsISupports)) + return this; + + throw Cr.NS_ERROR_NO_INTERFACE; + }, + + + // PRIVATE IMPLEMENTATION + + /** Ensures a property bag has been created for ad-hoc behaviors. */ + _ensurePropertyBag: function() + { + if (!this._bag) + this._bag = new WritablePropertyBag(); + } +}; + + +// XPCOM trappings +if ("XPCOMUtils" in this && // Firefox 3.6 doesn't load XPCOMUtils in this scope for some reason... + "generateNSGetFactory" in XPCOMUtils) { + var NSGetFactory = XPCOMUtils.generateNSGetFactory([nsHttpServer]); +} + + + +/** +* Creates a new HTTP server listening for loopback traffic on the given port, +* starts it, and runs the server until the server processes a shutdown request, +* spinning an event loop so that events posted by the server's socket are +* processed. +* +* This method is primarily intended for use in running this script from within +* xpcshell and running a functional HTTP server without having to deal with +* non-essential details. +* +* Note that running multiple servers using variants of this method probably +* doesn't work, simply due to how the internal event loop is spun and stopped. +* +* @note +* This method only works with Mozilla 1.9 (i.e., Firefox 3 or trunk code); +* you should use this server as a component in Mozilla 1.8. +* @param port +* the port on which the server will run, or -1 if there exists no preference +* for a specific port; note that attempting to use some values for this +* parameter (particularly those below 1024) may cause this method to throw or +* may result in the server being prematurely shut down +* @param basePath +* a local directory from which requests will be served (i.e., if this is +* "/home/jwalden/" then a request to /index.html will load +* /home/jwalden/index.html); if this is omitted, only the default URLs in +* this server implementation will be functional +*/ +function server(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + // if you're running this, you probably want to see debugging info + DEBUG = true; + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", SJS_TYPE); + srv.start(port); + + var thread = gThreadManager.currentThread; + while (!srv.isStopped()) + thread.processNextEvent(true); + + // get rid of any pending requests + while (thread.hasPendingEvents()) + thread.processNextEvent(true); + + DEBUG = false; +} + +function startServerAsync(port, basePath) +{ + if (basePath) + { + var lp = Cc["@mozilla.org/file/local;1"] + .createInstance(Ci.nsILocalFile); + lp.initWithPath(basePath); + } + + var srv = new nsHttpServer(); + if (lp) + srv.registerDirectory("/", lp); + srv.registerContentType("sjs", "sjs"); + srv.start(port); + return srv; +} + +exports.nsHttpServer = nsHttpServer; +exports.ScriptableInputStream = ScriptableInputStream; +exports.server = server; +exports.startServerAsync = startServerAsync; diff --git a/addon-sdk/source/test/addons/places/lib/main.js b/addon-sdk/source/test/addons/places/lib/main.js new file mode 100644 index 000000000..289cba4b5 --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/main.js @@ -0,0 +1,27 @@ +/* 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/. */ + +'use strict'; + +const { safeMerge: merge } = require('sdk/util/object'); +const app = require("sdk/system/xul-app"); + +// Once Bug 903018 is resolved, just move the application testing to +// module.metadata.engines +if (app.is('Firefox')) { + merge(module.exports, + require('./test-places-events'), + require('./test-places-bookmarks'), + require('./test-places-favicon'), + require('./test-places-history'), + require('./test-places-host'), + require('./test-places-utils') + ); +} else { + exports['test unsupported'] = (assert) => { + assert.pass('This application is unsupported.'); + }; +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/places/lib/places-helper.js b/addon-sdk/source/test/addons/places/lib/places-helper.js new file mode 100644 index 000000000..a8545b24a --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/places-helper.js @@ -0,0 +1,239 @@ +/* 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/. */ + 'use strict' + +const { Cc, Ci, Cu } = require('chrome'); +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const hsrv = Cc['@mozilla.org/browser/nav-history-service;1']. + getService(Ci.nsINavHistoryService); +const brsrv = Cc["@mozilla.org/browser/nav-history-service;1"] + .getService(Ci.nsIBrowserHistory); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); +const asyncHistory = Cc['@mozilla.org/browser/history;1']. + getService(Ci.mozIAsyncHistory); +const { send } = require('sdk/addon/events'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { defer, all } = require('sdk/core/promise'); +const { once } = require('sdk/system/events'); +const { set } = require('sdk/preferences/service'); +const { + Bookmark, Group, Separator, + save, search, + MENU, TOOLBAR, UNSORTED +} = require('sdk/places/bookmarks'); + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "PlacesUtils", + "resource://gre/modules/PlacesUtils.jsm"); + +function invalidResolve (assert) { + return function (e) { + assert.fail('Resolve state should not be called: ' + e); + }; +} +exports.invalidResolve = invalidResolve; + +// Removes all children of group +function clearBookmarks (group) { + group + ? bmsrv.removeFolderChildren(group.id) + : clearAllBookmarks(); +} + +function clearAllBookmarks () { + [MENU, TOOLBAR, UNSORTED].forEach(clearBookmarks); +} + +function clearHistory (done) { + PlacesUtils.history.clear().catch(Cu.reportError).then(done); +} + +// Cleans bookmarks and history and disables maintanance +function resetPlaces (done) { + // Set last maintenance to current time to prevent + // Places DB maintenance occuring and locking DB + set('places.database.lastMaintenance', Math.floor(Date.now() / 1000)); + clearAllBookmarks(); + clearHistory(done); +} +exports.resetPlaces = resetPlaces; + +function compareWithHost (assert, item) { + let id = item.id; + let type = item.type === 'group' ? bmsrv.TYPE_FOLDER : bmsrv['TYPE_' + item.type.toUpperCase()]; + let url = item.url && !item.url.endsWith('/') ? item.url + '/' : item.url; + + if (type === bmsrv.TYPE_BOOKMARK) { + assert.equal(url, bmsrv.getBookmarkURI(id).spec.toString(), 'Matches host url'); + let tags = tagsrv.getTagsForURI(newURI(item.url)); + for (let tag of tags) { + // Handle both array for raw data and set for instances + if (Array.isArray(item.tags)) + assert.ok(~item.tags.indexOf(tag), 'has correct tag'); + else + assert.ok(item.tags.has(tag), 'has correct tag'); + } + assert.equal(tags.length, + Array.isArray(item.tags) ? item.tags.length : item.tags.size, + 'matches tag count'); + } + if (type !== bmsrv.TYPE_SEPARATOR) { + assert.equal(item.title, bmsrv.getItemTitle(id), 'Matches host title'); + } + assert.equal(item.index, bmsrv.getItemIndex(id), 'Matches host index'); + assert.equal(item.group.id || item.group, bmsrv.getFolderIdForItem(id), 'Matches host group id'); + assert.equal(type, bmsrv.getItemType(id), 'Matches host type'); +} +exports.compareWithHost = compareWithHost; + +function addVisits (urls) { + var deferred = defer(); + asyncHistory.updatePlaces([].concat(urls).map(createVisit), { + handleResult: function () {}, + handleError: deferred.reject, + handleCompletion: deferred.resolve + }); + + return deferred.promise; +} +exports.addVisits = addVisits; + +function removeVisits (urls) { + [].concat(urls).map(url => { + hsrv.removePage(newURI(url)); + }); +} +exports.removeVisits = removeVisits; + +// Creates a mozIVisitInfo object +function createVisit (url) { + let place = {} + place.uri = newURI(url); + place.title = "Test visit for " + place.uri.spec; + place.visits = [{ + transitionType: hsrv.TRANSITION_LINK, + visitDate: +(new Date()) * 1000, + referredURI: undefined + }]; + return place; +} + +function createBookmark (data) { + data = data || {}; + let item = { + title: data.title || 'Moz', + url: data.url || (!data.type || data.type === 'bookmark' ? + 'http://moz.com/' : + undefined), + tags: data.tags || (!data.type || data.type === 'bookmark' ? + ['firefox'] : + undefined), + type: data.type || 'bookmark', + group: data.group + }; + return send('sdk-places-bookmarks-create', item); +} +exports.createBookmark = createBookmark; + +function historyBatch () { + hsrv.runInBatchMode(() => {}, null); +} +exports.historyBatch = historyBatch; + +function createBookmarkItem (data) { + let deferred = defer(); + data = data || {}; + save({ + title: data.title || 'Moz', + url: data.url || 'http://moz.com/', + tags: data.tags || (!data.type || data.type === 'bookmark' ? + ['firefox'] : + undefined), + type: data.type || 'bookmark', + group: data.group + }).on('end', function (bookmark) { + deferred.resolve(bookmark[0]); + }); + return deferred.promise; +} +exports.createBookmarkItem = createBookmarkItem; + +function createBookmarkTree () { + let agg = []; + return createBookmarkItem({ type: 'group', title: 'mozgroup' }) + .then(group => { + agg.push(group); + return all([createBookmarkItem({ + title: 'mozilla.com', + url: 'http://mozilla.com/', + group: group, + tags: ['mozilla', 'firefox', 'thunderbird', 'rust'] + }), createBookmarkItem({ + title: 'mozilla.org', + url: 'http://mozilla.org/', + group: group, + tags: ['mozilla', 'firefox', 'thunderbird', 'rust'] + }), createBookmarkItem({ + title: 'firefox', + url: 'http://firefox.com/', + group: group, + tags: ['mozilla', 'firefox', 'browser'] + }), createBookmarkItem({ + title: 'thunderbird', + url: 'http://mozilla.org/thunderbird/', + group: group, + tags: ['mozilla', 'thunderbird', 'email'] + }), createBookmarkItem({ + title: 'moz subfolder', + group: group, + type: 'group' + }) + ]); + }) + .then(results => { + agg = agg.concat(results); + let subfolder = results.filter(item => item.type === 'group')[0]; + return createBookmarkItem({ + title: 'dark javascript secrets', + url: 'http://w3schools.com', + group: subfolder, + tags: [] + }); + }).then(item => { + agg.push(item); + return createBookmarkItem( + { type: 'group', group: MENU, title: 'other stuff' } + ); + }).then(newGroup => { + agg.push(newGroup); + return all([ + createBookmarkItem({ + title: 'mdn', + url: 'http://developer.mozilla.org/en-US/', + group: newGroup, + tags: ['javascript'] + }), + createBookmarkItem({ + title: 'web audio', + url: 'http://webaud.io', + group: newGroup, + tags: ['javascript', 'web audio'] + }), + createBookmarkItem({ + title: 'web audio components', + url: 'http://component.fm', + group: newGroup, + tags: ['javascript', 'web audio', 'components'] + }) + ]); + }).then(results => { + agg = agg.concat(results); + return agg; + }); +} +exports.createBookmarkTree = createBookmarkTree; diff --git a/addon-sdk/source/test/addons/places/lib/test-places-bookmarks.js b/addon-sdk/source/test/addons/places/lib/test-places-bookmarks.js new file mode 100644 index 000000000..ff490f6a4 --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-bookmarks.js @@ -0,0 +1,948 @@ +/* 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/. */ +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { request } = require('sdk/addon/host'); +const { filter } = require('sdk/event/utils'); +const { on, off } = require('sdk/event/core'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { defer, all, resolve } = require('sdk/core/promise'); +const { before, after } = require('sdk/test/utils'); + +const { + Bookmark, Group, Separator, + save, search, remove, + MENU, TOOLBAR, UNSORTED +} = require('sdk/places/bookmarks'); +const { + invalidResolve, createTree, + compareWithHost, createBookmark, createBookmarkItem, + createBookmarkTree, addVisits, resetPlaces +} = require('./places-helper'); +const { promisedEmitter } = require('sdk/places/utils'); +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); + +exports.testDefaultFolders = function (assert) { + var ids = [ + bmsrv.bookmarksMenuFolder, + bmsrv.toolbarFolder, + bmsrv.unfiledBookmarksFolder + ]; + + [MENU, TOOLBAR, UNSORTED].forEach(function (g, i) { + assert.ok(g.id === ids[i], ' default group matches id'); + }); +}; + +exports.testValidation = function (assert) { + assert.throws(() => { + Bookmark({ title: 'a title' }); + }, /The `url` property must be a valid URL/, 'throws empty URL error'); + + assert.throws(() => { + Bookmark({ title: 'a title', url: 'not.a.url' }); + }, /The `url` property must be a valid URL/, 'throws invalid URL error'); + + assert.throws(() => { + Bookmark({ url: 'http://foo.com' }); + }, /The `title` property must be defined/, 'throws title error'); + + assert.throws(() => { + Bookmark(); + }, /./, 'throws any error'); + + assert.throws(() => { + Group(); + }, /The `title` property must be defined/, 'throws title error for group'); + + assert.throws(() => { + Bookmark({ url: 'http://foo.com', title: 'my title', tags: 'a tag' }); + }, /The `tags` property must be a Set, or an array/, 'throws error for non set/array tag'); +}; + +exports.testCreateBookmarks = function (assert, done) { + var bm = Bookmark({ + title: 'moz', + url: 'http://mozilla.org', + tags: ['moz1', 'moz2', 'moz3'] + }); + + save(bm).on('data', (bookmark, input) => { + assert.equal(input, bm, 'input is original input item'); + assert.ok(bookmark.id, 'Bookmark has ID'); + assert.equal(bookmark.title, 'moz'); + assert.equal(bookmark.url, 'http://mozilla.org'); + assert.equal(bookmark.group, UNSORTED, 'Unsorted folder is default parent'); + assert.ok(bookmark !== bm, 'bookmark should be a new instance'); + compareWithHost(assert, bookmark); + }).on('end', bookmarks => { + assert.equal(bookmarks.length, 1, 'returned bookmarks in end'); + assert.equal(bookmarks[0].url, 'http://mozilla.org'); + assert.equal(bookmarks[0].tags.has('moz1'), true, 'has first tag'); + assert.equal(bookmarks[0].tags.has('moz2'), true, 'has second tag'); + assert.equal(bookmarks[0].tags.has('moz3'), true, 'has third tag'); + assert.pass('end event is called'); + done(); + }); +}; + +exports.testCreateGroup = function (assert, done) { + save(Group({ title: 'mygroup', group: MENU })).on('data', g => { + assert.ok(g.id, 'Bookmark has ID'); + assert.equal(g.title, 'mygroup', 'matches title'); + assert.equal(g.group, MENU, 'Menu folder matches'); + compareWithHost(assert, g); + }).on('end', results => { + assert.equal(results.length, 1); + assert.pass('end event is called'); + done(); + }); +}; + +exports.testCreateSeparator = function (assert, done) { + save(Separator({ group: MENU })).on('data', function (s) { + assert.ok(s.id, 'Separator has id'); + assert.equal(s.group, MENU, 'Parent group matches'); + compareWithHost(assert, s); + }).on('end', function (results) { + assert.equal(results.length, 1); + assert.pass('end event is called'); + done(); + }); +}; + +exports.testCreateError = function (assert, done) { + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark'}, + { title: 'moz2', url: 'invalidurl', type: 'bookmark'}, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark'} + ]; + + let dataCount = 0, errorCount = 0; + save(bookmarks).on('data', bookmark => { + assert.ok(/moz[1|3]/.test(bookmark.title), 'valid bookmarks complete'); + dataCount++; + }).on('error', (reason, item) => { + assert.ok( + /The `url` property must be a valid URL/.test(reason), + 'Error event called with correct reason'); + assert.equal(item, bookmarks[1], 'returns input that failed in event'); + errorCount++; + }).on('end', items => { + assert.equal(dataCount, 2, 'data event called twice'); + assert.equal(errorCount, 1, 'error event called once'); + assert.equal(items.length, bookmarks.length, 'all items should be in result'); + assert.equal(items[0].toString(), '[object Bookmark]', + 'should be a saved instance'); + assert.equal(items[2].toString(), '[object Bookmark]', + 'should be a saved instance'); + assert.equal(items[1], bookmarks[1], 'should be original, unsaved object'); + + search({ query: 'moz' }).on('end', items => { + assert.equal(items.length, 2, 'only two items were successfully saved'); + bookmarks[1].url = 'http://moz2.com/'; + dataCount = errorCount = 0; + save(bookmarks).on('data', bookmark => { + dataCount++; + }).on('error', reason => errorCount++) + .on('end', items => { + assert.equal(items.length, 3, 'all 3 items saved'); + assert.equal(dataCount, 3, '3 data events called'); + assert.equal(errorCount, 0, 'no error events called'); + search({ query: 'moz' }).on('end', items => { + assert.equal(items.length, 3, 'only 3 items saved'); + items.map(item => + assert.ok(/moz\d\.com/.test(item.url), 'correct item')) + done(); + }); + }); + }); + }); +}; + +exports.testSaveDucktypes = function (assert, done) { + save({ + title: 'moz', + url: 'http://mozilla.org', + type: 'bookmark' + }).on('data', (bookmark) => { + compareWithHost(assert, bookmark); + done(); + }); +}; + +exports.testSaveDucktypesParent = function (assert, done) { + let folder = { title: 'myfolder', type: 'group' }; + let bookmark = { title: 'mozzie', url: 'http://moz.com', group: folder, type: 'bookmark' }; + let sep = { type: 'separator', group: folder }; + save([sep, bookmark]).on('end', (res) => { + compareWithHost(assert, res[0]); + compareWithHost(assert, res[1]); + assert.equal(res[0].group.title, 'myfolder', 'parent is ducktyped group'); + assert.equal(res[1].group.title, 'myfolder', 'parent is ducktyped group'); + done(); + }); +}; + +/* + * Tests the scenario where the original bookmark item is resaved + * and does not have an ID or an updated date, but should still be + * mapped to the item it created previously + */ +exports.testResaveOriginalItemMapping = function (assert, done) { + let bookmark = Bookmark({ title: 'moz', url: 'http://moz.org' }); + save(bookmark).on('data', newBookmark => { + bookmark.title = 'new moz'; + save(bookmark).on('data', newNewBookmark => { + assert.equal(newBookmark.id, newNewBookmark.id, 'should be the same bookmark item'); + assert.equal(bmsrv.getItemTitle(newBookmark.id), 'new moz', 'should have updated title'); + done(); + }); + }); +}; + +exports.testCreateMultipleBookmarks = function (assert, done) { + let data = [ + Bookmark({title: 'bm1', url: 'http://bm1.com'}), + Bookmark({title: 'bm2', url: 'http://bm2.com'}), + Bookmark({title: 'bm3', url: 'http://bm3.com'}), + ]; + save(data).on('data', function (bookmark, input) { + let stored = data.filter(({title}) => title === bookmark.title)[0]; + assert.equal(input, stored, 'input is original input item'); + assert.equal(bookmark.title, stored.title, 'titles match'); + assert.equal(bookmark.url, stored.url, 'urls match'); + compareWithHost(assert, bookmark); + }).on('end', function (bookmarks) { + assert.equal(bookmarks.length, 3, 'all bookmarks returned'); + done(); + }); +}; + +exports.testCreateImplicitParent = function (assert, done) { + let folder = Group({ title: 'my parent' }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: folder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: folder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: folder }) + ]; + save(bookmarks).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, folder.title, 'parent is linked'); + compareWithHost(assert, bookmark); + } else if (bookmark.type === 'group') { + assert.equal(bookmark.group.id, UNSORTED.id, 'parent ID of group is correct'); + compareWithHost(assert, bookmark); + } + }).on('end', function (results) { + assert.equal(results.length, 3, 'results should only hold explicit saves'); + done(); + }); +}; + +exports.testCreateExplicitParent = function (assert, done) { + let folder = Group({ title: 'my parent' }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: folder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: folder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: folder }) + ]; + save(bookmarks.concat(folder)).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, folder.title, 'parent is linked'); + compareWithHost(assert, bookmark); + } else if (bookmark.type === 'group') { + assert.equal(bookmark.group.id, UNSORTED.id, 'parent ID of group is correct'); + compareWithHost(assert, bookmark); + } + }).on('end', function () { + done(); + }); +}; + +exports.testCreateNested = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder }), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder }), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder }) + ]; + let dataEventCount = 0; + save(bookmarks).on('data', function (bookmark) { + if (bookmark.type === 'bookmark') { + assert.equal(bookmark.group.title, midFolder.title, 'parent is linked'); + } else if (bookmark.title === 'top') { + assert.equal(bookmark.group.id, MENU.id, 'parent ID of top group is correct'); + } else { + assert.equal(bookmark.group.title, topFolder.title, 'parent title of middle group is correct'); + } + dataEventCount++; + compareWithHost(assert, bookmark); + }).on('end', () => { + assert.equal(dataEventCount, 5, 'data events for all saves have occurred'); + assert.ok('end event called'); + done(); + }); +}; + +/* + * Was a scenario when implicitly saving a bookmark that was already created, + * it was not being properly fetched and attempted to recreate + */ +exports.testAddingToExistingParent = function (assert, done) { + let group = { type: 'group', title: 'mozgroup' }; + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark', group: group }, + { title: 'moz2', url: 'http://moz2.com', type: 'bookmark', group: group }, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark', group: group } + ], + firstBatch, secondBatch; + + saveP(bookmarks).then(data => { + firstBatch = data; + return saveP([ + { title: 'moz4', url: 'http://moz4.com', type: 'bookmark', group: group }, + { title: 'moz5', url: 'http://moz5.com', type: 'bookmark', group: group } + ]); + }, assert.fail).then(data => { + secondBatch = data; + assert.equal(firstBatch[0].group.id, secondBatch[0].group.id, + 'successfully saved to the same parent'); + }).then(done).catch(assert.fail); +}; + +exports.testUpdateParent = function (assert, done) { + let group = { type: 'group', title: 'mozgroup' }; + saveP(group).then(item => { + item[0].title = 'mozgroup-resave'; + return saveP(item[0]); + }).then(item => { + assert.equal(item[0].title, 'mozgroup-resave', 'group saved successfully'); + }).then(done).catch(assert.fail); +}; + +exports.testUpdateSeparator = function (assert, done) { + let sep = [Separator(), Separator(), Separator()]; + saveP(sep).then(item => { + item[0].index = 2; + return saveP(item[0]); + }).then(item => { + assert.equal(item[0].index, 2, 'updated index of separator'); + }).then(done).catch(assert.fail); +}; + +exports.testPromisedSave = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + let first, second, third; + saveP(bookmarks).then(bms => { + first = bms.filter(b => b.title === 'moz1')[0]; + second = bms.filter(b => b.title === 'moz2')[0]; + third = bms.filter(b => b.title === 'moz3')[0]; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + first.index = 3; + return saveP(first); + }).then(() => { + assert.equal(bmsrv.getItemIndex(first.id), 2, 'properly moved bookmark'); + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + }).then(done).catch(assert.fail); +}; + +exports.testPromisedErrorSave = function*(assert) { + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark'}, + { title: 'moz2', url: 'invalidurl', type: 'bookmark'}, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark'} + ]; + + yield saveP(bookmarks).then(() => { + assert.fail("should not resolve"); + }, reason => { + assert.ok( + /The `url` property must be a valid URL/.test(reason), + 'Error event called with correct reason'); + }); + + bookmarks[1].url = 'http://moz2.com'; + yield saveP(bookmarks); + + let res = yield searchP({ query: 'moz' }); + assert.equal(res.length, 3, 'all 3 should be saved upon retry'); + res.map(item => assert.ok(/moz\d\.com/.test(item.url), 'correct item')); +}; + +exports.testMovingChildren = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + + save(bookmarks).on('end', bms => { + let first = bms.filter(b => b.title === 'moz1')[0]; + let second = bms.filter(b => b.title === 'moz2')[0]; + let third = bms.filter(b => b.title === 'moz3')[0]; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + /* When moving down in the same container we take + * into account the removal of the original item. If you want + * to move from index X to index Y > X you must use + * moveItem(id, folder, Y + 1) + */ + first.index = 3; + save(first).on('end', () => { + assert.equal(bmsrv.getItemIndex(first.id), 2, 'properly moved bookmark'); + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + done(); + }); + }); +}; + +exports.testMovingChildrenNewFolder = function (assert, done) { + let topFolder = Group({ title: 'top', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let newFolder = Group({ title: 'new', group: MENU }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + save(bookmarks).on('end', bms => { + let first = bms.filter(b => b.title === 'moz1')[0]; + let second = bms.filter(b => b.title === 'moz2')[0]; + let third = bms.filter(b => b.title === 'moz3')[0]; + let definedMidFolder = first.group; + let definedNewFolder; + first.group = newFolder; + assert.equal(first.index, 0); + assert.equal(second.index, 1); + assert.equal(third.index, 2); + save(first).on('data', (data) => { + if (data.type === 'group') definedNewFolder = data; + }).on('end', (moved) => { + assert.equal(bmsrv.getItemIndex(second.id), 0, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(third.id), 1, 'other bookmarks adjusted'); + assert.equal(bmsrv.getItemIndex(first.id), 0, 'properly moved bookmark'); + assert.equal(bmsrv.getFolderIdForItem(first.id), definedNewFolder.id, + 'bookmark has new parent'); + assert.equal(bmsrv.getFolderIdForItem(second.id), definedMidFolder.id, + 'sibling bookmarks did not move'); + assert.equal(bmsrv.getFolderIdForItem(third.id), definedMidFolder.id, + 'sibling bookmarks did not move'); + done(); + }); + }); +}; + +exports.testRemoveFunction = function (assert) { + let topFolder = Group({ title: 'new', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + remove([midFolder, topFolder].concat(bookmarks)).map(item => { + assert.equal(item.remove, true, 'remove toggled `remove` property to true'); + }); +}; + +exports.testRemove = function (assert, done) { + let id; + createBookmarkItem().then(data => { + id = data.id; + compareWithHost(assert, data); // ensure bookmark exists + save(remove(data)).on('data', (res) => { + assert.pass('data event should be called'); + assert.ok(!res, 'response should be empty'); + }).on('end', () => { + assert.throws(function () { + bmsrv.getItemTitle(id); + }, 'item should no longer exist'); + done(); + }); + }).catch(assert.fail); +}; + +/* + * Tests recursively removing children when removing a group + */ +exports.testRemoveAllChildren = function (assert, done) { + let topFolder = Group({ title: 'new', group: MENU }); + let midFolder = Group({ title: 'middle', group: topFolder }); + let bookmarks = [ + Bookmark({ title: 'moz1', url: 'http://moz1.com', group: midFolder}), + Bookmark({ title: 'moz2', url: 'http://moz2.com', group: midFolder}), + Bookmark({ title: 'moz3', url: 'http://moz3.com', group: midFolder}) + ]; + + let saved = []; + save(bookmarks).on('data', (data) => saved.push(data)).on('end', () => { + save(remove(topFolder)).on('end', () => { + assert.equal(saved.length, 5, 'all items should have been saved'); + saved.map((item) => { + assert.throws(function () { + bmsrv.getItemTitle(item.id); + }, 'item should no longer exist'); + }); + done(); + }); + }); +}; + +exports.testResolution = function (assert, done) { + let firstSave, secondSave; + createBookmarkItem().then((item) => { + firstSave = item; + assert.ok(item.updated, 'bookmark has updated time'); + item.title = 'my title'; + // Ensure delay so a different save time is set + return resolve(item); + }).then(saveP) + .then(items => { + let item = items[0]; + secondSave = item; + assert.ok(firstSave.updated < secondSave.updated, 'snapshots have different update times'); + firstSave.title = 'updated title'; + return saveP(firstSave, { resolve: (mine, theirs) => { + assert.equal(mine.title, 'updated title', 'correct data for my object'); + assert.equal(theirs.title, 'my title', 'correct data for their object'); + assert.equal(mine.url, theirs.url, 'other data is equal'); + assert.equal(mine.group, theirs.group, 'other data is equal'); + assert.ok(mine !== firstSave, 'instance is not passed in'); + assert.ok(theirs !== secondSave, 'instance is not passed in'); + assert.equal(mine.toString(), '[object Object]', 'serialized objects'); + assert.equal(theirs.toString(), '[object Object]', 'serialized objects'); + mine.title = 'a new title'; + return mine; + }}); + }).then((results) => { + let result = results[0]; + assert.equal(result.title, 'a new title', 'resolve handles results'); + }).then(done).catch(assert.fail); +}; + +/* + * Same as the resolution test, but with the 'unsaved' snapshot + */ +exports.testResolutionMapping = function (assert, done) { + let bookmark = Bookmark({ title: 'moz', url: 'http://bookmarks4life.com/' }); + let saved; + + saveP(bookmark).then(data => { + saved = data[0]; + saved.title = 'updated title'; + // Ensure a delay for different updated times + return resolve(saved); + }). + then(saveP). + then(() => { + bookmark.title = 'conflicting title'; + return saveP(bookmark, { resolve: (mine, theirs) => { + assert.equal(mine.title, 'conflicting title', 'correct data for my object'); + assert.equal(theirs.title, 'updated title', 'correct data for their object'); + assert.equal(mine.url, theirs.url, 'other data is equal'); + assert.equal(mine.group, theirs.group, 'other data is equal'); + assert.ok(mine !== bookmark, 'instance is not passed in'); + assert.ok(theirs !== saved, 'instance is not passed in'); + assert.equal(mine.toString(), '[object Object]', 'serialized objects'); + assert.equal(theirs.toString(), '[object Object]', 'serialized objects'); + mine.title = 'a new title'; + return mine; + }}); + }).then((results) => { + let result = results[0]; + assert.equal(result.title, 'a new title', 'resolve handles results'); + }).then(done).catch(assert.fail); +}; + +exports.testUpdateTags = function (assert, done) { + createBookmarkItem({ tags: ['spidermonkey'] }).then(bookmark => { + bookmark.tags.add('jagermonkey'); + bookmark.tags.add('ionmonkey'); + bookmark.tags.delete('spidermonkey'); + save(bookmark).on('data', saved => { + assert.equal(saved.tags.size, 2, 'should have 2 tags'); + assert.ok(saved.tags.has('jagermonkey'), 'should have added tag'); + assert.ok(saved.tags.has('ionmonkey'), 'should have added tag'); + assert.ok(!saved.tags.has('spidermonkey'), 'should not have removed tag'); + done(); + }); + }).catch(assert.fail); +}; + +/* + * View `createBookmarkTree` in `./places-helper.js` to see + * expected tree construction + */ + +exports.testSearchByGroupSimple = function (assert, done) { + createBookmarkTree().then(() => { + // In initial release of Places API, groups can only be queried + // via a 'simple query', which is one folder set, and no other + // parameters + return searchP({ group: UNSORTED }); + }).then(results => { + let groups = results.filter(({type}) => type === 'group'); + assert.equal(groups.length, 2, 'returns folders'); + assert.equal(results.length, 7, + 'should return all bookmarks and folders under UNSORTED'); + assert.equal(groups[0].toString(), '[object Group]', 'returns instance'); + return searchP({ + group: groups.filter(({title}) => title === 'mozgroup')[0] + }); + }).then(results => { + let groups = results.filter(({type}) => type === 'group'); + assert.equal(groups.length, 1, 'returns one subfolder'); + assert.equal(results.length, 6, + 'returns all children bookmarks/folders'); + assert.ok(results.filter(({url}) => url === 'http://w3schools.com/'), + 'returns nested children'); + }).then(done).catch(assert.fail); +}; + +exports.testSearchByGroupComplex = function (assert, done) { + let mozgroup; + createBookmarkTree().then(results => { + mozgroup = results.filter(({title}) => title === 'mozgroup')[0]; + return searchP({ group: mozgroup, query: 'javascript' }); + }).then(results => { + assert.equal(results.length, 1, 'only one javascript result under mozgroup'); + assert.equal(results[0].url, 'http://w3schools.com/', 'correct result'); + return searchP({ group: mozgroup, url: '*.mozilla.org' }); + }).then(results => { + assert.equal(results.length, 2, 'expected results'); + assert.ok( + !results.filter(({url}) => /developer.mozilla/.test(url)).length, + 'does not find results from other folders'); + }).then(done).catch(assert.fail); +}; + +exports.testSearchEmitters = function (assert, done) { + createBookmarkTree().then(() => { + let count = 0; + search({ tags: ['mozilla', 'firefox'] }).on('data', data => { + assert.ok(/mozilla|firefox/.test(data.title), 'one of the correct items'); + assert.ok(data.tags.has('firefox'), 'has firefox tag'); + assert.ok(data.tags.has('mozilla'), 'has mozilla tag'); + assert.equal(data + '', '[object Bookmark]', 'returns bookmark'); + count++; + }).on('end', data => { + assert.equal(count, 3, 'data event was called for each item'); + assert.equal(data.length, 3, + 'should return two bookmarks that have both mozilla AND firefox'); + assert.equal(data[0].title, 'mozilla.com', 'returns correct bookmark'); + assert.equal(data[1].title, 'mozilla.org', 'returns correct bookmark'); + assert.equal(data[2].title, 'firefox', 'returns correct bookmark'); + assert.equal(data[0] + '', '[object Bookmark]', 'returns bookmarks'); + done(); + }); + }).catch(assert.fail); +}; + +exports.testSearchTags = function (assert, done) { + createBookmarkTree().then(() => { + // AND tags + return searchP({ tags: ['mozilla', 'firefox'] }); + }).then(data => { + assert.equal(data.length, 3, + 'should return two bookmarks that have both mozilla AND firefox'); + assert.equal(data[0].title, 'mozilla.com', 'returns correct bookmark'); + assert.equal(data[1].title, 'mozilla.org', 'returns correct bookmark'); + assert.equal(data[2].title, 'firefox', 'returns correct bookmark'); + assert.equal(data[0] + '', '[object Bookmark]', 'returns bookmarks'); + return searchP([{tags: ['firefox']}, {tags: ['javascript']}]); + }).then(data => { + // OR tags + assert.equal(data.length, 6, + 'should return all bookmarks with firefox OR javascript tag'); + }).then(done).catch(assert.fail); +}; + +/* + * Tests 4 scenarios + * '*.mozilla.com' + * 'mozilla.com' + * 'http://mozilla.com/' + * 'http://mozilla.com/*' + */ +exports.testSearchURLForBookmarks = function*(assert) { + yield createBookmarkTree() + let data = yield searchP({ url: 'mozilla.org' }); + + assert.equal(data.length, 2, 'only URLs with host domain'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + + data = yield searchP({ url: '*.mozilla.org' }); + + assert.equal(data.length, 3, 'returns domain and when host is other than domain'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + assert.equal(data[2].url, 'http://developer.mozilla.org/en-US/'); + + data = yield searchP({ url: 'http://mozilla.org' }); + + assert.equal(data.length, 1, 'only exact URL match'); + assert.equal(data[0].url, 'http://mozilla.org/'); + + data = yield searchP({ url: 'http://mozilla.org/*' }); + + assert.equal(data.length, 2, 'only URLs that begin with query'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + + data = yield searchP([{ url: 'mozilla.org' }, { url: 'component.fm' }]); + + assert.equal(data.length, 3, 'returns URLs that match EITHER query'); + assert.equal(data[0].url, 'http://mozilla.org/'); + assert.equal(data[1].url, 'http://mozilla.org/thunderbird/'); + assert.equal(data[2].url, 'http://component.fm/'); +}; + +/* + * Searches url, title, tags + */ +exports.testSearchQueryForBookmarks = function*(assert) { + yield createBookmarkTree(); + + let data = yield searchP({ query: 'thunder' }); + assert.equal(data.length, 3); + assert.equal(data[0].title, 'mozilla.com', 'query matches tag, url, or title'); + assert.equal(data[1].title, 'mozilla.org', 'query matches tag, url, or title'); + assert.equal(data[2].title, 'thunderbird', 'query matches tag, url, or title'); + + data = yield searchP([{ query: 'rust' }, { query: 'component' }]); + // rust OR component + assert.equal(data.length, 3); + assert.equal(data[0].title, 'mozilla.com', 'query matches tag, url, or title'); + assert.equal(data[1].title, 'mozilla.org', 'query matches tag, url, or title'); + assert.equal(data[2].title, 'web audio components', 'query matches tag, url, or title'); + + data = yield searchP([{ query: 'moz', tags: ['javascript']}]); + assert.equal(data.length, 1); + assert.equal(data[0].title, 'mdn', + 'only one item matches moz query AND has a javascript tag'); +}; + +/* + * Test caching on bulk calls. + * Each construction of a bookmark item snapshot results in + * the recursive lookup of parent groups up to the root groups -- + * ensure that the appropriate instances equal each other, and no duplicate + * fetches are called + * + * Implementation-dependent, this checks the host event `sdk-places-bookmarks-get`, + * and if implementation changes, this could increase or decrease + */ + +exports.testCaching = function (assert, done) { + let count = 0; + let stream = filter(request, ({event}) => + /sdk-places-bookmarks-get/.test(event)); + on(stream, 'data', handle); + + let group = { type: 'group', title: 'mozgroup' }; + let bookmarks = [ + { title: 'moz1', url: 'http://moz1.com', type: 'bookmark', group: group }, + { title: 'moz2', url: 'http://moz2.com', type: 'bookmark', group: group }, + { title: 'moz3', url: 'http://moz3.com', type: 'bookmark', group: group } + ]; + + /* + * Use timeout in tests since the platform calls are synchronous + * and the counting event shim may not have occurred yet + */ + + saveP(bookmarks).then(() => { + assert.equal(count, 0, 'all new items and root group, no fetches should occur'); + count = 0; + return saveP([ + { title: 'moz4', url: 'http://moz4.com', type: 'bookmark', group: group }, + { title: 'moz5', url: 'http://moz5.com', type: 'bookmark', group: group } + ]); + // Test `save` look-up + }).then(() => { + assert.equal(count, 1, 'should only look up parent once'); + count = 0; + return searchP({ query: 'moz' }); + }).then(results => { + // Should query for each bookmark (5) from the query (id -> data), + // their parent during `construct` (1) and the root shouldn't + // require a lookup + assert.equal(count, 6, 'lookup occurs once for each item and parent'); + off(stream, 'data', handle); + }).then(done).catch(assert.fail); + + function handle ({data}) { + return count++; + } +}; + +/* + * Search Query Options + */ + +exports.testSearchCount = function (assert, done) { + let max = 8; + createBookmarkTree() + .then(testCount(1)) + .then(testCount(2)) + .then(testCount(3)) + .then(testCount(5)) + .then(testCount(10)) + .then(done) + .catch(assert.fail); + + function testCount (n) { + return function () { + return searchP({}, { count: n }).then(results => { + if (n > max) n = max; + assert.equal(results.length, n, + 'count ' + n + ' returns ' + n + ' results'); + }); + }; + } +}; + +exports.testSearchSortForBookmarks = function (assert, done) { + let urls = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + + saveP( + urls.map(url => + Bookmark({ url: url, title: url.replace(/http:\/\/|\//g,'')})) + ).then(() => { + return searchP({}, { sort: 'title' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'title', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return searchP({}, { sort: 'url' }); + }).then(results => { + checkOrder(results, [4,3,0,2,1]); + return searchP({}, { sort: 'url', descending: true }); + }).then(results => { + checkOrder(results, [1,2,0,3,4]); + return addVisits(['http://mozilla.com/', 'http://mozilla.com']); + }).then(() => + saveP(Bookmark({ url: 'http://github.com', title: 'github.com' })) + ).then(() => addVisits('http://bandcamp.com/')) + .then(() => searchP({ query: 'webfwd' })) + .then(results => { + results[0].title = 'new title for webfwd'; + return saveP(results[0]); + }) + .then(() => + searchP({}, { sort: 'visitCount' }) + ).then(results => { + assert.equal(results[5].url, 'http://mozilla.com/', + 'last entry is the highest visit count'); + return searchP({}, { sort: 'visitCount', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://mozilla.com/', + 'first entry is the highest visit count'); + return searchP({}, { sort: 'date' }); + }).then(results => { + assert.equal(results[5].url, 'http://bandcamp.com/', + 'latest visited should be first'); + return searchP({}, { sort: 'date', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://bandcamp.com/', + 'latest visited should be at the end'); + return searchP({}, { sort: 'dateAdded' }); + }).then(results => { + assert.equal(results[5].url, 'http://github.com/', + 'last added should be at the end'); + return searchP({}, { sort: 'dateAdded', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://github.com/', + 'last added should be first'); + return searchP({}, { sort: 'lastModified' }); + }).then(results => { + assert.equal(results[5].url, 'http://mozilla.com/webfwd/', + 'last modified should be last'); + return searchP({}, { sort: 'lastModified', descending: true }); + }).then(results => { + assert.equal(results[0].url, 'http://mozilla.com/webfwd/', + 'last modified should be first'); + }).then(done).catch(assert.fail); + + function checkOrder (results, nums) { + assert.equal(results.length, nums.length, 'expected return count'); + for (let i = 0; i < nums.length; i++) { + assert.equal(results[i].url, urls[nums[i]], 'successful order'); + } + } +}; + +exports.testSearchComplexQueryWithOptions = function (assert, done) { + createBookmarkTree().then(() => { + return searchP([ + { tags: ['rust'], url: '*.mozilla.org' }, + { tags: ['javascript'], query: 'mozilla' } + ], { sort: 'title' }); + }).then(results => { + let expected = [ + 'http://developer.mozilla.org/en-US/', + 'http://mozilla.org/' + ]; + for (let i = 0; i < expected.length; i++) + assert.equal(results[i].url, expected[i], 'correct ordering and item'); + }).then(done).catch(assert.fail); +}; + +exports.testCheckSaveOrder = function (assert, done) { + let group = Group({ title: 'mygroup' }); + let bookmarks = [ + Bookmark({ url: 'http://url1.com', title: 'url1', group: group }), + Bookmark({ url: 'http://url2.com', title: 'url2', group: group }), + Bookmark({ url: 'http://url3.com', title: 'url3', group: group }), + Bookmark({ url: 'http://url4.com', title: 'url4', group: group }), + Bookmark({ url: 'http://url5.com', title: 'url5', group: group }) + ]; + saveP(bookmarks).then(results => { + for (let i = 0; i < bookmarks.length; i++) + assert.equal(results[i].url, bookmarks[i].url, + 'correct ordering of bookmark results'); + }).then(done).catch(assert.fail); +}; + +before(exports, (name, assert, done) => resetPlaces(done)); +after(exports, (name, assert, done) => resetPlaces(done)); + +function saveP () { + return promisedEmitter(save.apply(null, Array.prototype.slice.call(arguments))); +} + +function searchP () { + return promisedEmitter(search.apply(null, Array.prototype.slice.call(arguments))); +} diff --git a/addon-sdk/source/test/addons/places/lib/test-places-events.js b/addon-sdk/source/test/addons/places/lib/test-places-events.js new file mode 100644 index 000000000..3033f78d4 --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-events.js @@ -0,0 +1,328 @@ +/* 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/. */ +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { defer, all } = require('sdk/core/promise'); +const { filter } = require('sdk/event/utils'); +const { on, off } = require('sdk/event/core'); +const { events } = require('sdk/places/events'); +const { setTimeout } = require('sdk/timers'); +const { before, after } = require('sdk/test/utils'); +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const { release, platform } = require('node/os'); + +const isOSX10_6 = (() => { + let vString = release(); + return vString && /darwin/.test(platform()) && /10\.6/.test(vString); +})(); + +const { search } = require('sdk/places/history'); +const { + invalidResolve, createTree, createBookmark, + compareWithHost, addVisits, resetPlaces, createBookmarkItem, + removeVisits, historyBatch +} = require('./places-helper'); +const { save, MENU, UNSORTED } = require('sdk/places/bookmarks'); +const { promisedEmitter } = require('sdk/places/utils'); + +exports['test bookmark-item-added'] = function (assert, done) { + events.on('data', function handler ({type, data}) { + if (type !== 'bookmark-item-added') return; + if (data.title !== 'bookmark-added-title') return; + events.off('data', handler); + + assert.equal(type, 'bookmark-item-added', 'correct type in bookmark-added event'); + assert.equal(data.type, 'bookmark', 'correct data.type in bookmark-added event'); + assert.ok(data.id != null, 'correct data.id in bookmark-added event'); + assert.notEqual(data.parentId, null, 'correct data.parentId in bookmark-added event'); + assert.ok(data.index >= 0, 'correct data.index in bookmark-added event'); + assert.equal(data.url, 'http://moz.com/', 'correct data.url in bookmark-added event'); + assert.notEqual(data.dateAdded, null, 'correct data.dateAdded in bookmark-added event'); + done(); + }); + createBookmark({ title: 'bookmark-added-title' }); +}; + +exports['test bookmark-item-changed'] = function (assert, done) { + let id; + let complete = makeCompleted(done); + + // Due to bug 969616 and bug 971964, disabling tests in 10.6 (happens only + // in debug builds) to prevent intermittent failures + if (isOSX10_6) { + assert.pass('skipping test in OSX 10.6'); + return done(); + } + + function handler ({type, data}) { + if (type !== 'bookmark-item-changed') return; + if (data.id !== id) return; + // Abort if the 'bookmark-item-changed' event isn't for the `title` property, + // as sometimes the event can be for the `url` property. + // Intermittent failure, bug 969616 + if (data.property !== 'title') return; + + assert.equal(type, 'bookmark-item-changed', + 'correct type in bookmark-item-changed event'); + assert.equal(data.type, 'bookmark', + 'correct data in bookmark-item-changed event'); + assert.equal(data.property, 'title', + 'correct property in bookmark-item-changed event'); + assert.equal(data.value, 'bookmark-changed-title-2', + 'correct value in bookmark-item-changed event'); + assert.ok(data.id === id, 'correct id in bookmark-item-changed event'); + assert.ok(data.parentId != null, 'correct data in bookmark-added event'); + + events.off('data', handler); + complete(); + } + events.on('data', handler); + + createBookmarkItem({ title: 'bookmark-changed-title' }).then(item => { + id = item.id; + item.title = 'bookmark-changed-title-2'; + return saveP(item); + }).then(complete).catch(assert.fail); +}; + +exports['test bookmark-item-moved'] = function (assert, done) { + let id; + let complete = makeCompleted(done); + let previousIndex, previousParentId; + + // Due to bug 969616 and bug 971964, disabling tests in 10.6 (happens only + // in debug builds) to prevent intermittent failures + if (isOSX10_6) { + assert.ok(true, 'skipping test in OSX 10.6'); + return done(); + } + + function handler ({type, data}) { + if (type !== 'bookmark-item-moved') return; + if (data.id !== id) return; + assert.equal(type, 'bookmark-item-moved', + 'correct type in bookmark-item-moved event'); + assert.equal(data.type, 'bookmark', + 'correct data in bookmark-item-moved event'); + assert.ok(data.id === id, 'correct id in bookmark-item-moved event'); + assert.equal(data.previousParentId, previousParentId, + 'correct previousParentId'); + assert.equal(data.currentParentId, bmsrv.getFolderIdForItem(id), + 'correct currentParentId'); + assert.equal(data.previousIndex, previousIndex, 'correct previousIndex'); + assert.equal(data.currentIndex, bmsrv.getItemIndex(id), 'correct currentIndex'); + + events.off('data', handler); + complete(); + } + events.on('data', handler); + + createBookmarkItem({ + title: 'bookmark-moved-title', + group: UNSORTED + }).then(item => { + id = item.id; + previousIndex = bmsrv.getItemIndex(id); + previousParentId = bmsrv.getFolderIdForItem(id); + item.group = MENU; + return saveP(item); + }).then(complete).catch(assert.fail); +}; + +exports['test bookmark-item-removed'] = function (assert, done) { + let id; + let complete = makeCompleted(done); + function handler ({type, data}) { + if (type !== 'bookmark-item-removed') return; + if (data.id !== id) return; + assert.equal(type, 'bookmark-item-removed', + 'correct type in bookmark-item-removed event'); + assert.equal(data.type, 'bookmark', + 'correct data in bookmark-item-removed event'); + assert.ok(data.id === id, 'correct id in bookmark-item-removed event'); + assert.equal(data.parentId, UNSORTED.id, + 'correct parentId in bookmark-item-removed'); + assert.equal(data.url, 'http://moz.com/', + 'correct url in bookmark-item-removed event'); + assert.equal(data.index, 0, + 'correct index in bookmark-item-removed event'); + + events.off('data', handler); + complete(); + } + events.on('data', handler); + + createBookmarkItem({ + title: 'bookmark-item-remove-title', + group: UNSORTED + }).then(item => { + id = item.id; + item.remove = true; + return saveP(item); + }).then(complete).catch(assert.fail); +}; + +exports['test bookmark-item-visited'] = function (assert, done) { + let id; + let complete = makeCompleted(done); + function handler ({type, data}) { + if (type !== 'bookmark-item-visited') return; + if (data.id !== id) return; + assert.equal(type, 'bookmark-item-visited', + 'correct type in bookmark-item-visited event'); + assert.ok(data.id === id, 'correct id in bookmark-item-visited event'); + assert.equal(data.parentId, UNSORTED.id, + 'correct parentId in bookmark-item-visited'); + assert.ok(data.transitionType != null, + 'has a transition type in bookmark-item-visited event'); + assert.ok(data.time != null, + 'has a time in bookmark-item-visited event'); + assert.ok(data.visitId != null, + 'has a visitId in bookmark-item-visited event'); + assert.equal(data.url, 'http://bookmark-item-visited.com/', + 'correct url in bookmark-item-visited event'); + + events.off('data', handler); + complete(); + } + events.on('data', handler); + + createBookmarkItem({ + title: 'bookmark-item-visited', + url: 'http://bookmark-item-visited.com/' + }).then(item => { + id = item.id; + return addVisits('http://bookmark-item-visited.com/'); + }).then(complete).catch(assert.fail); +}; + +exports['test history-start-batch, history-end-batch, history-start-clear'] = function (assert, done) { + let complete = makeCompleted(done, 4); + let startEvent = filter(events, ({type}) => type === 'history-start-batch'); + let endEvent = filter(events, ({type}) => type === 'history-end-batch'); + let clearEvent = filter(events, ({type}) => type === 'history-start-clear'); + function startHandler ({type, data}) { + assert.pass('history-start-batch called'); + assert.equal(type, 'history-start-batch', + 'history-start-batch has correct type'); + off(startEvent, 'data', startHandler); + on(endEvent, 'data', endHandler); + complete(); + } + function endHandler ({type, data}) { + assert.pass('history-end-batch called'); + assert.equal(type, 'history-end-batch', + 'history-end-batch has correct type'); + off(endEvent, 'data', endHandler); + complete(); + } + function clearHandler ({type, data}) { + assert.pass('history-start-clear called'); + assert.equal(type, 'history-start-clear', + 'history-start-clear has correct type'); + off(clearEvent, 'data', clearHandler); + complete(); + } + + on(startEvent, 'data', startHandler); + on(clearEvent, 'data', clearHandler); + + historyBatch(); + resetPlaces(complete); +}; + +exports['test history-visit, history-title-changed'] = function (assert, done) { + let complete = makeCompleted(() => { + off(titleEvents, 'data', titleHandler); + off(visitEvents, 'data', visitHandler); + done(); + }, 6); + let visitEvents = filter(events, ({type}) => type === 'history-visit'); + let titleEvents = filter(events, ({type}) => type === 'history-title-changed'); + + let urls = ['http://moz.com/', 'http://firefox.com/', 'http://mdn.com/']; + + function visitHandler ({type, data}) { + assert.equal(type, 'history-visit', 'correct type in history-visit'); + assert.ok(~urls.indexOf(data.url), 'history-visit has correct url'); + assert.ok(data.visitId != null, 'history-visit has a visitId'); + assert.ok(data.time != null, 'history-visit has a time'); + assert.ok(data.sessionId != null, 'history-visit has a sessionId'); + assert.ok(data.referringId != null, 'history-visit has a referringId'); + assert.ok(data.transitionType != null, 'history-visit has a transitionType'); + complete(); + } + + function titleHandler ({type, data}) { + assert.equal(type, 'history-title-changed', + 'correct type in history-title-changed'); + assert.ok(~urls.indexOf(data.url), + 'history-title-changed has correct url'); + assert.ok(data.title, 'history-title-changed has title'); + complete(); + } + + on(titleEvents, 'data', titleHandler); + on(visitEvents, 'data', visitHandler); + addVisits(urls); +} + +exports['test history-delete-url'] = function (assert, done) { + let complete = makeCompleted(() => { + events.off('data', handler); + done(); + }, 3); + let urls = ['http://moz.com/', 'http://firefox.com/', 'http://mdn.com/']; + function handler({type, data}) { + if (type !== 'history-delete-url') return; + assert.equal(type, 'history-delete-url', + 'history-delete-url has correct type'); + assert.ok(~urls.indexOf(data.url), 'history-delete-url has correct url'); + complete(); + } + + events.on('data', handler); + addVisits(urls).then(() => { + removeVisits(urls); + }); +}; + +exports['test history-page-changed'] = function (assert) { + assert.pass('history-page-changed tested in test-places-favicons'); +}; + +exports['test history-delete-visits'] = function (assert) { + assert.pass('TODO test history-delete-visits'); +}; + +// Bug 1060843 +// Wait a tick before finishing tests, as some bookmark activities require +// completion of a result for events. For example, when creating a bookmark, +// a `bookmark-item-added` event is fired, listened to by the first test here, +// while constructing the bookmark item requires subsequent calls to that bookmark item. +// If we destroy the underlying bookmark immediately, these calls will fail. +// +// The places SDK abstraction around this alleviates it, but these are low level events. +after(exports, (name, assert, done) => setTimeout(() => resetPlaces(done), 1)); +before(exports, (name, assert, done) => resetPlaces(done)); + +function saveP () { + return promisedEmitter(save.apply(null, Array.prototype.slice.call(arguments))); +} + +function makeCompleted (done, countTo) { + let count = 0; + countTo = countTo || 2; + return function () { + if (++count === countTo) done(); + }; +} diff --git a/addon-sdk/source/test/addons/places/lib/test-places-favicon.js b/addon-sdk/source/test/addons/places/lib/test-places-favicon.js new file mode 100644 index 000000000..669c66e64 --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-favicon.js @@ -0,0 +1,242 @@ +/* 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/. */ + +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { Cc, Ci, Cu } = require('chrome'); +const { getFavicon } = require('sdk/places/favicon'); +const tabs = require('sdk/tabs'); +const open = tabs.open; +const port = 8099; +const host = 'http://localhost:' + port; +const { onFaviconChange, serve, binFavicon } = require('./favicon-helpers'); +const { once } = require('sdk/system/events'); +const { resetPlaces } = require('./places-helper'); +const faviconService = Cc["@mozilla.org/browser/favicon-service;1"]. + getService(Ci.nsIFaviconService); + +exports.testStringGetFaviconCallbackSuccess = function*(assert) { + let name = 'callbacksuccess' + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let favicon = host + '/' + name + '.ico'; + let tab; + + let wait = new Promise(resolve => { + onFaviconChange(url).then((faviconUrl) => { + getFavicon(url, (url) => { + assert.equal(favicon, url, 'Callback returns correct favicon url'); + resolve(); + }); + }); + }); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield wait; + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + +exports.testStringGetFaviconCallbackFailure = function*(assert) { + let name = 'callbackfailure'; + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let tab; + + let wait = waitAndExpire(url); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield wait; + + assert.pass("Getting favicon"); + + yield new Promise(resolve => { + getFavicon(url, (url) => { + assert.equal(url, null, 'Callback returns null'); + resolve(); + }); + }); + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + +exports.testStringGetFaviconPromiseSuccess = function*(assert) { + let name = 'promisesuccess' + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let favicon = host + '/' + name + '.ico'; + let tab; + + let wait = onFaviconChange(url); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield wait; + + assert.pass("Getting favicon"); + + yield getFavicon(url).then((url) => { + assert.equal(url, favicon, 'Callback returns null'); + }, () => { + assert.fail('Reject should not be called'); + }); + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + +exports.testStringGetFaviconPromiseFailure = function*(assert) { + let name = 'promisefailure' + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let tab; + + let wait = waitAndExpire(url); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield wait; + + assert.pass("Getting favicon"); + + yield getFavicon(url).then(invalidResolve(assert), validReject(assert, 'expired url')); + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + +exports.testTabsGetFaviconPromiseSuccess = function*(assert) { + let name = 'tabs-success' + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let favicon = host + '/' + name + '.ico'; + let tab; + + let iconPromise = onFaviconChange(url); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield iconPromise; + + assert.pass("Getting favicon"); + + yield getFavicon(tab).then((url) => { + assert.equal(url, favicon, "getFavicon should return url for tab"); + }); + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + + +exports.testTabsGetFaviconPromiseFailure = function*(assert) { + let name = 'tabs-failure' + let srv = yield makeServer(name); + let url = host + '/' + name + '.html'; + let tab; + + let wait = waitAndExpire(url); + + assert.pass("Opening tab"); + + open({ + url: url, + onOpen: (newTab) => tab = newTab, + inBackground: true + }); + + yield wait; + + assert.pass("Getting favicon"); + + yield getFavicon(tab).then(invalidResolve(assert), validReject(assert, 'expired tab')); + + assert.pass("Complete"); + + yield complete(tab, srv); +}; + +exports.testRejects = function*(assert) { + yield getFavicon({}) + .then(invalidResolve(assert), validReject(assert, 'Object')); + + yield getFavicon(null) + .then(invalidResolve(assert), validReject(assert, 'null')); + + yield getFavicon(undefined) + .then(invalidResolve(assert), validReject(assert, 'undefined')); + + yield getFavicon([]) + .then(invalidResolve(assert), validReject(assert, 'Array')); +}; + +var invalidResolve = (assert) => () => assert.fail('Promise should not be resolved successfully'); +var validReject = (assert, name) => () => assert.pass(name + ' correctly rejected'); + +var makeServer = (name) => serve({ + name: name, + favicon: binFavicon, + port: port, + host: host +}); + +var waitAndExpire = (url) => new Promise(resolve => { + onFaviconChange(url).then(() => { + once('places-favicons-expired', resolve); + faviconService.expireAllFavicons(); + }); +}); + +var complete = (tab, srv) => new Promise(resolve => { + tab.close(() => { + resetPlaces(() => { + srv.stop(resolve); + }); + }); +}); diff --git a/addon-sdk/source/test/addons/places/lib/test-places-history.js b/addon-sdk/source/test/addons/places/lib/test-places-history.js new file mode 100644 index 000000000..0a1e2b8cc --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-history.js @@ -0,0 +1,244 @@ +/* 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/. */ +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { defer, all } = require('sdk/core/promise'); +const { has } = require('sdk/util/array'); +const { setTimeout } = require('sdk/timers'); +const { before, after } = require('sdk/test/utils'); +const { set } = require('sdk/preferences/service'); +const { + search +} = require('sdk/places/history'); +const { + invalidResolve, createTree, + compareWithHost, addVisits, resetPlaces +} = require('./places-helper'); +const { promisedEmitter } = require('sdk/places/utils'); + +exports.testEmptyQuery = function*(assert) { + let within = toBeWithin(); + yield addVisits([ + 'http://simplequery-1.com', 'http://simplequery-2.com' + ]); + + let results = yield searchP(); + assert.equal(results.length, 2, 'Correct number of entries returned'); + assert.equal(results[0].url, 'http://simplequery-1.com/', + 'matches url'); + assert.equal(results[1].url, 'http://simplequery-2.com/', + 'matches url'); + assert.equal(results[0].title, 'Test visit for ' + results[0].url, + 'title matches'); + assert.equal(results[1].title, 'Test visit for ' + results[1].url, + 'title matches'); + assert.equal(results[0].visitCount, 1, 'matches access'); + assert.equal(results[1].visitCount, 1, 'matches access'); + assert.ok(within(results[0].time), 'accurate access time'); + assert.ok(within(results[1].time), 'accurate access time'); + assert.equal(Object.keys(results[0]).length, 4, + 'no addition exposed properties on history result'); +}; + +exports.testVisitCount = function*(assert) { + yield addVisits([ + 'http://simplequery-1.com', 'http://simplequery-1.com', + 'http://simplequery-1.com', 'http://simplequery-1.com' + ]); + let results = yield searchP(); + assert.equal(results.length, 1, 'Correct number of entries returned'); + assert.equal(results[0].url, 'http://simplequery-1.com/', 'correct url'); + assert.equal(results[0].visitCount, 4, 'matches access count'); +}; + +/* + * Tests 4 scenarios + * '*.mozilla.org' + * 'mozilla.org' + * 'http://mozilla.org/' + * 'http://mozilla.org/*' + */ +exports.testSearchURLForHistory = function*(assert) { + yield addVisits([ + 'http://developer.mozilla.org', 'http://mozilla.org', + 'http://mozilla.org/index', 'https://mozilla.org' + ]); + + let results = yield searchP({ url: 'http://mozilla.org/' }); + assert.equal(results.length, 1, 'should just be an exact match'); + + results = yield searchP({ url: '*.mozilla.org' }); + assert.equal(results.length, 4, 'returns all entries'); + + results = yield searchP({ url: 'mozilla.org' }); + assert.equal(results.length, 3, 'returns entries where mozilla.org is host'); + + results = yield searchP({ url: 'http://mozilla.org/*' }); + assert.equal(results.length, 2, 'should match anything starting with substring'); +}; + +// Disabling due to intermittent Bug 892619 +// TODO solve this +/* +exports.testSearchTimeRange = function (assert, done) { + let firstTime, secondTime; + addVisits([ + 'http://earlyvisit.org', 'http://earlyvisit.org/earlytown.html' + ]).then(searchP).then(results => { + firstTime = results[0].time; + var deferred = defer(); + setTimeout(() => deferred.resolve(), 1000); + return deferred.promise; + }).then(() => { + return addVisits(['http://newvisit.org', 'http://newvisit.org/whoawhoa.html']); + }).then(searchP).then(results => { + results.filter(({url, time}) => { + if (/newvisit/.test(url)) secondTime = time; + }); + return searchP({ from: firstTime - 1000 }); + }).then(results => { + assert.equal(results.length, 4, 'should return all entries'); + return searchP({ to: firstTime + 500 }); + }).then(results => { + assert.equal(results.length, 2, 'should return only first entries'); + results.map(item => { + assert.ok(/earlyvisit/.test(item.url), 'correct entry'); + }); + return searchP({ from: firstTime + 500 }); + }).then(results => { + assert.equal(results.length, 2, 'should return only last entries'); + results.map(item => { + assert.ok(/newvisit/.test(item.url), 'correct entry'); + }); + done(); + }); +}; +*/ +exports.testSearchQueryForHistory = function*(assert) { + yield addVisits([ + 'http://mozilla.com', 'http://webaud.io', 'http://mozilla.com/webfwd' + ]); + + let results = yield searchP({ query: 'moz' }); + assert.equal(results.length, 2, 'should return urls that match substring'); + results.map(({url}) => { + assert.ok(/moz/.test(url), 'correct item'); + }); + + results = yield searchP([{ query: 'webfwd' }, { query: 'aud.io' }]); + assert.equal(results.length, 2, 'should OR separate queries'); + results.map(({url}) => { + assert.ok(/webfwd|aud\.io/.test(url), 'correct item'); + }); +}; + +/* + * Query Options + */ + +exports.testSearchCount = function (assert, done) { + addVisits([ + 'http://mozilla.com', 'http://webaud.io', 'http://mozilla.com/webfwd', + 'http://developer.mozilla.com', 'http://bandcamp.com' + ]).then(testCount(1)) + .then(testCount(2)) + .then(testCount(3)) + .then(testCount(5)) + .then(done); + + function testCount (n) { + return function () { + return searchP({}, { count: n }).then(results => { + assert.equal(results.length, n, + 'count ' + n + ' returns ' + n + ' results'); + }); + }; + } +}; + +exports.testSearchSortForHistory = function*(assert) { + function checkOrder (results, nums) { + assert.equal(results.length, nums.length, 'expected return count'); + for (let i = 0; i < nums.length; i++) { + assert.equal(results[i].url, places[nums[i]], 'successful order'); + } + } + + let places = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + yield addVisits(places); + + let results = yield searchP({}, { sort: 'title' }); + checkOrder(results, [4,3,0,2,1]); + + results = yield searchP({}, { sort: 'title', descending: true }); + checkOrder(results, [1,2,0,3,4]); + + results = yield searchP({}, { sort: 'url' }); + checkOrder(results, [4,3,0,2,1]); + + results = yield searchP({}, { sort: 'url', descending: true }); + checkOrder(results, [1,2,0,3,4]); + + yield addVisits('http://mozilla.com'); // for visit conut + yield addVisits('http://github.com'); // for checking date + + results = yield searchP({}, { sort: 'visitCount' }); + assert.equal(results[5].url, 'http://mozilla.com/', + 'last entry is the highest visit count'); + + results = yield searchP({}, { sort: 'visitCount', descending: true }); + assert.equal(results[0].url, 'http://mozilla.com/', + 'first entry is the highest visit count'); + + results = yield searchP({}, { sort: 'date' }); + assert.equal(results[5].url, 'http://github.com/', + 'latest visited should be first'); + + results = yield searchP({}, { sort: 'date', descending: true }); + assert.equal(results[0].url, 'http://github.com/', + 'latest visited should be at the end'); +}; + +exports.testEmitters = function (assert, done) { + let urls = [ + 'http://mozilla.com/', 'http://webaud.io/', 'http://mozilla.com/webfwd/', + 'http://developer.mozilla.com/', 'http://bandcamp.com/' + ]; + addVisits(urls).then(() => { + let count = 0; + search().on('data', item => { + assert.ok(~urls.indexOf(item.url), 'data value found in url list'); + count++; + }).on('end', results => { + assert.equal(results.length, 5, 'correct count of items'); + assert.equal(count, 5, 'data event called 5 times'); + done(); + }); + }); +}; + +function toBeWithin (range) { + range = range || 2000; + var current = new Date() * 1000; // convert to microseconds + return compared => { + return compared - current < range; + }; +} + +function searchP () { + return promisedEmitter(search.apply(null, Array.prototype.slice.call(arguments))); +} + +before(exports, (name, assert, done) => resetPlaces(done)); +after(exports, (name, assert, done) => resetPlaces(done)); diff --git a/addon-sdk/source/test/addons/places/lib/test-places-host.js b/addon-sdk/source/test/addons/places/lib/test-places-host.js new file mode 100644 index 000000000..3d0b2b3f4 --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-host.js @@ -0,0 +1,301 @@ +/* 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/. */ +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { Cc, Ci } = require('chrome'); +const { defer, all } = require('sdk/core/promise'); +const { setTimeout } = require('sdk/timers'); +const { newURI } = require('sdk/url/utils'); +const { send } = require('sdk/addon/events'); +const { set } = require('sdk/preferences/service'); +const { before, after } = require('sdk/test/utils'); + +require('sdk/places/host/host-bookmarks'); +require('sdk/places/host/host-tags'); +require('sdk/places/host/host-query'); +const { + invalidResolve, createTree, + compareWithHost, createBookmark, createBookmarkTree, resetPlaces +} = require('./places-helper'); + +const bmsrv = Cc['@mozilla.org/browser/nav-bookmarks-service;1']. + getService(Ci.nsINavBookmarksService); +const hsrv = Cc['@mozilla.org/browser/nav-history-service;1']. + getService(Ci.nsINavHistoryService); +const tagsrv = Cc['@mozilla.org/browser/tagging-service;1']. + getService(Ci.nsITaggingService); + +exports.testBookmarksCreate = function*(assert) { + let items = [{ + title: 'my title', + url: 'http://test-places-host.com/testBookmarksCreate/', + tags: ['some', 'tags', 'yeah'], + type: 'bookmark' + }, { + title: 'my folder', + type: 'group', + group: bmsrv.bookmarksMenuFolder + }, { + type: 'separator', + group: bmsrv.unfiledBookmarksFolder + }]; + + yield all(items.map((item) => { + return send('sdk-places-bookmarks-create', item).then((data) => { + compareWithHost(assert, data); + }); + })); +}; + +exports.testBookmarksCreateFail = function (assert, done) { + let items = [{ + title: 'my title', + url: 'not-a-url', + type: 'bookmark' + }, { + type: 'group', + group: bmsrv.bookmarksMenuFolder + }, { + group: bmsrv.unfiledBookmarksFolder + }]; + all(items.map(function (item) { + return send('sdk-places-bookmarks-create', item).then(null, function (reason) { + assert.ok(reason, 'bookmark create should fail'); + }); + })).then(done); +}; + +exports.testBookmarkLastUpdated = function (assert, done) { + let timestamp; + let item; + createBookmark({ + url: 'http://test-places-host.com/testBookmarkLastUpdated' + }).then(function (data) { + item = data; + timestamp = item.updated; + return send('sdk-places-bookmarks-last-updated', { id: item.id }); + }).then(function (updated) { + let { resolve, promise } = defer(); + assert.equal(timestamp, updated, 'should return last updated time'); + item.title = 'updated mozilla'; + setTimeout(() => { + resolve(send('sdk-places-bookmarks-save', item)); + }, 100); + return promise; + }).then(function (data) { + assert.ok(data.updated > timestamp, 'time has elapsed and updated the updated property'); + done(); + }); +}; + +exports.testBookmarkRemove = function (assert, done) { + let id; + createBookmark({ + url: 'http://test-places-host.com/testBookmarkRemove/' + }).then(function (data) { + id = data.id; + compareWithHost(assert, data); // ensure bookmark exists + bmsrv.getItemTitle(id); // does not throw an error + return send('sdk-places-bookmarks-remove', data); + }).then(function () { + assert.throws(function () { + bmsrv.getItemTitle(id); + }, 'item should no longer exist'); + done(); + }, assert.fail); +}; + +exports.testBookmarkGet = function (assert, done) { + let bookmark; + createBookmark({ + url: 'http://test-places-host.com/testBookmarkGet/' + }).then(function (data) { + bookmark = data; + return send('sdk-places-bookmarks-get', { id: data.id }); + }).then(function (data) { + 'title url index group updated type tags'.split(' ').map(function (prop) { + if (prop === 'tags') { + for (let tag of bookmark.tags) { + assert.ok(~data.tags.indexOf(tag), + 'correctly fetched tag ' + tag); + } + assert.equal(bookmark.tags.length, data.tags.length, + 'same amount of tags'); + } + else + assert.equal(bookmark[prop], data[prop], 'correctly fetched ' + prop); + }); + done(); + }); +}; + +exports.testTagsTag = function (assert, done) { + let url; + createBookmark({ + url: 'http://test-places-host.com/testTagsTag/', + }).then(function (data) { + url = data.url; + return send('sdk-places-tags-tag', { + url: data.url, tags: ['mozzerella', 'foxfire'] + }); + }).then(function () { + let tags = tagsrv.getTagsForURI(newURI(url)); + assert.ok(~tags.indexOf('mozzerella'), 'first tag found'); + assert.ok(~tags.indexOf('foxfire'), 'second tag found'); + assert.ok(~tags.indexOf('firefox'), 'default tag found'); + assert.equal(tags.length, 3, 'no extra tags'); + done(); + }); +}; + +exports.testTagsUntag = function (assert, done) { + let item; + createBookmark({ + url: 'http://test-places-host.com/testTagsUntag/', + tags: ['tag1', 'tag2', 'tag3'] + }).then(data => { + item = data; + return send('sdk-places-tags-untag', { + url: item.url, + tags: ['tag2', 'firefox'] + }); + }).then(function () { + let tags = tagsrv.getTagsForURI(newURI(item.url)); + assert.ok(~tags.indexOf('tag1'), 'first tag persisted'); + assert.ok(~tags.indexOf('tag3'), 'second tag persisted'); + assert.ok(!~tags.indexOf('firefox'), 'first tag removed'); + assert.ok(!~tags.indexOf('tag2'), 'second tag removed'); + assert.equal(tags.length, 2, 'no extra tags'); + done(); + }); +}; + +exports.testTagsGetURLsByTag = function (assert, done) { + let item; + createBookmark({ + url: 'http://test-places-host.com/testTagsGetURLsByTag/' + }).then(function (data) { + item = data; + return send('sdk-places-tags-get-urls-by-tag', { + tag: 'firefox' + }); + }).then(function(urls) { + assert.equal(item.url, urls[0], 'returned correct url'); + assert.equal(urls.length, 1, 'returned only one url'); + done(); + }); +}; + +exports.testTagsGetTagsByURL = function (assert, done) { + let item; + createBookmark({ + url: 'http://test-places-host.com/testTagsGetURLsByTag/', + tags: ['firefox', 'mozilla', 'metal'] + }).then(function (data) { + item = data; + return send('sdk-places-tags-get-tags-by-url', { + url: data.url, + }); + }).then(function(tags) { + assert.ok(~tags.indexOf('firefox'), 'returned first tag'); + assert.ok(~tags.indexOf('mozilla'), 'returned second tag'); + assert.ok(~tags.indexOf('metal'), 'returned third tag'); + assert.equal(tags.length, 3, 'returned all tags'); + done(); + }); +}; + +exports.testHostQuery = function (assert, done) { + all([ + createBookmark({ + url: 'http://firefox.com/testHostQuery/', + tags: ['firefox', 'mozilla'] + }), + createBookmark({ + url: 'http://mozilla.com/testHostQuery/', + tags: ['mozilla'] + }), + createBookmark({ url: 'http://thunderbird.com/testHostQuery/' }) + ]).then(data => { + return send('sdk-places-query', { + queries: { tags: ['mozilla'] }, + options: { sortingMode: 6, queryType: 1 } // sort by URI ascending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should only return two'); + assert.equal(results[0].url, + 'http://mozilla.com/testHostQuery/', 'is sorted by URI asc'); + return send('sdk-places-query', { + queries: { tags: ['mozilla'] }, + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should only return two'); + assert.equal(results[0].url, + 'http://firefox.com/testHostQuery/', 'is sorted by URI desc'); + done(); + }); +}; + +exports.testHostMultiQuery = function (assert, done) { + all([ + createBookmark({ + url: 'http://firefox.com/testHostMultiQuery/', + tags: ['firefox', 'mozilla'] + }), + createBookmark({ + url: 'http://mozilla.com/testHostMultiQuery/', + tags: ['mozilla'] + }), + createBookmark({ url: 'http://thunderbird.com/testHostMultiQuery/' }) + ]).then(data => { + return send('sdk-places-query', { + queries: [{ tags: ['firefox'] }, { uri: 'http://thunderbird.com/testHostMultiQuery/' }], + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 2, 'should return 2 results ORing queries'); + assert.equal(results[0].url, + 'http://firefox.com/testHostMultiQuery/', 'should match URL or tag'); + assert.equal(results[1].url, + 'http://thunderbird.com/testHostMultiQuery/', 'should match URL or tag'); + return send('sdk-places-query', { + queries: [{ tags: ['firefox'], url: 'http://mozilla.com/testHostMultiQuery/' }], + options: { sortingMode: 5, queryType: 1 } // sort by URI descending, bookmarks only + }); + }).then(results => { + assert.equal(results.length, 0, 'query props should be AND\'d'); + done(); + }); +}; + +exports.testGetAllBookmarks = function (assert, done) { + createBookmarkTree().then(() => { + return send('sdk-places-bookmarks-get-all', {}); + }).then(res => { + assert.equal(res.length, 8, 'all bookmarks returned'); + done(); + }, assert.fail); +}; + +exports.testGetAllChildren = function (assert, done) { + createBookmarkTree().then(results => { + return send('sdk-places-bookmarks-get-children', { + id: results.filter(({title}) => title === 'mozgroup')[0].id + }); + }).then(results => { + assert.equal(results.length, 5, + 'should return all children and folders at a single depth'); + done(); + }); +}; + +before(exports, (name, assert, done) => resetPlaces(done)); +after(exports, (name, assert, done) => resetPlaces(done)); diff --git a/addon-sdk/source/test/addons/places/lib/test-places-utils.js b/addon-sdk/source/test/addons/places/lib/test-places-utils.js new file mode 100644 index 000000000..c909a2cbb --- /dev/null +++ b/addon-sdk/source/test/addons/places/lib/test-places-utils.js @@ -0,0 +1,78 @@ +/* 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/. */ +'use strict'; + +module.metadata = { + 'engines': { + 'Firefox': '*' + } +}; + +const { defer, all } = require('sdk/core/promise'); +const { setTimeout } = require('sdk/timers'); +const { TreeNode } = require('sdk/places/utils'); + +exports['test construct tree'] = function (assert) { + let tree = TreeNode(1); + tree.add([2, 3, 4]); + tree.get(2).add([2.1, 2.2, 2.3]); + let newTreeNode = TreeNode(4.3); + newTreeNode.add([4.31, 4.32]); + tree.get(4).add([4.1, 4.2, newTreeNode]); + + assert.equal(tree.get(2).value, 2, 'get returns node with correct value'); + assert.equal(tree.get(2.3).value, 2.3, 'get returns node with correct value'); + assert.equal(tree.get(4.32).value, 4.32, 'get returns node even if created from nested node'); + assert.equal(tree.get(4).children.length, 3, 'nodes have correct children length'); + assert.equal(tree.get(3).children.length, 0, 'nodes have correct children length'); + + assert.equal(tree.get(4).get(4.32).value, 4.32, 'node.get descends from itself'); + assert.equal(tree.get(4).get(2), null, 'node.get descends from itself fails if not descendant'); +}; + +exports['test walk'] = function (assert, done) { + let resultsAll = []; + let resultsNode = []; + let tree = TreeNode(1); + tree.add([2, 3, 4]); + tree.get(2).add([2.1, 2.2]); + + tree.walk(function (node) { + resultsAll.push(node.value); + }).then(() => { + [1, 2, 2.1, 2.2, 3, 4].forEach(num => { + assert.ok(~resultsAll.indexOf(num), 'function applied to each node from root'); + }); + return tree.get(2).walk(node => resultsNode.push(node.value)); + }).then(() => { + [2, 2.1, 2.2].forEach(function (num) { + assert.ok(~resultsNode.indexOf(num), 'function applied to each node from node'); + }); + }).catch(assert.fail).then(done); +}; + +exports['test async walk'] = function (assert, done) { + let resultsAll = []; + let tree = TreeNode(1); + tree.add([2, 3, 4]); + tree.get(2).add([2.1, 2.2]); + + tree.walk(function (node) { + let deferred = defer(); + setTimeout(function () { + resultsAll.push(node.value); + deferred.resolve(node.value); + }, node.value === 2 ? 50 : 5); + return deferred.promise; + }).then(function () { + [1, 2, 2.1, 2.2, 3, 4].forEach(function (num) { + assert.ok(~resultsAll.indexOf(num), 'function applied to each node from root'); + }); + assert.ok(resultsAll.indexOf(2) < resultsAll.indexOf(2.1), + 'child should wait for parent to complete'); + assert.ok(resultsAll.indexOf(2) < resultsAll.indexOf(2.2), + 'child should wait for parent to complete'); + done(); + }); +}; diff --git a/addon-sdk/source/test/addons/places/package.json b/addon-sdk/source/test/addons/places/package.json new file mode 100644 index 000000000..97ce28c88 --- /dev/null +++ b/addon-sdk/source/test/addons/places/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-places@jetpack", + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/predefined-id-with-at/lib/main.js b/addon-sdk/source/test/addons/predefined-id-with-at/lib/main.js new file mode 100644 index 000000000..12748f4eb --- /dev/null +++ b/addon-sdk/source/test/addons/predefined-id-with-at/lib/main.js @@ -0,0 +1,32 @@ +/* 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/. */ +'use strict'; + +const { id, preferencesBranch } = require('sdk/self'); +const simple = require('sdk/simple-prefs'); +const service = require('sdk/preferences/service'); +const { getAddonByID } = require('sdk/addon/manager'); + +const expected_id = 'predefined-id@test'; + +exports.testExpectedID = function(assert) { + assert.equal(id, expected_id, 'ID is as expected'); + assert.equal(preferencesBranch, expected_id, 'preferences-branch is ' + expected_id); + + assert.equal(simple.prefs.test, 5, 'test pref is 5'); + + simple.prefs.test2 = '25'; + assert.equal(service.get('extensions.'+expected_id+'.test2'), '25', 'test pref is 25'); + assert.equal(service.get('extensions.'+expected_id+'.test2'), simple.prefs.test2, 'test pref is 25'); +} + +exports.testSelfID = function*(assert) { + assert.equal(typeof(id), 'string', 'self.id is a string'); + assert.ok(id.length > 0, 'self.id not empty'); + + let addon = yield getAddonByID(id); + assert.equal(addon.id, id, 'found addon with self.id'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/predefined-id-with-at/package.json b/addon-sdk/source/test/addons/predefined-id-with-at/package.json new file mode 100644 index 000000000..c20d8715b --- /dev/null +++ b/addon-sdk/source/test/addons/predefined-id-with-at/package.json @@ -0,0 +1,13 @@ +{ + "id": "predefined-id@test", + "fullName": "predefined ID test", + "author": "Erik Vold", + "preferences": [{ + "name": "test", + "type": "integer", + "title": "test", + "value": 5 + }], + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/preferences-branch/lib/main.js b/addon-sdk/source/test/addons/preferences-branch/lib/main.js new file mode 100644 index 000000000..659a57e92 --- /dev/null +++ b/addon-sdk/source/test/addons/preferences-branch/lib/main.js @@ -0,0 +1,28 @@ +/* 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/. */ +'use strict'; + +const { id, preferencesBranch } = require('sdk/self'); +const simple = require('sdk/simple-prefs'); +const service = require('sdk/preferences/service'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testPreferencesBranch = function(assert) { + assert.equal(preferencesBranch, 'human-readable', 'preferencesBranch is human-readable'); + assert.equal(simple.prefs.test42, true, 'test42 is true'); + + simple.prefs.test43 = 'movie'; + assert.equal(service.get('extensions.human-readable.test43'), 'movie', 'test43 is a movie'); + +} + +// from `/test/test-self.js`, adapted to `sdk/test/assert` API +exports.testSelfID = function*(assert) { + assert.equal(typeof(id), 'string', 'self.id is a string'); + assert.ok(id.length > 0, 'self.id not empty'); + let addon = yield getAddonByID(id); + assert.ok(addon, 'found addon with self.id'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/preferences-branch/package.json b/addon-sdk/source/test/addons/preferences-branch/package.json new file mode 100644 index 000000000..8e9858558 --- /dev/null +++ b/addon-sdk/source/test/addons/preferences-branch/package.json @@ -0,0 +1,14 @@ +{ + "id": "test-preferences-branch@jetpack", + "fullName": "preferences-branch test", + "author": "Tomislav Jovanovic", + "preferences": [{ + "name": "test42", + "type": "bool", + "title": "test42", + "value": true + }], + "preferences-branch": "human-readable", + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/main.js b/addon-sdk/source/test/addons/private-browsing-supported/main.js new file mode 100644 index 000000000..290427dc2 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/main.js @@ -0,0 +1,28 @@ +/* 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/. */ +'use strict'; + +const { merge } = require('sdk/util/object'); +const app = require('sdk/system/xul-app'); + +merge(module.exports, + require('./test-tabs'), + require('./test-page-mod'), + require('./test-private-browsing'), + require('./test-sidebar') +); + +// Doesn't make sense to test window-utils and windows on fennec, +// as there is only one window which is never private. Also ignore +// unsupported modules (panel, selection) +if (!app.is('Fennec')) { + merge(module.exports, + require('./test-selection'), + require('./test-panel'), + require('./test-window-tabs'), + require('./test-windows') + ); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/private-browsing-supported/package.json b/addon-sdk/source/test/addons/private-browsing-supported/package.json new file mode 100644 index 000000000..87b96017d --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/package.json @@ -0,0 +1,8 @@ +{ + "id": "private-browsing-mode-test@jetpack", + "permissions": { + "private-browsing": true + }, + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/sidebar/utils.js b/addon-sdk/source/test/addons/private-browsing-supported/sidebar/utils.js new file mode 100644 index 000000000..e0f6234f6 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/sidebar/utils.js @@ -0,0 +1,67 @@ +/* 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/. */ +'use strict'; + +const { Cu } = require('chrome'); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { fromIterator } = require('sdk/util/array'); + +const BUILTIN_SIDEBAR_MENUITEMS = exports.BUILTIN_SIDEBAR_MENUITEMS = [ + 'menu_socialSidebar', + 'menu_historySidebar', + 'menu_bookmarksSidebar', +]; + +function isSidebarShowing(window) { + window = window || getMostRecentBrowserWindow(); + let sidebar = window.document.getElementById('sidebar-box'); + return !sidebar.hidden; +} +exports.isSidebarShowing = isSidebarShowing; + +function getSidebarMenuitems(window) { + window = window || getMostRecentBrowserWindow(); + return fromIterator(window.document.querySelectorAll('#viewSidebarMenu menuitem')); +} +exports.getSidebarMenuitems = getSidebarMenuitems; + +function getExtraSidebarMenuitems() { + let menuitems = getSidebarMenuitems(); + return menuitems.filter(function(mi) { + return BUILTIN_SIDEBAR_MENUITEMS.indexOf(mi.getAttribute('id')) < 0; + }); +} +exports.getExtraSidebarMenuitems = getExtraSidebarMenuitems; + +function makeID(id) { + return 'jetpack-sidebar-' + id; +} +exports.makeID = makeID; + +function simulateCommand(ele) { + let window = ele.ownerDocument.defaultView; + let { document } = window; + var evt = document.createEvent('XULCommandEvent'); + evt.initCommandEvent('command', true, true, window, + 0, false, false, false, false, null); + ele.dispatchEvent(evt); +} +exports.simulateCommand = simulateCommand; + +function simulateClick(ele) { + let window = ele.ownerDocument.defaultView; + let { document } = window; + let evt = document.createEvent('MouseEvents'); + evt.initMouseEvent('click', true, true, window, + 0, 0, 0, 0, 0, false, false, false, false, 0, null); + ele.dispatchEvent(evt); +} +exports.simulateClick = simulateClick; + +// OSX and Windows exhibit different behaviors when 'checked' is false, +// so compare against the consistent 'true'. See bug 894809. +function isChecked(node) { + return node.getAttribute('checked') === 'true'; +}; +exports.isChecked = isChecked; diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-page-mod.js b/addon-sdk/source/test/addons/private-browsing-supported/test-page-mod.js new file mode 100644 index 000000000..ca5122013 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-page-mod.js @@ -0,0 +1,119 @@ +/* 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/. */ +"use strict"; + +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { PageMod } = require("sdk/page-mod"); +const { getActiveTab, setTabURL, openTab, closeTab } = require('sdk/tabs/utils'); +const xulApp = require('sdk/system/xul-app'); +const windowHelpers = require('sdk/window/helpers'); +const { defer } = require("sdk/core/promise"); +const { isPrivate } = require('sdk/private-browsing'); +const { isTabPBSupported, isWindowPBSupported } = require('sdk/private-browsing/utils'); +const { cleanUI } = require('sdk/test/utils'); + +function openWebpage(url, enablePrivate) { + return new Promise((resolve, reject) => { + if (xulApp.is("Fennec")) { + let chromeWindow = getMostRecentBrowserWindow(); + let rawTab = openTab(chromeWindow, url, { + isPrivate: enablePrivate + }); + + resolve(() => new Promise(resolve => { + closeTab(rawTab); + resolve(); + })); + } + else { + windowHelpers.open("", { + features: { + toolbar: true, + private: enablePrivate + } + }). + then((chromeWindow) => { + if (isPrivate(chromeWindow) !== !!enablePrivate) { + reject(new Error("Window should have Private set to " + !!enablePrivate)); + } + + let tab = getActiveTab(chromeWindow); + setTabURL(tab, url); + + resolve(() => windowHelpers.close(chromeWindow)); + }). + catch(reject); + } + }); +} + +exports["test page-mod on private tab"] = function*(assert) { + // Only set private mode when explicitely supported. + // (fennec 19 has some intermediate PB support where isTabPBSupported + // will be false, but isPrivate(worker.tab) will be true if we open a private + // tab) + let setPrivate = isTabPBSupported || isWindowPBSupported; + + let id = Date.now().toString(36); + let frameUri = "data:text/html;charset=utf-8," + id; + let uri = "data:text/html;charset=utf-8," + + encodeURIComponent(id + "<iframe src='" + frameUri + "'><iframe>"); + + let count = 0; + + let close = yield openWebpage(uri, setPrivate); + yield new Promise(resolve => { + PageMod({ + include: [uri, frameUri], + + onAttach: function(worker) { + assert.ok(worker.tab.url == uri || worker.tab.url == frameUri, + "Got a worker attached to the private window tab"); + + if (setPrivate) { + assert.ok(isPrivate(worker), "The worker is really private"); + assert.ok(isPrivate(worker.tab), "The document is really private"); + } + else { + assert.ok(!isPrivate(worker), + "private browsing isn't supported, " + + "so that the worker isn't private"); + assert.ok(!isPrivate(worker.tab), + "private browsing isn't supported, " + + "so that the document isn't private"); + } + + if (++count == 2) { + this.destroy(); + resolve(); + } + } + }); + }); + yield close(); + yield cleanUI(); +}; + +exports["test page-mod on non-private tab"] = function*(assert) { + let id = Date.now().toString(36); + let url = "data:text/html;charset=utf-8," + encodeURIComponent(id); + + let close = yield openWebpage(url, false); + let mod; + let worker = yield new Promise(resolve => { + mod = PageMod({ + include: url, + onAttach: resolve + }); + }); + + assert.equal(worker.tab.url, url, + "Got a worker attached to the private window tab"); + assert.ok(!isPrivate(worker), "The worker is really non-private"); + assert.ok(!isPrivate(worker.tab), "The document is really non-private"); + + mod.destroy(); + yield close(); + yield cleanUI(); +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-panel.js b/addon-sdk/source/test/addons/private-browsing-supported/test-panel.js new file mode 100644 index 000000000..1ba3e9554 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-panel.js @@ -0,0 +1,99 @@ +/* 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/. */ +'use strict'; + +const { open, focus, close } = require('sdk/window/helpers'); +const { isPrivate } = require('sdk/private-browsing'); +const { defer } = require('sdk/core/promise'); +const { browserWindows: windows } = require('sdk/windows'); +const { getInnerId, getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { getActiveView } = require('sdk/view/core'); + +const BROWSER = 'chrome://browser/content/browser.xul'; + +exports.testRequirePanel = function(assert) { + require('sdk/panel'); + assert.ok('the panel module should not throw an error'); +}; + +exports.testShowPanelInPrivateWindow = function(assert, done) { + let panel = require('sdk/panel').Panel({ + contentURL: "data:text/html;charset=utf-8,I'm a leaf on the wind" + }); + + assert.ok(windows.length > 0, 'there is at least one open window'); + for (let window of windows) { + assert.equal(isPrivate(window), false, 'open window is private'); + } + + let panelView = getActiveView(panel); + let expectedWindowId = getInnerId(panelView.backgroundFrame.contentWindow); + + function checkPanelFrame() { + let iframe = panelView.firstChild; + + assert.equal(panelView.viewFrame, iframe, 'panel has the correct viewFrame value'); + + let windowId = getInnerId(iframe.contentWindow); + + assert.equal(windowId, expectedWindowId, 'panel has the correct window visible'); + + assert.equal(iframe.contentDocument.body.textContent, + "I'm a leaf on the wind", + 'the panel has the expected content'); + } + + function testPanel(window) { + let { promise, resolve } = defer(); + + assert.ok(!panel.isShowing, 'the panel is not showing [1]'); + + panel.once('show', function() { + assert.ok(panel.isShowing, 'the panel is showing'); + + checkPanelFrame(); + + panel.once('hide', function() { + assert.ok(!panel.isShowing, 'the panel is not showing [2]'); + + resolve(window); + }); + + panel.hide(); + }); + + panel.show(); + + return promise; + }; + + let initialWindow = getMostRecentBrowserWindow(); + + testPanel(initialWindow). + then(makeEmptyPrivateBrowserWindow). + then(focus). + then(function(window) { + assert.equal(isPrivate(window), true, 'opened window is private'); + assert.pass('private window was focused'); + return window; + }). + then(testPanel). + then(close). + then(() => focus(initialWindow)). + then(testPanel). + then(done). + then(null, assert.fail); +}; + + +function makeEmptyPrivateBrowserWindow(options) { + options = options || {}; + return open(BROWSER, { + features: { + chrome: true, + toolbar: true, + private: true + } + }); +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-private-browsing.js b/addon-sdk/source/test/addons/private-browsing-supported/test-private-browsing.js new file mode 100644 index 000000000..a7b1e26ca --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-private-browsing.js @@ -0,0 +1,111 @@ +/* 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/. */ +'use strict'; + +const { Ci } = require('chrome'); +const { isPrivateBrowsingSupported } = require('sdk/self'); +const tabs = require('sdk/tabs'); +const { browserWindows: windows } = require('sdk/windows'); +const { isPrivate } = require('sdk/private-browsing'); +const { is } = require('sdk/system/xul-app'); +const { isWindowPBSupported, isTabPBSupported } = require('sdk/private-browsing/utils'); +const { cleanUI } = require('sdk/test/utils'); + +const TAB_URL = 'about:addons'; + +exports.testIsPrivateBrowsingTrue = function(assert) { + assert.ok(isPrivateBrowsingSupported, + 'isPrivateBrowsingSupported property is true'); +}; + +// test that it is possible to open a private tab +exports.testTabOpenPrivate = function(assert, done) { + tabs.open({ + url: TAB_URL, + isPrivate: true, + onReady: function(tab) { + assert.equal(tab.url, TAB_URL, 'opened correct tab'); + assert.equal(isPrivate(tab), (isWindowPBSupported || isTabPBSupported), "tab is private"); + cleanUI().then(done).catch(console.exception); + } + }); +} + + +// test that it is possible to open a non private tab +exports.testTabOpenPrivateDefault = function(assert, done) { + tabs.open({ + url: TAB_URL, + onReady: function(tab) { + assert.equal(tab.url, TAB_URL, 'opened correct tab'); + assert.equal(isPrivate(tab), false, "tab is not private"); + cleanUI().then(done).catch(console.exception); + } + }); +} + +// test that it is possible to open a non private tab in explicit case +exports.testTabOpenPrivateOffExplicit = function(assert, done) { + tabs.open({ + url: TAB_URL, + isPrivate: false, + onReady: function(tab) { + assert.equal(tab.url, TAB_URL, 'opened correct tab'); + assert.equal(isPrivate(tab), false, "tab is not private"); + cleanUI().then(done).catch(console.exception); + } + }); +} + +// test windows.open with isPrivate: true +// test isPrivate on a window +if (!is('Fennec')) { + // test that it is possible to open a private window + exports.testWindowOpenPrivate = function(assert, done) { + windows.open({ + url: TAB_URL, + isPrivate: true, + onOpen: function(window) { + let tab = window.tabs[0]; + tab.once('ready', function() { + assert.equal(tab.url, TAB_URL, 'opened correct tab'); + assert.equal(isPrivate(tab), isWindowPBSupported, 'tab is private'); + cleanUI().then(done).catch(console.exception); + }); + } + }); + }; + + exports.testIsPrivateOnWindowOn = function(assert, done) { + windows.open({ + isPrivate: true, + onOpen: function(window) { + assert.equal(isPrivate(window), isWindowPBSupported, 'isPrivate for a window is true when it should be'); + assert.equal(isPrivate(window.tabs[0]), isWindowPBSupported, 'isPrivate for a tab is false when it should be'); + cleanUI().then(done).catch(console.exception); + } + }); + }; + + exports.testIsPrivateOnWindowOffImplicit = function(assert, done) { + windows.open({ + onOpen: function(window) { + assert.equal(isPrivate(window), false, 'isPrivate for a window is false when it should be'); + assert.equal(isPrivate(window.tabs[0]), false, 'isPrivate for a tab is false when it should be'); + cleanUI().then(done).catch(console.exception); + } + }) + } + + exports.testIsPrivateOnWindowOffExplicit = function(assert, done) { + windows.open({ + isPrivate: false, + onOpen: function(window) { + assert.equal(isPrivate(window), false, 'isPrivate for a window is false when it should be'); + assert.equal(isPrivate(window.tabs[0]), false, 'isPrivate for a tab is false when it should be'); + cleanUI().then(done).catch(console.exception); + } + }) + } +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-selection.js b/addon-sdk/source/test/addons/private-browsing-supported/test-selection.js new file mode 100644 index 000000000..3fa1c1b5d --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-selection.js @@ -0,0 +1,447 @@ +/* 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/. */ + +"use strict"; + +const HTML = "<html>\ + <body>\ + <div>foo</div>\ + <div>and</div>\ + <textarea>noodles</textarea>\ + </body>\ +</html>"; + +const URL = "data:text/html;charset=utf-8," + encodeURIComponent(HTML); + +const FRAME_HTML = "<iframe src='" + URL + "'><iframe>"; +const FRAME_URL = "data:text/html;charset=utf-8," + encodeURIComponent(FRAME_HTML); + +const { defer } = require("sdk/core/promise"); +const { browserWindows } = require("sdk/windows"); +const tabs = require("sdk/tabs"); +const { setTabURL, getActiveTab, getTabContentWindow, closeTab, getTabs, + getTabTitle } = require("sdk/tabs/utils"); +const { getMostRecentBrowserWindow, isFocused } = require("sdk/window/utils"); +const { open: openNewWindow, close: closeWindow, focus } = require("sdk/window/helpers"); +const { Loader } = require("sdk/test/loader"); +const { merge } = require("sdk/util/object"); +const { isPrivate } = require("sdk/private-browsing"); + +// General purpose utility functions + +/** + * Opens the url given and return a promise, that will be resolved with the + * content window when the document is ready. + * + * I believe this approach could be useful in most of our unit test, that + * requires to open a tab and need to access to its content. + */ +function open(url, options) { + let { promise, resolve } = defer(); + + if (options && typeof(options) === "object") { + openNewWindow("", { + features: merge({ toolbar: true }, options) + }).then(function(chromeWindow) { + if (isPrivate(chromeWindow) !== !!options.private) + throw new Error("Window should have Private set to " + !!options.private); + + let tab = getActiveTab(chromeWindow); + + tab.linkedBrowser.addEventListener("load", function ready(event) { + let { document } = getTabContentWindow(tab); + + if (document.readyState === "complete" && document.URL === url) { + this.removeEventListener(event.type, ready); + + if (options.title) + document.title = options.title; + + resolve(document.defaultView); + } + }, true); + + setTabURL(tab, url); + }); + + return promise; + }; + + tabs.open({ + url: url, + onReady: function(tab) { + // Unfortunately there is no way to get a XUL Tab from SDK Tab on Firefox, + // only on Fennec. We should implement `tabNS` also on Firefox in order + // to have that. + + // Here we assuming that the most recent browser window is the one we're + // doing the test, and the active tab is the one we just opened. + let window = getTabContentWindow(getActiveTab(getMostRecentBrowserWindow())); + + resolve(window); + } + }); + + return promise; +}; + +/** + * Reload the window given and return a promise, that will be resolved with the + * content window after a small delay. + */ +function reload(window) { + let { promise, resolve } = defer(); + + // Here we assuming that the most recent browser window is the one we're + // doing the test, and the active tab is the one we just opened. + let tab = tabs.activeTab; + + tab.once("ready", function () { + resolve(window); + }); + + window.location.reload(true); + + return promise; +} + +// Selection's unit test utility function + +/** + * Select the first div in the page, adding the range to the selection. + */ +function selectFirstDiv(window) { + let div = window.document.querySelector("div"); + let selection = window.getSelection(); + let range = window.document.createRange(); + + if (selection.rangeCount > 0) + selection.removeAllRanges(); + + range.selectNode(div); + selection.addRange(range); + + return window; +} + +/** + * Select all divs in the page, adding the ranges to the selection. + */ +function selectAllDivs(window) { + let divs = window.document.getElementsByTagName("div"); + let selection = window.getSelection(); + + if (selection.rangeCount > 0) + selection.removeAllRanges(); + + for (let i = 0; i < divs.length; i++) { + let range = window.document.createRange(); + + range.selectNode(divs[i]); + selection.addRange(range); + } + + return window; +} + +/** + * Select the textarea content + */ +function selectTextarea(window) { + let selection = window.getSelection(); + let textarea = window.document.querySelector("textarea"); + + if (selection.rangeCount > 0) + selection.removeAllRanges(); + + textarea.setSelectionRange(0, textarea.value.length); + textarea.focus(); + + return window; +} + +/** + * Select the content of the first div + */ +function selectContentFirstDiv(window) { + let div = window.document.querySelector("div"); + let selection = window.getSelection(); + let range = window.document.createRange(); + + if (selection.rangeCount > 0) + selection.removeAllRanges(); + + range.selectNodeContents(div); + selection.addRange(range); + + return window; +} + +/** + * Dispatch the selection event for the selection listener added by + * `nsISelectionPrivate.addSelectionListener` + */ +function dispatchSelectionEvent(window) { + // We modify the selection in order to dispatch the selection's event, by + // contract the selection by one character. So if the text selected is "foo" + // will be "fo". + window.getSelection().modify("extend", "backward", "character"); + + return window; +} + +/** + * Dispatch the selection event for the selection listener added by + * `window.onselect` / `window.addEventListener` + */ +function dispatchOnSelectEvent(window) { + let { document } = window; + let textarea = document.querySelector("textarea"); + let event = document.createEvent("UIEvents"); + + event.initUIEvent("select", true, true, window, 1); + + textarea.dispatchEvent(event); + + return window; +} + +// Test cases + +exports["test PWPB Selection Listener"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "PWPB Selection Listener"}). + then(function(window) { + selection.once("select", function() { + assert.equal(browserWindows.length, 2, "there should be only two windows open."); + assert.equal(getTabs().length, 2, "there should be only two tabs open: '" + + getTabs().map(tab => getTabTitle(tab)).join("', '") + + "'." + ); + + // window should be focused, but force the focus anyhow.. see bug 841823 + focus(window).then(function() { + // check state of window + assert.ok(isFocused(window), "the window is focused"); + assert.ok(isPrivate(window), "the window should be a private window"); + + assert.equal(selection.text, "fo"); + + closeWindow(window). + then(loader.unload). + then(done). + then(null, assert.fail); + }); + }); + return window; + }). + then(selectContentFirstDiv). + then(dispatchSelectionEvent). + then(null, assert.fail); +}; + +exports["test PWPB Textarea OnSelect Listener"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "PWPB OnSelect Listener"}). + then(function(window) { + selection.once("select", function() { + assert.equal(browserWindows.length, 2, "there should be only two windows open."); + assert.equal(getTabs().length, 2, "there should be only two tabs open: '" + + getTabs().map(tab => getTabTitle(tab)).join("', '") + + "'." + ); + + // window should be focused, but force the focus anyhow.. see bug 841823 + focus(window).then(function() { + assert.equal(selection.text, "noodles"); + + closeWindow(window). + then(loader.unload). + then(done). + then(null, assert.fail); + }); + }); + return window; + }). + then(selectTextarea). + then(dispatchOnSelectEvent). + then(null, assert.fail); +}; + +exports["test PWPB Single DOM Selection"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "PWPB Single DOM Selection"}). + then(selectFirstDiv). + then(focus).then(function(window) { + assert.equal(selection.isContiguous, true, + "selection.isContiguous with single DOM Selection works."); + + assert.equal(selection.text, "foo", + "selection.text with single DOM Selection works."); + + assert.equal(selection.html, "<div>foo</div>", + "selection.html with single DOM Selection works."); + + let selectionCount = 0; + for (let sel of selection) { + selectionCount++; + + assert.equal(sel.text, "foo", + "iterable selection.text with single DOM Selection works."); + + assert.equal(sel.html, "<div>foo</div>", + "iterable selection.html with single DOM Selection works."); + } + + assert.equal(selectionCount, 1, + "One iterable selection"); + + return closeWindow(window); + }).then(loader.unload).then(done).then(null, assert.fail); +} + +exports["test PWPB Textarea Selection"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "PWPB Textarea Listener"}). + then(selectTextarea). + then(focus). + then(function(window) { + + assert.equal(selection.isContiguous, true, + "selection.isContiguous with Textarea Selection works."); + + assert.equal(selection.text, "noodles", + "selection.text with Textarea Selection works."); + + assert.strictEqual(selection.html, null, + "selection.html with Textarea Selection works."); + + let selectionCount = 0; + for (let sel of selection) { + selectionCount++; + + assert.equal(sel.text, "noodles", + "iterable selection.text with Textarea Selection works."); + + assert.strictEqual(sel.html, null, + "iterable selection.html with Textarea Selection works."); + } + + assert.equal(selectionCount, 1, + "One iterable selection"); + + return closeWindow(window); + }).then(loader.unload).then(done).then(null, assert.fail); +}; + +exports["test PWPB Set HTML in Multiple DOM Selection"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "PWPB Set HTML in Multiple DOM Selection"}). + then(selectAllDivs). + then(focus). + then(function(window) { + let html = "<span>b<b>a</b>r</span>"; + + let expectedText = ["bar", "and"]; + let expectedHTML = [html, "<div>and</div>"]; + + selection.html = html; + + assert.equal(selection.text, expectedText[0], + "set selection.text with DOM Selection works."); + + assert.equal(selection.html, expectedHTML[0], + "selection.html with DOM Selection works."); + + let selectionCount = 0; + for (let sel of selection) { + + assert.equal(sel.text, expectedText[selectionCount], + "iterable selection.text with multiple DOM Selection works."); + + assert.equal(sel.html, expectedHTML[selectionCount], + "iterable selection.html with multiple DOM Selection works."); + + selectionCount++; + } + + assert.equal(selectionCount, 2, + "Two iterable selections"); + + return closeWindow(window); + }).then(loader.unload).then(done).then(null, assert.fail); +}; + +exports["test PWPB Set Text in Textarea Selection"] = function(assert, done) { + let loader = Loader(module); + let selection = loader.require("sdk/selection"); + + open(URL, {private: true, title: "test PWPB Set Text in Textarea Selection"}). + then(selectTextarea). + then(focus). + then(function(window) { + + let text = "bar"; + + selection.text = text; + + assert.equal(selection.text, text, + "set selection.text with Textarea Selection works."); + + assert.strictEqual(selection.html, null, + "selection.html with Textarea Selection works."); + + let selectionCount = 0; + for (let sel of selection) { + selectionCount++; + + assert.equal(sel.text, text, + "iterable selection.text with Textarea Selection works."); + + assert.strictEqual(sel.html, null, + "iterable selection.html with Textarea Selection works."); + } + + assert.equal(selectionCount, 1, + "One iterable selection"); + + return closeWindow(window); + }).then(loader.unload).then(done).then(null, assert.fail); +}; + +// If the platform doesn't support the PBPW, we're replacing PBPW tests +if (!require("sdk/private-browsing/utils").isWindowPBSupported) { + module.exports = { + "test PBPW Unsupported": function Unsupported (assert) { + assert.pass("Private Window Per Browsing is not supported on this platform."); + } + } +} + +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +try { + require("sdk/selection"); +} +catch (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + "test Unsupported Application": function Unsupported (assert) { + assert.pass(err.message); + } + } +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-sidebar.js b/addon-sdk/source/test/addons/private-browsing-supported/test-sidebar.js new file mode 100644 index 000000000..410e64ff5 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-sidebar.js @@ -0,0 +1,212 @@ +/* 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/. */ +'use strict'; + +const { Loader } = require('sdk/test/loader'); +const { show, hide } = require('sdk/ui/sidebar/actions'); +const { isShowing } = require('sdk/ui/sidebar/utils'); +const { getMostRecentBrowserWindow, isWindowPrivate } = require('sdk/window/utils'); +const { open, close, focus, promise: windowPromise } = require('sdk/window/helpers'); +const { setTimeout } = require('sdk/timers'); +const { isPrivate } = require('sdk/private-browsing'); +const { data } = require('sdk/self'); +const { URL } = require('sdk/url'); + +const { BUILTIN_SIDEBAR_MENUITEMS, isSidebarShowing, + getSidebarMenuitems, getExtraSidebarMenuitems, makeID, simulateCommand, + simulateClick, isChecked } = require('./sidebar/utils'); + +exports.testSideBarIsInNewPrivateWindows = function(assert, done) { + const { Sidebar } = require('sdk/ui/sidebar'); + let testName = 'testSideBarIsInNewPrivateWindows'; + let sidebar = Sidebar({ + id: testName, + title: testName, + url: 'data:text/html;charset=utf-8,'+testName + }); + + let startWindow = getMostRecentBrowserWindow(); + let ele = startWindow.document.getElementById(makeID(testName)); + assert.ok(ele, 'sidebar element was added'); + + open(null, { features: { private: true } }).then(function(window) { + let ele = window.document.getElementById(makeID(testName)); + assert.ok(isPrivate(window), 'the new window is private'); + assert.ok(!!ele, 'sidebar element was added'); + + sidebar.destroy(); + assert.ok(!window.document.getElementById(makeID(testName)), 'sidebar id DNE'); + assert.ok(!startWindow.document.getElementById(makeID(testName)), 'sidebar id DNE'); + + return close(window); + }).then(done).then(null, assert.fail); +} + +// Disabled in order to land other fixes, see bug 910647 for further details. +/* +exports.testSidebarIsOpenInNewPrivateWindow = function(assert, done) { + const { Sidebar } = require('sdk/ui/sidebar'); + let testName = 'testSidebarIsOpenInNewPrivateWindow'; + let window = getMostRecentBrowserWindow(); + + let sidebar = Sidebar({ + id: testName, + title: testName, + url: 'data:text/html;charset=utf-8,'+testName + }); + + assert.equal(isPrivate(window), false, 'the window is not private'); + + sidebar.on('show', function() { + assert.equal(isSidebarShowing(window), true, 'the sidebar is showing'); + assert.equal(isShowing(sidebar), true, 'the sidebar is showing'); + + windowPromise(window.OpenBrowserWindow({private: true}), 'DOMContentLoaded').then(function(window2) { + assert.equal(isPrivate(window2), true, 'the new window is private'); + + let sidebarEle = window2.document.getElementById('sidebar'); + + // wait for the sidebar to load something + function onSBLoad() { + sidebarEle.contentDocument.getElementById('web-panels-browser').addEventListener('load', function() { + assert.equal(isSidebarShowing(window), true, 'the sidebar is showing in old window still'); + assert.equal(isSidebarShowing(window2), true, 'the sidebar is showing in the new private window'); + assert.equal(isShowing(sidebar), true, 'the sidebar is showing'); + + sidebar.destroy(); + close(window2).then(done); + }, true); + } + + sidebarEle.addEventListener('load', onSBLoad, true); + + assert.pass('waiting for the sidebar to open...'); + }, assert.fail).then(null, assert.fail); + }); + + sidebar.show(); +} +*/ +// TEST: edge case where web panel is destroyed while loading +exports.testDestroyEdgeCaseBugWithPrivateWindow = function(assert, done) { + const { Sidebar } = require('sdk/ui/sidebar'); + let testName = 'testDestroyEdgeCaseBug'; + let window = getMostRecentBrowserWindow(); + let sidebar = Sidebar({ + id: testName, + title: testName, + url: 'data:text/html;charset=utf-8,'+testName + }); + + // NOTE: purposely not listening to show event b/c the event happens + // between now and then. + sidebar.show(); + + assert.equal(isPrivate(window), false, 'the new window is not private'); + assert.equal(isSidebarShowing(window), true, 'the sidebar is showing'); + + //assert.equal(isShowing(sidebar), true, 'the sidebar is showing'); + + open(null, { features: { private: true } }).then(focus).then(function(window2) { + assert.equal(isPrivate(window2), true, 'the new window is private'); + assert.equal(isSidebarShowing(window2), false, 'the sidebar is not showing'); + assert.equal(isShowing(sidebar), false, 'the sidebar is not showing'); + + sidebar.destroy(); + assert.pass('destroying the sidebar'); + + close(window2).then(function() { + let loader = Loader(module); + + assert.equal(isPrivate(window), false, 'the current window is not private'); + + let sidebar = loader.require('sdk/ui/sidebar').Sidebar({ + id: testName, + title: testName, + url: 'data:text/html;charset=utf-8,'+ testName, + onShow: function() { + assert.pass('onShow works for Sidebar'); + loader.unload(); + + for (let mi of getSidebarMenuitems()) { + assert.ok(BUILTIN_SIDEBAR_MENUITEMS.indexOf(mi.getAttribute('id')) >= 0, 'the menuitem is for a built-in sidebar') + assert.ok(!isChecked(mi), 'no sidebar menuitem is checked'); + } + assert.ok(!window.document.getElementById(makeID(testName)), 'sidebar id DNE'); + assert.equal(isSidebarShowing(window), false, 'the sidebar is not showing'); + + done(); + } + }) + + sidebar.show(); + assert.pass('showing the sidebar'); + }).then(null, assert.fail); + }).then(null, assert.fail); +} + +exports.testShowInPrivateWindow = function(assert, done) { + const { Sidebar } = require('sdk/ui/sidebar'); + let testName = 'testShowInPrivateWindow'; + let window1 = getMostRecentBrowserWindow(); + let url = 'data:text/html;charset=utf-8,'+testName; + + let sidebar1 = Sidebar({ + id: testName, + title: testName, + url: url + }); + let menuitemID = makeID(sidebar1.id); + + assert.equal(sidebar1.url, url, 'url getter works'); + assert.equal(isShowing(sidebar1), false, 'the sidebar is not showing'); + assert.ok(!isChecked(window1.document.getElementById(menuitemID)), + 'the menuitem is not checked'); + assert.equal(isSidebarShowing(window1), false, 'the new window sidebar is not showing'); + + windowPromise(window1.OpenBrowserWindow({ private: true }), 'load').then(function(window) { + let { document } = window; + assert.equal(isWindowPrivate(window), true, 'new window is private'); + assert.equal(isPrivate(window), true, 'new window is private'); + + sidebar1.show().then( + function good() { + assert.equal(isShowing(sidebar1), true, 'the sidebar is showing'); + assert.ok(!!document.getElementById(menuitemID), + 'the menuitem exists on the private window'); + assert.equal(isSidebarShowing(window), true, 'the new window sidebar is showing'); + + sidebar1.destroy(); + assert.equal(isSidebarShowing(window), false, 'the new window sidebar is showing'); + assert.ok(!window1.document.getElementById(menuitemID), + 'the menuitem on the new window dne'); + + // test old window state + assert.equal(isSidebarShowing(window1), false, 'the old window sidebar is not showing'); + assert.equal(window1.document.getElementById(menuitemID), + null, + 'the menuitem on the old window dne'); + + close(window).then(done).then(null, assert.fail); + }, + function bad() { + assert.fail('a successful show should not happen here..'); + }); + }).then(null, assert.fail); +} + +// If the module doesn't support the app we're being run in, require() will +// throw. In that case, remove all tests above from exports, and add one dummy +// test that passes. +try { + require('sdk/ui/sidebar'); +} +catch (err) { + if (!/^Unsupported Application/.test(err.message)) + throw err; + + module.exports = { + 'test Unsupported Application': assert => assert.pass(err.message) + } +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-tabs.js b/addon-sdk/source/test/addons/private-browsing-supported/test-tabs.js new file mode 100644 index 000000000..c9cb34f0e --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-tabs.js @@ -0,0 +1,34 @@ +/* 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/. */ +'use strict'; + +const tabs = require('sdk/tabs'); +const { isPrivate } = require('sdk/private-browsing'); +const pbUtils = require('sdk/private-browsing/utils'); + +exports.testPrivateTabsAreListed = function (assert, done) { + let originalTabCount = tabs.length; + + tabs.open({ + url: 'about:blank', + isPrivate: true, + onOpen: function(tab) { + // PWPB case + if (pbUtils.isWindowPBSupported || pbUtils.isTabPBSupported) { + assert.ok(isPrivate(tab), "tab is private"); + assert.equal(tabs.length, originalTabCount + 1, + 'New private window\'s tab are visible in tabs list'); + } + else { + // Global case, openDialog didn't opened a private window/tab + assert.ok(!isPrivate(tab), "tab isn't private"); + assert.equal(tabs.length, originalTabCount + 1, + 'New non-private window\'s tab is visible in tabs list'); + } + + tab.close(done); + } + }); +}; + diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-window-tabs.js b/addon-sdk/source/test/addons/private-browsing-supported/test-window-tabs.js new file mode 100644 index 000000000..647a73741 --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-window-tabs.js @@ -0,0 +1,75 @@ +/* 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/. */ +'use strict'; + +const tabs = require('sdk/tabs'); +const { isPrivate } = require('sdk/private-browsing'); +const { promise: windowPromise, close, focus } = require('sdk/window/helpers'); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); + +exports.testOpenTabWithPrivateActiveWindowNoIsPrivateOption = function(assert, done) { + let window = getMostRecentBrowserWindow().OpenBrowserWindow({ private: true }); + + windowPromise(window, 'load').then(focus).then(function (window) { + assert.ok(isPrivate(window), 'new window is private'); + + tabs.open({ + url: 'about:blank', + onOpen: function(tab) { + assert.ok(isPrivate(tab), 'new tab is private'); + close(window).then(done).then(null, assert.fail); + } + }) + }).then(null, assert.fail); +} + +exports.testOpenTabWithNonPrivateActiveWindowNoIsPrivateOption = function(assert, done) { + let window = getMostRecentBrowserWindow().OpenBrowserWindow({ private: false }); + + windowPromise(window, 'load').then(focus).then(function (window) { + assert.equal(isPrivate(window), false, 'new window is not private'); + + tabs.open({ + url: 'about:blank', + onOpen: function(tab) { + assert.equal(isPrivate(tab), false, 'new tab is not private'); + close(window).then(done).then(null, assert.fail); + } + }) + }).then(null, assert.fail); +} + +exports.testOpenTabWithPrivateActiveWindowWithIsPrivateOptionTrue = function(assert, done) { + let window = getMostRecentBrowserWindow().OpenBrowserWindow({ private: true }); + + windowPromise(window, 'load').then(focus).then(function (window) { + assert.ok(isPrivate(window), 'new window is private'); + + tabs.open({ + url: 'about:blank', + isPrivate: true, + onOpen: function(tab) { + assert.ok(isPrivate(tab), 'new tab is private'); + close(window).then(done).then(null, assert.fail); + } + }) + }).then(null, assert.fail); +} + +exports.testOpenTabWithNonPrivateActiveWindowWithIsPrivateOptionFalse = function(assert, done) { + let window = getMostRecentBrowserWindow().OpenBrowserWindow({ private: false }); + + windowPromise(window, 'load').then(focus).then(function (window) { + assert.equal(isPrivate(window), false, 'new window is not private'); + + tabs.open({ + url: 'about:blank', + isPrivate: false, + onOpen: function(tab) { + assert.equal(isPrivate(tab), false, 'new tab is not private'); + close(window).then(done).then(null, assert.fail); + } + }) + }).then(null, assert.fail); +} diff --git a/addon-sdk/source/test/addons/private-browsing-supported/test-windows.js b/addon-sdk/source/test/addons/private-browsing-supported/test-windows.js new file mode 100644 index 000000000..ce4e69cae --- /dev/null +++ b/addon-sdk/source/test/addons/private-browsing-supported/test-windows.js @@ -0,0 +1,240 @@ +/* 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/. */ +'use strict'; + +const { Cc, Ci } = require('chrome'); +const { isPrivate } = require('sdk/private-browsing'); +const { isWindowPBSupported } = require('sdk/private-browsing/utils'); +const { onFocus, getMostRecentWindow, getWindowTitle, getInnerId, + getFrames, windows, open: openWindow, isWindowPrivate } = require('sdk/window/utils'); +const { open, close, focus, promise } = require('sdk/window/helpers'); +const { browserWindows } = require("sdk/windows"); +const winUtils = require("sdk/deprecated/window-utils"); +const { fromIterator: toArray } = require('sdk/util/array'); +const tabs = require('sdk/tabs'); +const { cleanUI } = require('sdk/test/utils'); + +const WM = Cc['@mozilla.org/appshell/window-mediator;1'].getService(Ci.nsIWindowMediator); + +const BROWSER = 'chrome://browser/content/browser.xul'; + +function makeEmptyBrowserWindow(options) { + options = options || {}; + return open(BROWSER, { + features: { + chrome: true, + private: !!options.private + } + }).then(focus); +} + +exports.testWindowTrackerIgnoresPrivateWindows = function(assert, done) { + var myNonPrivateWindowId, myPrivateWindowId; + var privateWindowClosed = false; + var privateWindowOpened = false; + var trackedWindowIds = []; + + let wt = winUtils.WindowTracker({ + onTrack: function(window) { + let id = getInnerId(window); + trackedWindowIds.push(id); + }, + onUntrack: function(window) { + let id = getInnerId(window); + if (id === myPrivateWindowId) { + privateWindowClosed = true; + } + + if (id === myNonPrivateWindowId) { + assert.equal(privateWindowClosed, true, 'private window was untracked'); + wt.unload(); + done(); + } + } + }); + + // make a new private window + makeEmptyBrowserWindow({ private: true }).then(function(window) { + myPrivateWindowId = getInnerId(window); + + assert.ok(trackedWindowIds.indexOf(myPrivateWindowId) >= 0, 'private window was tracked'); + assert.equal(isPrivate(window), isWindowPBSupported, 'private window isPrivate'); + assert.equal(isWindowPrivate(window), isWindowPBSupported); + assert.ok(getFrames(window).length > 1, 'there are frames for private window'); + assert.equal(getWindowTitle(window), window.document.title, + 'getWindowTitle works'); + + return close(window).then(function() { + assert.pass('private window was closed'); + + return makeEmptyBrowserWindow().then(function(window) { + myNonPrivateWindowId = getInnerId(window); + assert.notEqual(myPrivateWindowId, myNonPrivateWindowId, 'non private window was opened'); + return close(window); + }); + }); + }).then(null, assert.fail); +}; + +// Test setting activeWIndow and onFocus for private windows +exports.testSettingActiveWindowDoesNotIgnorePrivateWindow = function(assert, done) { + let browserWindow = WM.getMostRecentWindow("navigator:browser"); + let testSteps; + + assert.equal(winUtils.activeBrowserWindow, browserWindow, + "Browser window is the active browser window."); + assert.ok(!isPrivate(browserWindow), "Browser window is not private."); + + // make a new private window + makeEmptyBrowserWindow({ + private: true + }).then(function(window) { + let continueAfterFocus = window => onFocus(window).then(nextTest); + + // PWPB case + if (isWindowPBSupported) { + assert.ok(isPrivate(window), "window is private"); + assert.notStrictEqual(winUtils.activeBrowserWindow, browserWindow); + } + // Global case + else { + assert.ok(!isPrivate(window), "window is not private"); + } + + assert.strictEqual(winUtils.activeBrowserWindow, window, + "Correct active browser window pb supported"); + assert.notStrictEqual(browserWindow, window, + "The window is not the old browser window"); + + testSteps = [ + function() { + // test setting a non private window + continueAfterFocus(winUtils.activeWindow = browserWindow); + }, + function() { + assert.strictEqual(winUtils.activeWindow, browserWindow, + "Correct active window [1]"); + assert.strictEqual(winUtils.activeBrowserWindow, browserWindow, + "Correct active browser window [1]"); + + // test focus(window) + focus(window).then(nextTest); + }, + function(w) { + assert.strictEqual(w, window, 'require("sdk/window/helpers").focus on window works'); + assert.strictEqual(winUtils.activeBrowserWindow, window, + "Correct active browser window [2]"); + assert.strictEqual(winUtils.activeWindow, window, + "Correct active window [2]"); + + // test setting a private window + continueAfterFocus(winUtils.activeWindow = window); + }, + function() { + assert.strictEqual(winUtils.activeBrowserWindow, window, + "Correct active browser window [3]"); + assert.strictEqual(winUtils.activeWindow, window, + "Correct active window [3]"); + + // just to get back to original state + continueAfterFocus(winUtils.activeWindow = browserWindow); + }, + function() { + assert.strictEqual(winUtils.activeBrowserWindow, browserWindow, + "Correct active browser window when pb mode is supported [4]"); + assert.strictEqual(winUtils.activeWindow, browserWindow, + "Correct active window when pb mode is supported [4]"); + + close(window).then(done).then(null, assert.fail); + } + ]; + + function nextTest() { + let args = arguments; + if (testSteps.length) { + require('sdk/timers').setTimeout(function() { + (testSteps.shift()).apply(null, args); + }, 0); + } + } + nextTest(); + }); +}; + +exports.testActiveWindowDoesNotIgnorePrivateWindow = function*(assert) { + // make a new private window + let window = yield makeEmptyBrowserWindow({ + private: true + }); + + // PWPB case + if (isWindowPBSupported) { + assert.equal(isPrivate(winUtils.activeWindow), true, + "active window is private"); + assert.equal(isPrivate(winUtils.activeBrowserWindow), true, + "active browser window is private"); + assert.ok(isWindowPrivate(window), "window is private"); + assert.ok(isPrivate(window), "window is private"); + + // pb mode is supported + assert.ok( + isWindowPrivate(winUtils.activeWindow), + "active window is private when pb mode is supported"); + assert.ok( + isWindowPrivate(winUtils.activeBrowserWindow), + "active browser window is private when pb mode is supported"); + assert.ok(isPrivate(winUtils.activeWindow), + "active window is private when pb mode is supported"); + assert.ok(isPrivate(winUtils.activeBrowserWindow), + "active browser window is private when pb mode is supported"); + } + + yield cleanUI(); +} + +exports.testWindowIteratorIgnoresPrivateWindows = function*(assert) { + // make a new private window + let window = yield makeEmptyBrowserWindow({ + private: true + }); + + assert.equal(isWindowPrivate(window), isWindowPBSupported); + assert.ok(toArray(winUtils.windowIterator()).indexOf(window) > -1, + "window is in windowIterator()"); + + yield cleanUI(); +}; + +// test that it is not possible to find a private window in +// windows module's iterator +exports.testWindowIteratorPrivateDefault = function(assert, done) { + // there should only be one window open here, if not give us the + // the urls + if (browserWindows.length > 1) { + for (let tab of tabs) { + assert.fail("TAB URL: " + tab.url); + } + } + else { + assert.equal(browserWindows.length, 1, 'only one window open'); + } + + open('chrome://browser/content/browser.xul', { + features: { + private: true, + chrome: true + } + }).then(focus).then(function(window) { + // test that there is a private window opened + assert.equal(isPrivate(window), isWindowPBSupported, 'there is a private window open'); + assert.equal(isPrivate(winUtils.activeWindow), isWindowPBSupported); + assert.equal(isPrivate(getMostRecentWindow()), isWindowPBSupported); + assert.equal(isPrivate(browserWindows.activeWindow), isWindowPBSupported); + + assert.equal(browserWindows.length, 2, '2 windows open'); + assert.equal(windows(null, { includePrivate: true }).length, 2); + + return close(window); + }).then(done).then(null, assert.fail); +}; diff --git a/addon-sdk/source/test/addons/remote/main.js b/addon-sdk/source/test/addons/remote/main.js new file mode 100644 index 000000000..cea27af9b --- /dev/null +++ b/addon-sdk/source/test/addons/remote/main.js @@ -0,0 +1,578 @@ +/* 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/. */ + +"use strict"; + +const LOCAL_URI = "about:robots"; +const REMOTE_URI = "data:text/html;charset=utf-8,remote"; + +const { Cu } = require('chrome'); +const { Loader } = require('sdk/test/loader'); +const { getTabs, openTab, closeTab, setTabURL, getBrowserForTab, getURI } = require('sdk/tabs/utils'); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); +const { cleanUI } = require("sdk/test/utils"); +const { setTimeout } = require("sdk/timers"); +const { promiseEvent, promiseDOMEvent, promiseEventOnItemAndContainer, + waitForProcesses, getChildFrameCount, isE10S } = require("./utils"); +const { after } = require('sdk/test/utils'); +const { processID } = require('sdk/system/runtime'); + +const { set } = require('sdk/preferences/service'); +// The hidden preload browser messes up our frame counts +set('browser.newtab.preload', false); + +function promiseTabFrameAttach(frames) { + return new Promise(resolve => { + let listener = function(frame, ...args) { + if (!frame.isTab) + return; + frames.off("attach", listener); + resolve([frame, ...args]); + } + + frames.on("attach", listener); + }); +} + +// Check that we see a process stop and start +exports["test process restart"] = function*(assert) { + if (!isE10S) { + assert.pass("Skipping test in non-e10s mode"); + return; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + let tab = tabs[0]; + let browser = getBrowserForTab(tab); + + let loader = new Loader(module); + let { processes, frames } = yield waitForProcesses(loader); + + let remoteProcess = Array.filter(processes, p => p.isRemote)[0]; + let localProcess = Array.filter(processes, p => !p.isRemote)[0]; + let remoteFrame = Array.filter(frames, f => f.process == remoteProcess)[0]; + + // Switch the remote tab to a local URI which should kill the remote process + + let frameDetach = promiseEventOnItemAndContainer(assert, remoteFrame, frames, 'detach'); + let frameAttach = promiseTabFrameAttach(frames); + let processDetach = promiseEventOnItemAndContainer(assert, remoteProcess, processes, 'detach'); + let browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, LOCAL_URI); + // The load should kill the remote frame + yield frameDetach; + // And create a new frame in the local process + let [newFrame] = yield frameAttach; + assert.equal(newFrame.process, localProcess, "New frame should be in the local process"); + // And kill the process + yield processDetach; + yield browserLoad; + + frameDetach = promiseEventOnItemAndContainer(assert, newFrame, frames, 'detach'); + let processAttach = promiseEvent(processes, 'attach'); + frameAttach = promiseTabFrameAttach(frames); + browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, REMOTE_URI); + // The load should kill the remote frame + yield frameDetach; + // And create a new remote process + [remoteProcess] = yield processAttach; + assert.ok(remoteProcess.isRemote, "Process should be remote"); + // And create a new frame in the remote process + [newFrame] = yield frameAttach; + assert.equal(newFrame.process, remoteProcess, "New frame should be in the remote process"); + yield browserLoad; + + browserLoad = promiseDOMEvent(browser, "load", true); + setTabURL(tab, "about:blank"); + yield browserLoad; + + loader.unload(); +}; + +// Test that we find the right number of processes and that messaging between +// them works and none of the streams cross +exports["test process list"] = function*(assert) { + let loader = new Loader(module); + let { processes } = loader.require('sdk/remote/parent'); + + let processCount = 0; + processes.forEvery(processes => processCount++); + + yield waitForProcesses(loader); + + let remoteProcesses = Array.filter(processes, process => process.isRemote); + let localProcesses = Array.filter(processes, process => !process.isRemote); + + assert.equal(localProcesses.length, 1, "Should always be one process"); + + if (isE10S) { + assert.equal(remoteProcesses.length, 1, "Should be one remote process"); + } + else { + assert.equal(remoteProcesses.length, 0, "Should be no remote processes"); + } + + assert.equal(processCount, processes.length, "Should have seen all processes"); + + processCount = 0; + processes.forEvery(process => processCount++); + + assert.equal(processCount, processes.length, "forEvery should send existing processes to the listener"); + + localProcesses[0].port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "local", "Should not have seen a pong from the local process with the wrong key"); + }); + + if (isE10S) { + remoteProcesses[0].port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "remote", "Should not have seen a pong from the remote process with the wrong key"); + }); + } + + let promise = promiseEventOnItemAndContainer(assert, localProcesses[0].port, processes.port, 'sdk/test/pong', localProcesses[0]); + localProcesses[0].port.emit('sdk/test/ping', "local"); + + let reply = yield promise; + assert.equal(reply[0], "local", "Saw the process reply with the right key"); + + if (isE10S) { + promise = promiseEventOnItemAndContainer(assert, remoteProcesses[0].port, processes.port, 'sdk/test/pong', remoteProcesses[0]); + remoteProcesses[0].port.emit('sdk/test/ping', "remote"); + + reply = yield promise; + assert.equal(reply[0], "remote", "Saw the process reply with the right key"); + + assert.notEqual(localProcesses[0], remoteProcesses[0], "Processes should be different"); + } + + loader.unload(); +}; + +// Test that the frame lists are kept up to date +exports["test frame list"] = function*(assert) { + function browserFrames(list) { + return Array.filter(list, b => b.isTab).length; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + + let loader = new Loader(module); + let { processes, frames } = yield waitForProcesses(loader); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + let promise = promiseTabFrameAttach(frames); + let tab1 = openTab(window, LOCAL_URI); + let [frame1] = yield promise; + assert.ok(!!frame1, "Should have seen the new frame"); + assert.ok(!frame1.process.isRemote, "Frame should not be remote"); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + promise = promiseTabFrameAttach(frames); + let tab2 = openTab(window, REMOTE_URI); + let [frame2] = yield promise; + assert.ok(!!frame2, "Should have seen the new frame"); + if (isE10S) + assert.ok(frame2.process.isRemote, "Frame should be remote"); + else + assert.ok(!frame2.process.isRemote, "Frame should not be remote"); + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + frames.port.emit('sdk/test/ping') + yield new Promise(resolve => { + let count = 0; + let listener = () => { + console.log("Saw pong"); + count++; + if (count == frames.length) { + frames.port.off('sdk/test/pong', listener); + resolve(); + } + }; + frames.port.on('sdk/test/pong', listener); + }); + + let badListener = () => { + assert.fail("Should not have seen a response through this frame"); + } + frame1.port.on('sdk/test/pong', badListener); + frame2.port.emit('sdk/test/ping', 'b'); + let [key] = yield promiseEventOnItemAndContainer(assert, frame2.port, frames.port, 'sdk/test/pong', frame2); + assert.equal(key, 'b', "Should have seen the right response"); + frame1.port.off('sdk/test/pong', badListener); + + frame2.port.on('sdk/test/pong', badListener); + frame1.port.emit('sdk/test/ping', 'b'); + [key] = yield promiseEventOnItemAndContainer(assert, frame1.port, frames.port, 'sdk/test/pong', frame1); + assert.equal(key, 'b', "Should have seen the right response"); + frame2.port.off('sdk/test/pong', badListener); + + promise = promiseEventOnItemAndContainer(assert, frame1, frames, 'detach'); + closeTab(tab1); + yield promise; + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + promise = promiseEventOnItemAndContainer(assert, frame2, frames, 'detach'); + closeTab(tab2); + yield promise; + + assert.equal(browserFrames(frames), getTabs(window).length, "Should be the right number of browser frames."); + assert.equal((yield getChildFrameCount(processes)), frames.length, "Child processes should have the right number of frames"); + + loader.unload(); +}; + +// Test that multiple loaders get their own loaders in the child and messages +// don't cross. Unload should work +exports["test new loader"] = function*(assert) { + let loader1 = new Loader(module); + let { processes: processes1 } = yield waitForProcesses(loader1); + + let loader2 = new Loader(module); + let { processes: processes2 } = yield waitForProcesses(loader2); + + let process1 = [...processes1][0]; + let process2 = [...processes2][0]; + + process1.port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "a", "Should have seen the right pong"); + }); + + process2.port.on('sdk/test/pong', (process, key) => { + assert.equal(key, "b", "Should have seen the right pong"); + }); + + process1.port.emit('sdk/test/ping', 'a'); + yield promiseEvent(process1.port, 'sdk/test/pong'); + + process2.port.emit('sdk/test/ping', 'b'); + yield promiseEvent(process2.port, 'sdk/test/pong'); + + loader1.unload(); + + process2.port.emit('sdk/test/ping', 'b'); + yield promiseEvent(process2.port, 'sdk/test/pong'); + + loader2.unload(); +}; + +// Test that unloading the loader unloads the child instances +exports["test unload"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:,<html/>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + promise = promiseDOMEvent(browser, 'hashchange'); + frame.port.emit('sdk/test/testunload'); + loader.unload("shutdown"); + yield promise; + + let hash = getURI(tab).replace(/.*#/, ""); + assert.equal(hash, "unloaded:shutdown", "Saw the correct hash change.") + + closeTab(tab); +} + +// Test that unloading the loader causes the child to see frame detach events +exports["test frame detach on unload"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:,<html/>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + promise = promiseDOMEvent(browser, 'hashchange'); + frame.port.emit('sdk/test/testdetachonunload'); + loader.unload("shutdown"); + yield promise; + + let hash = getURI(tab).replace(/.*#/, ""); + assert.equal(hash, "unloaded", "Saw the correct hash change.") + + closeTab(tab); +} + +// Test that DOM event listener on the frame object works +exports["test frame event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframeevent'); + promise = Promise.all([ + promiseEvent(frame.port, 'sdk/test/sawreply'), + promiseEvent(frame.port, 'sdk/test/eventsent') + ]); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + frame.port.emit('sdk/test/unregisterframeevent'); + promise = promiseEvent(frame.port, 'sdk/test/eventsent'); + frame.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader.unload(); +} + +// Test that DOM event listener on the frames object works +exports["test frames event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = promiseTabFrameAttach(frames); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + assert.ok(!!frame, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframesevent'); + promise = Promise.all([ + promiseEvent(frame.port, 'sdk/test/sawreply'), + promiseEvent(frame.port, 'sdk/test/eventsent') + ]); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + frame.port.emit('sdk/test/unregisterframesevent'); + promise = promiseEvent(frame.port, 'sdk/test/eventsent'); + frame.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader.unload(); +} + +// Test that unloading unregisters frame DOM events +exports["test unload removes frame event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let loader2 = new Loader(module); + let { frames: frames2 } = yield waitForProcesses(loader2); + + let promise = promiseTabFrameAttach(frames); + let promise2 = promiseTabFrameAttach(frames2); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + let [frame2] = yield promise2; + assert.ok(!!frame && !!frame2, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframeevent'); + promise = Promise.all([ + promiseEvent(frame2.port, 'sdk/test/sawreply'), + promiseEvent(frame2.port, 'sdk/test/eventsent') + ]); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + loader.unload(); + + promise = promiseEvent(frame2.port, 'sdk/test/eventsent'); + frame2.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader2.unload(); +} + +// Test that unloading unregisters frames DOM events +exports["test unload removes frames event listeners"] = function*(assert) { + let window = getMostRecentBrowserWindow(); + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let loader2 = new Loader(module); + let { frames: frames2 } = yield waitForProcesses(loader2); + + let promise = promiseTabFrameAttach(frames); + let promise2 = promiseTabFrameAttach(frames2); + let tab = openTab(window, "data:text/html,<html></html>"); + let browser = getBrowserForTab(tab); + yield promiseDOMEvent(browser, "load", true); + let [frame] = yield promise; + let [frame2] = yield promise2; + assert.ok(!!frame && !!frame2, "Should have seen the new frame"); + + frame.port.emit('sdk/test/registerframesevent'); + promise = Promise.all([ + promiseEvent(frame2.port, 'sdk/test/sawreply'), + promiseEvent(frame2.port, 'sdk/test/eventsent') + ]); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + loader.unload(); + + promise = promiseEvent(frame2.port, 'sdk/test/eventsent'); + frame2.port.on('sdk/test/sawreply', () => { + assert.fail("Should not have seen the event listener reply"); + }); + + frame2.port.emit('sdk/test/sendevent'); + yield promise; + + closeTab(tab); + loader2.unload(); +} + +// Check that the child frame has the right properties +exports["test frame properties"] = function*(assert) { + let loader = new Loader(module); + let { frames } = yield waitForProcesses(loader); + + let promise = new Promise(resolve => { + let count = frames.length; + let listener = (frame, properties) => { + assert.equal(properties.isTab, frame.isTab, + "Child frame should have the same isTab property"); + + if (--count == 0) { + frames.port.off('sdk/test/replyproperties', listener); + resolve(); + } + } + + frames.port.on('sdk/test/replyproperties', listener); + }) + + frames.port.emit('sdk/test/checkproperties'); + yield promise; + + loader.unload(); +} + +// Check that non-remote processes have the same process ID and remote processes +// have different IDs +exports["test processID"] = function*(assert) { + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + for (let process of processes) { + process.port.emit('sdk/test/getprocessid'); + let [p, ID] = yield promiseEvent(process.port, 'sdk/test/processid'); + if (process.isRemote) { + assert.notEqual(ID, processID, "Remote processes should have a different process ID"); + } + else { + assert.equal(ID, processID, "Remote processes should have the same process ID"); + } + } + + loader.unload(); +} + +// Check that sdk/remote/parent and sdk/remote/child can only be loaded in the +// appropriate loaders +exports["test cannot load in wrong loader"] = function*(assert) { + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + try { + require('sdk/remote/child'); + assert.fail("Should not have been able to load sdk/remote/child"); + } + catch (e) { + assert.ok(/Cannot load sdk\/remote\/child in a main process loader/.test(e), + "Should have seen the right exception."); + } + + for (let process of processes) { + processes.port.emit('sdk/test/parentload'); + let [_, isChildLoader, loaded, message] = yield promiseEvent(processes.port, 'sdk/test/parentload'); + assert.ok(isChildLoader, "Process should see itself in a child loader."); + assert.ok(!loaded, "Process couldn't load sdk/remote/parent."); + assert.ok(/Cannot load sdk\/remote\/parent in a child loader/.test(message), + "Should have seen the right exception."); + } + + loader.unload(); +}; + +exports["test send cpow"] = function*(assert) { + if (!isE10S) { + assert.pass("Skipping test in non-e10s mode"); + return; + } + + let window = getMostRecentBrowserWindow(); + + let tabs = getTabs(window); + assert.equal(tabs.length, 1, "Should have just the one tab to start with"); + let tab = tabs[0]; + let browser = getBrowserForTab(tab); + + assert.ok(Cu.isCrossProcessWrapper(browser.contentWindow), + "Should have a CPOW for the browser content window"); + + let loader = new Loader(module); + let { processes } = yield waitForProcesses(loader); + + processes.port.emitCPOW('sdk/test/cpow', ['foobar'], { window: browser.contentWindow }); + let [process, arg, id] = yield promiseEvent(processes.port, 'sdk/test/cpow'); + + assert.ok(process.isRemote, "Response should come from the remote process"); + assert.equal(arg, "foobar", "Argument should have passed through"); + assert.equal(id, browser.outerWindowID, "Should have got the ID from the child"); +}; + +after(exports, function*(name, assert) { + yield cleanUI(); +}); + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/remote/package.json b/addon-sdk/source/test/addons/remote/package.json new file mode 100644 index 000000000..b147b6fda --- /dev/null +++ b/addon-sdk/source/test/addons/remote/package.json @@ -0,0 +1,8 @@ +{ + "name": "remote", + "title": "remote", + "id": "remote@jetpack", + "description": "Run remote tests", + "version": "1.0.0", + "main": "main.js" +} diff --git a/addon-sdk/source/test/addons/remote/remote-module.js b/addon-sdk/source/test/addons/remote/remote-module.js new file mode 100644 index 000000000..cedf005a9 --- /dev/null +++ b/addon-sdk/source/test/addons/remote/remote-module.js @@ -0,0 +1,129 @@ +/* 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/. */ + +const { when } = require('sdk/system/unload'); +const { process, frames } = require('sdk/remote/child'); +const { loaderID } = require('@loader/options'); +const { processID } = require('sdk/system/runtime'); +const system = require('sdk/system/events'); +const { Cu } = require('chrome'); +const { isChildLoader } = require('sdk/remote/core'); +const { getOuterId } = require('sdk/window/utils'); + +function log(str) { + console.log("remote[" + loaderID + "][" + processID + "]: " + str); +} + +log("module loaded"); + +process.port.emit('sdk/test/load'); + +process.port.on('sdk/test/ping', (process, key) => { + log("received process ping"); + process.port.emit('sdk/test/pong', key); +}); + +var frameCount = 0; +frames.forEvery(frame => { + frameCount++; + frame.on('detach', () => { + frameCount--; + }); + + frame.port.on('sdk/test/ping', (frame, key) => { + log("received frame ping"); + frame.port.emit('sdk/test/pong', key); + }); +}); + +frames.port.on('sdk/test/checkproperties', frame => { + frame.port.emit('sdk/test/replyproperties', { + isTab: frame.isTab + }); +}); + +process.port.on('sdk/test/count', () => { + log("received count ping"); + process.port.emit('sdk/test/count', frameCount); +}); + +process.port.on('sdk/test/getprocessid', () => { + process.port.emit('sdk/test/processid', processID); +}); + +frames.port.on('sdk/test/testunload', (frame) => { + // Cache the content since the frame will have been destroyed by the time + // we see the unload event. + let content = frame.content; + when((reason) => { + content.location = "#unloaded:" + reason; + }); +}); + +frames.port.on('sdk/test/testdetachonunload', (frame) => { + let content = frame.content; + frame.on('detach', () => { + console.log("Detach from " + frame.content.location); + frame.content.location = "#unloaded"; + }); +}); + +frames.port.on('sdk/test/sendevent', (frame) => { + let doc = frame.content.document; + + let listener = () => { + frame.port.emit('sdk/test/sawreply'); + } + + system.on("Test:Reply", listener); + let event = new frame.content.CustomEvent("Test:Event"); + doc.dispatchEvent(event); + system.off("Test:Reply", listener); + frame.port.emit('sdk/test/eventsent'); +}); + +process.port.on('sdk/test/parentload', () => { + let loaded = false; + let message = ""; + try { + require('sdk/remote/parent'); + loaded = true; + } + catch (e) { + message = "" + e; + } + + process.port.emit('sdk/test/parentload', + isChildLoader, + loaded, + message + ) +}); + +function listener(event) { + // Use the raw observer service here since it will be usable even if the + // loader has unloaded + let { Services } = Cu.import("resource://gre/modules/Services.jsm", {}); + Services.obs.notifyObservers(null, "Test:Reply", ""); +} + +frames.port.on('sdk/test/registerframesevent', (frame) => { + frames.addEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/unregisterframesevent', (frame) => { + frames.removeEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/registerframeevent', (frame) => { + frame.addEventListener("Test:Event", listener, true); +}); + +frames.port.on('sdk/test/unregisterframeevent', (frame) => { + frame.removeEventListener("Test:Event", listener, true); +}); + +process.port.on('sdk/test/cpow', (process, arg, cpows) => { + process.port.emit('sdk/test/cpow', arg, getOuterId(cpows.window)); +}); diff --git a/addon-sdk/source/test/addons/remote/utils.js b/addon-sdk/source/test/addons/remote/utils.js new file mode 100644 index 000000000..f30f4f3a4 --- /dev/null +++ b/addon-sdk/source/test/addons/remote/utils.js @@ -0,0 +1,110 @@ +/* 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/. */ + +"use strict"; + +const { Cu } = require('chrome'); +const { Task: { async } } = Cu.import('resource://gre/modules/Task.jsm', {}); +const { getMostRecentBrowserWindow } = require('sdk/window/utils'); + +const REMOTE_MODULE = "./remote-module"; + +function promiseEvent(emitter, event) { + console.log("Waiting for " + event); + return new Promise(resolve => { + emitter.once(event, (...args) => { + console.log("Saw " + event); + resolve(args); + }); + }); +} +exports.promiseEvent = promiseEvent; + +function promiseDOMEvent(target, event, isCapturing = false) { + console.log("Waiting for " + event); + return new Promise(resolve => { + let listener = (event) => { + target.removeEventListener(event, listener, isCapturing); + resolve(event); + }; + target.addEventListener(event, listener, isCapturing); + }) +} +exports.promiseDOMEvent = promiseDOMEvent; + +const promiseEventOnItemAndContainer = async(function*(assert, itemport, container, event, item = itemport) { + let itemEvent = promiseEvent(itemport, event); + let containerEvent = promiseEvent(container, event); + + let itemArgs = yield itemEvent; + let containerArgs = yield containerEvent; + + assert.equal(containerArgs[0], item, "Should have seen a container event for the right item"); + assert.equal(JSON.stringify(itemArgs), JSON.stringify(containerArgs), "Arguments should have matched"); + + // Strip off the item from the returned arguments + return itemArgs.slice(1); +}); +exports.promiseEventOnItemAndContainer = promiseEventOnItemAndContainer; + +const waitForProcesses = async(function*(loader) { + console.log("Starting remote"); + let { processes, frames, remoteRequire } = loader.require('sdk/remote/parent'); + remoteRequire(REMOTE_MODULE, module); + + let events = []; + + // In e10s we should expect to see two processes + let expectedCount = isE10S ? 2 : 1; + + yield new Promise(resolve => { + let count = 0; + + // Wait for a process to be detected + let listener = process => { + console.log("Saw a process attach"); + // Wait for the remote module to load in this process + process.port.once('sdk/test/load', () => { + console.log("Saw a remote module load"); + count++; + if (count == expectedCount) { + processes.off('attach', listener); + resolve(); + } + }); + } + processes.on('attach', listener); + }); + + console.log("Remote ready"); + return { processes, frames, remoteRequire }; +}); +exports.waitForProcesses = waitForProcesses; + +// Counts the frames in all the child processes +const getChildFrameCount = async(function*(processes) { + let frameCount = 0; + + for (let process of processes) { + process.port.emit('sdk/test/count'); + let [p, count] = yield promiseEvent(process.port, 'sdk/test/count'); + frameCount += count; + } + + return frameCount; +}); +exports.getChildFrameCount = getChildFrameCount; + +const mainWindow = getMostRecentBrowserWindow(); +const isE10S = mainWindow.gMultiProcessBrowser; +exports.isE10S = isE10S; + +if (isE10S) { + console.log("Testing in E10S mode"); + // We expect a child process to already be present, make sure that is the case + mainWindow.XULBrowserWindow.forceInitialBrowserRemote(); +} +else { + console.log("Testing in non-E10S mode"); +} diff --git a/addon-sdk/source/test/addons/require/list.js b/addon-sdk/source/test/addons/require/list.js new file mode 100644 index 000000000..9d2566a87 --- /dev/null +++ b/addon-sdk/source/test/addons/require/list.js @@ -0,0 +1,6 @@ +/* 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/. */ +"use strict"; + +exports.local = true; diff --git a/addon-sdk/source/test/addons/require/main.js b/addon-sdk/source/test/addons/require/main.js new file mode 100644 index 000000000..53391f08b --- /dev/null +++ b/addon-sdk/source/test/addons/require/main.js @@ -0,0 +1,87 @@ +/* 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/. */ +"use strict"; + +var { isNative } = require("@loader/options"); + +exports["test local vs sdk module"] = function (assert) { + assert.notEqual(require("list"), + require("sdk/util/list"), + "Local module takes the priority over sdk modules"); + assert.ok(require("list").local, + "this module is really the local one"); +} + +if (!isNative) { + exports["test 3rd party vs sdk module"] = function (assert) { + // We are testing with a 3rd party package called `tabs` with 3 modules + // main, page-mod and third-party + + // the only way to require 3rd party package modules are to use absolute paths + // require("tabs/main"), require("tabs/page-mod"), + // require("tabs/third-party") and also require("tabs") which will refer + // to tabs's main package module. + + // So require(page-mod) shouldn't map the 3rd party + assert.equal(require("page-mod"), + require("sdk/page-mod"), + "Third party modules don't overload sdk modules"); + + assert.ok(require("page-mod").PageMod, + "page-mod module is really the sdk one"); + + assert.equal(require("tabs/page-mod").id, "page-mod", + "tabs/page-mod is the 3rd party"); + + // But require(tabs) will map to 3rd party main module + // *and* overload the sdk module + // and also any local module with the same name + assert.equal(require("tabs").id, "tabs-main", + "Third party main module overload sdk modules"); + + assert.equal(require("tabs"), + require("tabs/main"), + "require(tabs) maps to require(tabs/main)"); + + // So that you have to use relative path to ensure getting the local module + assert.equal(require("./tabs").id, + "local-tabs", + "require(./tabs) maps to the local module"); + + // It should still be possible to require sdk module with absolute path + assert.ok(require("sdk/tabs").open, + "We can bypass this overloading with absolute path to sdk modules"); + + assert.equal(require("sdk/tabs"), + require("addon-kit/tabs"), + "Old and new layout both work"); + } +} + +// /!\ Always use distinct module for each test. +// Otherwise, the linker can correctly parse and allow the first usage of it +// but still silently fail on the second. + +exports.testRelativeRequire = function (assert) { + assert.equal(require('./same-folder').id, 'same-folder'); +} + +exports.testRelativeSubFolderRequire = function (assert) { + assert.equal(require('./sub-folder/module').id, 'sub-folder'); +} + +exports.testMultipleRequirePerLine = function (assert) { + var a=require('./multiple/a'),b=require('./multiple/b'); + assert.equal(a.id, 'a'); + assert.equal(b.id, 'b'); +} + +exports.testSDKRequire = function (assert) { + assert.deepEqual(Object.keys(require('sdk/page-worker')), ['Page']); + if (!isNative) { + assert.equal(require('page-worker'), require('sdk/page-worker')); + } +} + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/require/multiple/a.js b/addon-sdk/source/test/addons/require/multiple/a.js new file mode 100644 index 000000000..737cce2b0 --- /dev/null +++ b/addon-sdk/source/test/addons/require/multiple/a.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = 'a'; diff --git a/addon-sdk/source/test/addons/require/multiple/b.js b/addon-sdk/source/test/addons/require/multiple/b.js new file mode 100644 index 000000000..382a7d22c --- /dev/null +++ b/addon-sdk/source/test/addons/require/multiple/b.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = 'b'; diff --git a/addon-sdk/source/test/addons/require/package.json b/addon-sdk/source/test/addons/require/package.json new file mode 100644 index 000000000..828853f56 --- /dev/null +++ b/addon-sdk/source/test/addons/require/package.json @@ -0,0 +1,8 @@ +{ + "id": "test-require@jetpack", + "name": "test-require", + "packages": "packages", + "ignore-deprecated-path": true, + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/require/packages/tabs/main.js b/addon-sdk/source/test/addons/require/packages/tabs/main.js new file mode 100644 index 000000000..871c9e4de --- /dev/null +++ b/addon-sdk/source/test/addons/require/packages/tabs/main.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = "tabs-main"; diff --git a/addon-sdk/source/test/addons/require/packages/tabs/package.json b/addon-sdk/source/test/addons/require/packages/tabs/package.json new file mode 100644 index 000000000..2446c2e53 --- /dev/null +++ b/addon-sdk/source/test/addons/require/packages/tabs/package.json @@ -0,0 +1,3 @@ +{ + "id": "test-panel" +}
\ No newline at end of file diff --git a/addon-sdk/source/test/addons/require/packages/tabs/page-mod.js b/addon-sdk/source/test/addons/require/packages/tabs/page-mod.js new file mode 100644 index 000000000..6c90f46c1 --- /dev/null +++ b/addon-sdk/source/test/addons/require/packages/tabs/page-mod.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = "page-mod"; diff --git a/addon-sdk/source/test/addons/require/same-folder.js b/addon-sdk/source/test/addons/require/same-folder.js new file mode 100644 index 000000000..d2f9b017d --- /dev/null +++ b/addon-sdk/source/test/addons/require/same-folder.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = 'same-folder'; diff --git a/addon-sdk/source/test/addons/require/sub-folder/module.js b/addon-sdk/source/test/addons/require/sub-folder/module.js new file mode 100644 index 000000000..8ce8181b2 --- /dev/null +++ b/addon-sdk/source/test/addons/require/sub-folder/module.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = 'sub-folder'; diff --git a/addon-sdk/source/test/addons/require/tabs.js b/addon-sdk/source/test/addons/require/tabs.js new file mode 100644 index 000000000..5a46e63b8 --- /dev/null +++ b/addon-sdk/source/test/addons/require/tabs.js @@ -0,0 +1,5 @@ +/* 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/. */ + +exports.id = "local-tabs"; diff --git a/addon-sdk/source/test/addons/self/data/data.md b/addon-sdk/source/test/addons/self/data/data.md new file mode 100644 index 000000000..2be7c65ae --- /dev/null +++ b/addon-sdk/source/test/addons/self/data/data.md @@ -0,0 +1 @@ +# hello world diff --git a/addon-sdk/source/test/addons/self/main.js b/addon-sdk/source/test/addons/self/main.js new file mode 100644 index 000000000..789f27899 --- /dev/null +++ b/addon-sdk/source/test/addons/self/main.js @@ -0,0 +1,23 @@ +/* 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/. */ +"use strict"; + +const self = require("sdk/self"); + +exports["test self.data.load"] = assert => { + + assert.equal(self.data.load("data.md").trim(), + "# hello world", + "paths work"); + + assert.equal(self.data.load("./data.md").trim(), + "# hello world", + "relative paths work"); +}; + +exports["test self.id"] = assert => { + assert.equal(self.id, "test-self@jetpack", "self.id should be correct."); +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/self/package.json b/addon-sdk/source/test/addons/self/package.json new file mode 100644 index 000000000..fec21298b --- /dev/null +++ b/addon-sdk/source/test/addons/self/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-self@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/simple-prefs-l10n/locale/en.properties b/addon-sdk/source/test/addons/simple-prefs-l10n/locale/en.properties new file mode 100644 index 000000000..ec458f1f7 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-l10n/locale/en.properties @@ -0,0 +1,5 @@ +# 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/. + +somePreference_title=A diff --git a/addon-sdk/source/test/addons/simple-prefs-l10n/main.js b/addon-sdk/source/test/addons/simple-prefs-l10n/main.js new file mode 100644 index 000000000..ce8235d19 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-l10n/main.js @@ -0,0 +1,65 @@ +/* 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/. */ +'use strict'; + +const { Cu } = require('chrome'); +const sp = require('sdk/simple-prefs'); +const app = require('sdk/system/xul-app'); +const self = require('sdk/self'); +const tabs = require('sdk/tabs'); +const { preferencesBranch } = require('sdk/self'); + +const { AddonManager } = Cu.import('resource://gre/modules/AddonManager.jsm', {}); + +// Once Bug 903018 is resolved, just move the application testing to +// module.metadata.engines +// +// This should work in Fennec, needs to be refactored to work, via bug 979645 +if (app.is('Firefox')) { + exports.testAOMLocalization = function(assert, done) { + tabs.open({ + url: 'about:addons', + onReady: function(tab) { + tab.attach({ + contentScriptWhen: 'end', + contentScript: 'function onLoad() {\n' + + 'unsafeWindow.removeEventListener("load", onLoad, false);\n' + + 'AddonManager.getAddonByID("' + self.id + '", function(aAddon) {\n' + + 'unsafeWindow.gViewController.viewObjects.detail.node.addEventListener("ViewChanged", function whenViewChanges() {\n' + + 'unsafeWindow.gViewController.viewObjects.detail.node.removeEventListener("ViewChanged", whenViewChanges, false);\n' + + 'setTimeout(function() {\n' + // TODO: figure out why this is necessary.. + 'self.postMessage({\n' + + 'somePreference: getAttributes(unsafeWindow.document.querySelector("setting[data-jetpack-id=\'' + self.id + '\']"))\n' + + '});\n' + + '}, 250);\n' + + '}, false);\n' + + 'unsafeWindow.gViewController.commands.cmd_showItemDetails.doCommand(aAddon, true);\n' + + '});\n' + + 'function getAttributes(ele) {\n' + + 'if (!ele) return {};\n' + + 'return {\n' + + 'title: ele.getAttribute("title")\n' + + '}\n' + + '}\n' + + '}\n' + + // Wait for the load event ? + 'if (document.readyState == "complete") {\n' + + 'onLoad()\n' + + '} else {\n' + + 'unsafeWindow.addEventListener("load", onLoad, false);\n' + + '}\n', + onMessage: function(msg) { + // test somePreference + assert.equal(msg.somePreference.title, 'A', 'somePreference title is correct'); + tab.close(done); + } + }); + } + }); + } +} else { + exports['test unsupported'] = (assert) => assert.pass('This test is unsupported.'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/simple-prefs-l10n/package.json b/addon-sdk/source/test/addons/simple-prefs-l10n/package.json new file mode 100644 index 000000000..540033cf3 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-l10n/package.json @@ -0,0 +1,10 @@ +{ + "id": "test-simple-prefs-l10n", + "preferences": [{ + "name": "somePreference", + "title": "some-title", + "description": "Some short description for the preference", + "type": "string", + "value": "TEST" + }] +}
\ No newline at end of file diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/application.ini b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/application.ini new file mode 100644 index 000000000..6cec69a16 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/application.ini @@ -0,0 +1,11 @@ +[App] +Vendor=Varma +Name=Test App +Version=1.0 +BuildID=20060101 +Copyright=Copyright (c) 2009 Atul Varma +ID=xulapp@toolness.com + +[Gecko] +MinVersion=1.9.2.0 +MaxVersion=2.0.* diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/bootstrap.js b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/bootstrap.js new file mode 100644 index 000000000..fbb9b5186 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/bootstrap.js @@ -0,0 +1,339 @@ +/* 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/. */ + +// @see http://dxr.mozilla.org/mozilla-central/source/js/src/xpconnect/loader/mozJSComponentLoader.cpp + +'use strict'; + +// IMPORTANT: Avoid adding any initialization tasks here, if you need to do +// something before add-on is loaded consider addon/runner module instead! + +const { classes: Cc, Constructor: CC, interfaces: Ci, utils: Cu, + results: Cr, manager: Cm } = Components; +const ioService = Cc['@mozilla.org/network/io-service;1']. + getService(Ci.nsIIOService); +const resourceHandler = ioService.getProtocolHandler('resource'). + QueryInterface(Ci.nsIResProtocolHandler); +const systemPrincipal = CC('@mozilla.org/systemprincipal;1', 'nsIPrincipal')(); +const scriptLoader = Cc['@mozilla.org/moz/jssubscript-loader;1']. + getService(Ci.mozIJSSubScriptLoader); +const prefService = Cc['@mozilla.org/preferences-service;1']. + getService(Ci.nsIPrefService). + QueryInterface(Ci.nsIPrefBranch); +const appInfo = Cc["@mozilla.org/xre/app-info;1"]. + getService(Ci.nsIXULAppInfo); +const vc = Cc["@mozilla.org/xpcom/version-comparator;1"]. + getService(Ci.nsIVersionComparator); + +const { NetUtil } = Cu.import("resource://gre/modules/NetUtil.jsm"); + +const REASON = [ 'unknown', 'startup', 'shutdown', 'enable', 'disable', + 'install', 'uninstall', 'upgrade', 'downgrade' ]; + +const bind = Function.call.bind(Function.bind); + +var loader = null; +var unload = null; +var cuddlefishSandbox = null; +var nukeTimer = null; + +// Utility function that synchronously reads local resource from the given +// `uri` and returns content string. +function readURI(uri) { + let channel = NetUtil.newChannel({ + uri: NetUtil.newURI(uri, "UTF-8"), + loadUsingSystemPrincipal: true + }); + let stream = channel.open2(); + + let cstream = Cc['@mozilla.org/intl/converter-input-stream;1']. + createInstance(Ci.nsIConverterInputStream); + cstream.init(stream, 'UTF-8', 0, 0); + + let str = {}; + let data = ''; + let read = 0; + do { + read = cstream.readString(0xffffffff, str); + data += str.value; + } while (read != 0); + + cstream.close(); + + return data; +} + +// We don't do anything on install & uninstall yet, but in a future +// we should allow add-ons to cleanup after uninstall. +function install(data, reason) {} +function uninstall(data, reason) {} + +function startup(data, reasonCode) { + try { + let reason = REASON[reasonCode]; + // URI for the root of the XPI file. + // 'jar:' URI if the addon is packed, 'file:' URI otherwise. + // (Used by l10n module in order to fetch `locale` folder) + let rootURI = data.resourceURI.spec; + + // TODO: Maybe we should perform read harness-options.json asynchronously, + // since we can't do anything until 'sessionstore-windows-restored' anyway. + let options = JSON.parse(readURI(rootURI + './harness-options.json')); + + let id = options.jetpackID; + let name = options.name; + + // Clean the metadata + options.metadata[name]['permissions'] = options.metadata[name]['permissions'] || {}; + + // freeze the permissionss + Object.freeze(options.metadata[name]['permissions']); + // freeze the metadata + Object.freeze(options.metadata[name]); + + // Register a new resource 'domain' for this addon which is mapping to + // XPI's `resources` folder. + // Generate the domain name by using jetpack ID, which is the extension ID + // by stripping common characters that doesn't work as a domain name: + let uuidRe = + /^\{([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\}$/; + + let domain = id. + toLowerCase(). + replace(/@/g, '-at-'). + replace(/\./g, '-dot-'). + replace(uuidRe, '$1'); + + let prefixURI = 'resource://' + domain + '/'; + let resourcesURI = ioService.newURI(rootURI + '/resources/', null, null); + resourceHandler.setSubstitution(domain, resourcesURI); + + // Create path to URLs mapping supported by loader. + let paths = { + // Relative modules resolve to add-on package lib + './': prefixURI + name + '/lib/', + './tests/': prefixURI + name + '/tests/', + '': 'resource://gre/modules/commonjs/' + }; + + // Maps addon lib and tests ressource folders for each package + paths = Object.keys(options.metadata).reduce(function(result, name) { + result[name + '/'] = prefixURI + name + '/lib/' + result[name + '/tests/'] = prefixURI + name + '/tests/' + return result; + }, paths); + + // We need to map tests folder when we run sdk tests whose package name + // is stripped + if (name == 'addon-sdk') + paths['tests/'] = prefixURI + name + '/tests/'; + + let useBundledSDK = options['force-use-bundled-sdk']; + if (!useBundledSDK) { + try { + useBundledSDK = prefService.getBoolPref("extensions.addon-sdk.useBundledSDK"); + } + catch (e) { + // Pref doesn't exist, allow using Firefox shipped SDK + } + } + + // Starting with Firefox 21.0a1, we start using modules shipped into firefox + // Still allow using modules from the xpi if the manifest tell us to do so. + // And only try to look for sdk modules in xpi if the xpi actually ship them + if (options['is-sdk-bundled'] && + (vc.compare(appInfo.version, '21.0a1') < 0 || useBundledSDK)) { + // Maps sdk module folders to their resource folder + paths[''] = prefixURI + 'addon-sdk/lib/'; + // test.js is usually found in root commonjs or SDK_ROOT/lib/ folder, + // so that it isn't shipped in the xpi. Keep a copy of it in sdk/ folder + // until we no longer support SDK modules in XPI: + paths['test'] = prefixURI + 'addon-sdk/lib/sdk/test.js'; + } + + // Retrieve list of module folder overloads based on preferences in order to + // eventually used a local modules instead of files shipped into Firefox. + let branch = prefService.getBranch('extensions.modules.' + id + '.path'); + paths = branch.getChildList('', {}).reduce(function (result, name) { + // Allows overloading of any sub folder by replacing . by / in pref name + let path = name.substr(1).split('.').join('/'); + // Only accept overloading folder by ensuring always ending with `/` + if (path) path += '/'; + let fileURI = branch.getCharPref(name); + + // On mobile, file URI has to end with a `/` otherwise, setSubstitution + // takes the parent folder instead. + if (fileURI[fileURI.length-1] !== '/') + fileURI += '/'; + + // Maps the given file:// URI to a resource:// in order to avoid various + // failure that happens with file:// URI and be close to production env + let resourcesURI = ioService.newURI(fileURI, null, null); + let resName = 'extensions.modules.' + domain + '.commonjs.path' + name; + resourceHandler.setSubstitution(resName, resourcesURI); + + result[path] = 'resource://' + resName + '/'; + return result; + }, paths); + + // Make version 2 of the manifest + let manifest = options.manifest; + + // Import `cuddlefish.js` module using a Sandbox and bootstrap loader. + let cuddlefishPath = 'loader/cuddlefish.js'; + let cuddlefishURI = 'resource://gre/modules/commonjs/sdk/' + cuddlefishPath; + if (paths['sdk/']) { // sdk folder has been overloaded + // (from pref, or cuddlefish is still in the xpi) + cuddlefishURI = paths['sdk/'] + cuddlefishPath; + } + else if (paths['']) { // root modules folder has been overloaded + cuddlefishURI = paths[''] + 'sdk/' + cuddlefishPath; + } + + cuddlefishSandbox = loadSandbox(cuddlefishURI); + let cuddlefish = cuddlefishSandbox.exports; + + // Normalize `options.mainPath` so that it looks like one that will come + // in a new version of linker. + let main = options.mainPath; + + unload = cuddlefish.unload; + loader = cuddlefish.Loader({ + paths: paths, + // modules manifest. + manifest: manifest, + + // Add-on ID used by different APIs as a unique identifier. + id: id, + // Add-on name. + name: name, + // Add-on version. + version: options.metadata[name].version, + // Add-on package descriptor. + metadata: options.metadata[name], + // Add-on load reason. + loadReason: reason, + + prefixURI: prefixURI, + // Add-on URI. + rootURI: rootURI, + // options used by system module. + // File to write 'OK' or 'FAIL' (exit code emulation). + resultFile: options.resultFile, + // Arguments passed as --static-args + staticArgs: options.staticArgs, + + // Arguments related to test runner. + modules: { + '@test/options': { + allTestModules: options.allTestModules, + iterations: options.iterations, + filter: options.filter, + profileMemory: options.profileMemory, + stopOnError: options.stopOnError, + verbose: options.verbose, + parseable: options.parseable, + checkMemory: options.check_memory, + } + } + }); + + let module = cuddlefish.Module('sdk/loader/cuddlefish', cuddlefishURI); + let require = cuddlefish.Require(loader, module); + + require('sdk/addon/runner').startup(reason, { + loader: loader, + main: main, + prefsURI: rootURI + 'defaults/preferences/prefs.js' + }); + } catch (error) { + dump('Bootstrap error: ' + + (error.message ? error.message : String(error)) + '\n' + + (error.stack || error.fileName + ': ' + error.lineNumber) + '\n'); + throw error; + } +}; + +function loadSandbox(uri) { + let proto = { + sandboxPrototype: { + loadSandbox: loadSandbox, + ChromeWorker: ChromeWorker + } + }; + let sandbox = Cu.Sandbox(systemPrincipal, proto); + // Create a fake commonjs environnement just to enable loading loader.js + // correctly + sandbox.exports = {}; + sandbox.module = { uri: uri, exports: sandbox.exports }; + sandbox.require = function (id) { + if (id !== "chrome") + throw new Error("Bootstrap sandbox `require` method isn't implemented."); + + return Object.freeze({ Cc: Cc, Ci: Ci, Cu: Cu, Cr: Cr, Cm: Cm, + CC: bind(CC, Components), components: Components, + ChromeWorker: ChromeWorker }); + }; + scriptLoader.loadSubScript(uri, sandbox, 'UTF-8'); + return sandbox; +} + +function unloadSandbox(sandbox) { + if (Cu.getClassName(sandbox, true) == "Sandbox") + Cu.nukeSandbox(sandbox); +} + +function setTimeout(callback, delay) { + let timer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer); + timer.initWithCallback({ notify: callback }, delay, + Ci.nsITimer.TYPE_ONE_SHOT); + return timer; +} + +function shutdown(data, reasonCode) { + let reason = REASON[reasonCode]; + if (loader) { + unload(loader, reason); + unload = null; + + // Don't waste time cleaning up if the application is shutting down + if (reason != "shutdown") { + // Avoid leaking all modules when something goes wrong with one particular + // module. Do not clean it up immediatly in order to allow executing some + // actions on addon disabling. + // We need to keep a reference to the timer, otherwise it is collected + // and won't ever fire. + nukeTimer = setTimeout(nukeModules, 1000); + } + } +}; + +function nukeModules() { + nukeTimer = null; + // module objects store `exports` which comes from sandboxes + // We should avoid keeping link to these object to avoid leaking sandboxes + for (let key in loader.modules) { + delete loader.modules[key]; + } + // Direct links to sandboxes should be removed too + for (let key in loader.sandboxes) { + let sandbox = loader.sandboxes[key]; + delete loader.sandboxes[key]; + // Bug 775067: From FF17 we can kill all CCW from a given sandbox + unloadSandbox(sandbox); + } + loader = null; + + // both `toolkit/loader` and `system/xul-app` are loaded as JSM's via + // `cuddlefish.js`, and needs to be unloaded to avoid memory leaks, when + // the addon is unload. + + unloadSandbox(cuddlefishSandbox.loaderSandbox); + unloadSandbox(cuddlefishSandbox.xulappSandbox); + + // Bug 764840: We need to unload cuddlefish otherwise it will stay alive + // and keep a reference to this compartment. + unloadSandbox(cuddlefishSandbox); + cuddlefishSandbox = null; +} diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/defaults/preferences/prefs.js b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/defaults/preferences/prefs.js new file mode 100644 index 000000000..86f724a53 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/defaults/preferences/prefs.js @@ -0,0 +1,7 @@ +/* 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/. */ + +pref("extensions.test-simple-prefs@jetpack.somePreference", "TEST"); +pref("extensions.test-simple-prefs@jetpack.myInteger", 8); +pref("extensions.test-simple-prefs@jetpack.myHiddenInt", 5); diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/install.rdf b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/install.rdf new file mode 100644 index 000000000..5e3aae0d7 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/install.rdf @@ -0,0 +1,34 @@ +<?xml version="1.0"?> +<!-- 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/. --> + + +<RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:em="http://www.mozilla.org/2004/em-rdf#"> + <Description about="urn:mozilla:install-manifest"> + <em:id>xulapp@toolness.com</em:id> + <em:version>1.0</em:version> + <em:type>2</em:type> + <em:bootstrap>true</em:bootstrap> + <em:unpack>false</em:unpack> + + <!-- Firefox --> + <em:targetApplication> + <Description> + <em:id>{ec8030f7-c20a-464f-9b0e-13a3a9e97384}</em:id> + <em:minVersion>21.0</em:minVersion> + <em:maxVersion>25.0a1</em:maxVersion> + </Description> + </em:targetApplication> + + <!-- Front End MetaData --> + <em:name>Test App</em:name> + <em:description>Harness for tests.</em:description> + <em:creator>Mozilla Corporation</em:creator> + <em:homepageURL></em:homepageURL> + <em:optionsType></em:optionsType> + <em:optionsURL></em:optionsURL> + <em:updateURL></em:updateURL> + </Description> +</RDF> diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/options.xul b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/options.xul new file mode 100644 index 000000000..89b95e8d7 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/app-extension/options.xul @@ -0,0 +1,5 @@ +<?xml version="1.0" ?> +<vbox xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <setting data-jetpack-id="test-simple-prefs@jetpack" pref="extensions.test-simple-prefs@jetpack.somePreference" pref-name="somePreference" title="some-title" type="string">Some short description for the preference</setting> + <setting data-jetpack-id="test-simple-prefs@jetpack" pref="extensions.test-simple-prefs@jetpack.myInteger" pref-name="myInteger" title="my-int" type="integer">How many of them we have.</setting> +</vbox> diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/lib/main.js b/addon-sdk/source/test/addons/simple-prefs-regression/lib/main.js new file mode 100644 index 000000000..757759dcb --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/lib/main.js @@ -0,0 +1,94 @@ +/* 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/. */ +'use strict'; + +const { Cu } = require('chrome'); +const sp = require('sdk/simple-prefs'); +const app = require('sdk/system/xul-app'); +const tabs = require('sdk/tabs'); +const { preferencesBranch, id } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); +const { AddonManager } = Cu.import('resource://gre/modules/AddonManager.jsm', {}); + +exports.testRegression = (assert) => { + assert.equal(preferencesBranch, id, 'preferencesBranch returns id here'); +} + +exports.testDefaultValues = (assert) => { + assert.equal(sp.prefs.myHiddenInt, 5, 'myHiddenInt default is 5'); + assert.equal(sp.prefs.myInteger, 8, 'myInteger default is 8'); + assert.equal(sp.prefs.somePreference, 'TEST', 'somePreference default is correct'); +} + +exports.testOptionsType = function*(assert) { + let addon = yield getAddonByID(id); + assert.equal(addon.optionsType, AddonManager.OPTIONS_TYPE_INLINE, 'options type is inline'); +} + +if (app.is('Firefox')) { + exports.testAOM = function(assert, done) { + tabs.open({ + url: 'about:addons', + onReady: function(tab) { + tab.attach({ + contentScriptWhen: 'end', + contentScript: 'function onLoad() {\n' + + 'unsafeWindow.removeEventListener("load", onLoad, false);\n' + + 'AddonManager.getAddonByID("' + id + '", function(aAddon) {\n' + + 'unsafeWindow.gViewController.viewObjects.detail.node.addEventListener("ViewChanged", function whenViewChanges() {\n' + + 'unsafeWindow.gViewController.viewObjects.detail.node.removeEventListener("ViewChanged", whenViewChanges, false);\n' + + 'setTimeout(function() {\n' + // TODO: figure out why this is necessary.. + 'self.postMessage({\n' + + 'somePreference: getAttributes(unsafeWindow.document.querySelector("setting[title=\'some-title\']")),\n' + + 'myInteger: getAttributes(unsafeWindow.document.querySelector("setting[title=\'my-int\']")),\n' + + 'myHiddenInt: getAttributes(unsafeWindow.document.querySelector("setting[title=\'hidden-int\']"))\n' + + '});\n' + + '}, 250);\n' + + '}, false);\n' + + 'unsafeWindow.gViewController.commands.cmd_showItemDetails.doCommand(aAddon, true);\n' + + '});\n' + + 'function getAttributes(ele) {\n' + + 'if (!ele) return {};\n' + + 'return {\n' + + 'pref: ele.getAttribute("pref"),\n' + + 'type: ele.getAttribute("type"),\n' + + 'title: ele.getAttribute("title"),\n' + + 'desc: ele.getAttribute("desc")\n' + + '}\n' + + '}\n' + + '}\n' + + // Wait for the load event ? + 'if (document.readyState == "complete") {\n' + + 'onLoad()\n' + + '} else {\n' + + 'unsafeWindow.addEventListener("load", onLoad, false);\n' + + '}\n', + onMessage: function(msg) { + // test somePreference + assert.equal(msg.somePreference.type, 'string', 'some pref is a string'); + assert.equal(msg.somePreference.pref, 'extensions.'+preferencesBranch+'.somePreference', 'somePreference path is correct'); + assert.equal(msg.somePreference.title, 'some-title', 'somePreference title is correct'); + assert.equal(msg.somePreference.desc, 'Some short description for the preference', 'somePreference description is correct'); + + // test myInteger + assert.equal(msg.myInteger.type, 'integer', 'myInteger is a int'); + assert.equal(msg.myInteger.pref, 'extensions.'+preferencesBranch+'.myInteger', 'extensions.test-simple-prefs.myInteger'); + assert.equal(msg.myInteger.title, 'my-int', 'myInteger title is correct'); + assert.equal(msg.myInteger.desc, 'How many of them we have.', 'myInteger desc is correct'); + + // test myHiddenInt + assert.equal(msg.myHiddenInt.type, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.pref, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.title, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.desc, undefined, 'myHiddenInt was not displayed'); + + tab.close(done); + } + }); + } + }); + } +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/simple-prefs-regression/package.json b/addon-sdk/source/test/addons/simple-prefs-regression/package.json new file mode 100644 index 000000000..1de5e8ac2 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs-regression/package.json @@ -0,0 +1,24 @@ +{ + "id": "test-simple-prefs", + "preferences": [{ + "name": "somePreference", + "title": "some-title", + "description": "Some short description for the preference", + "type": "string", + "value": "TEST" + }, + { + "description": "How many of them we have.", + "name": "myInteger", + "type": "integer", + "value": 8, + "title": "my-int" + }, { + "name": "myHiddenInt", + "type": "integer", + "hidden": true, + "value": 5, + "title": "hidden-int" + }], + "preferences-branch": "simple-prefs-regression" +} diff --git a/addon-sdk/source/test/addons/simple-prefs/lib/main.js b/addon-sdk/source/test/addons/simple-prefs/lib/main.js new file mode 100644 index 000000000..65d25b381 --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs/lib/main.js @@ -0,0 +1,109 @@ +/* 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/. */ +'use strict'; + +const { Cu } = require('chrome'); +const sp = require('sdk/simple-prefs'); +const app = require('sdk/system/xul-app'); +const { id, preferencesBranch } = require('sdk/self'); +const { open } = require('sdk/preferences/utils'); +const { getTabForId } = require('sdk/tabs/utils'); +const { modelFor } = require('sdk/model/core'); +const { getAddonByID } = require('sdk/addon/manager'); +const { AddonManager } = Cu.import('resource://gre/modules/AddonManager.jsm', {}); +require('sdk/tabs'); + +exports.testDefaultValues = function (assert) { + assert.equal(sp.prefs.myHiddenInt, 5, 'myHiddenInt default is 5'); + assert.equal(sp.prefs.myInteger, 8, 'myInteger default is 8'); + assert.equal(sp.prefs.somePreference, 'TEST', 'somePreference default is correct'); +} + +exports.testOptionsType = function*(assert) { + let addon = yield getAddonByID(id); + assert.equal(addon.optionsType, AddonManager.OPTIONS_TYPE_INLINE, 'options type is inline'); +} + +exports.testButton = function(assert, done) { + open({ id: id }).then(({ tabId, document }) => { + let tab = modelFor(getTabForId(tabId)); + sp.once('sayHello', _ => { + assert.pass('The button was pressed!'); + tab.close(done); + }); + + tab.attach({ + contentScript: 'unsafeWindow.document.querySelector("button[label=\'Click me!\']").click();' + }); + }); +} + +if (app.is('Firefox')) { + exports.testAOM = function(assert, done) { + open({ id: id }).then(({ tabId }) => { + let tab = modelFor(getTabForId(tabId)); + assert.pass('the add-on prefs page was opened.'); + + tab.attach({ + contentScriptWhen: "end", + contentScript: 'self.postMessage({\n' + + 'someCount: unsafeWindow.document.querySelectorAll("setting[title=\'some-title\']").length,\n' + + 'somePreference: getAttributes(unsafeWindow.document.querySelector("setting[title=\'some-title\']")),\n' + + 'myInteger: getAttributes(unsafeWindow.document.querySelector("setting[title=\'my-int\']")),\n' + + 'myHiddenInt: getAttributes(unsafeWindow.document.querySelector("setting[title=\'hidden-int\']")),\n' + + 'sayHello: getAttributes(unsafeWindow.document.querySelector("button[label=\'Click me!\']"))\n' + + '});\n' + + 'function getAttributes(ele) {\n' + + 'if (!ele) return {};\n' + + 'return {\n' + + 'pref: ele.getAttribute("pref"),\n' + + 'type: ele.getAttribute("type"),\n' + + 'title: ele.getAttribute("title"),\n' + + 'desc: ele.getAttribute("desc"),\n' + + '"data-jetpack-id": ele.getAttribute(\'data-jetpack-id\')\n' + + '}\n' + + '}\n', + onMessage: msg => { + // test against doc caching + assert.equal(msg.someCount, 1, 'there is exactly one <setting> node for somePreference'); + + // test somePreference + assert.equal(msg.somePreference.type, 'string', 'some pref is a string'); + assert.equal(msg.somePreference.pref, 'extensions.' + id + '.somePreference', 'somePreference path is correct'); + assert.equal(msg.somePreference.title, 'some-title', 'somePreference title is correct'); + assert.equal(msg.somePreference.desc, 'Some short description for the preference', 'somePreference description is correct'); + assert.equal(msg.somePreference['data-jetpack-id'], id, 'data-jetpack-id attribute value is correct'); + + // test myInteger + assert.equal(msg.myInteger.type, 'integer', 'myInteger is a int'); + assert.equal(msg.myInteger.pref, 'extensions.' + id + '.myInteger', 'extensions.test-simple-prefs.myInteger'); + assert.equal(msg.myInteger.title, 'my-int', 'myInteger title is correct'); + assert.equal(msg.myInteger.desc, 'How many of them we have.', 'myInteger desc is correct'); + assert.equal(msg.myInteger['data-jetpack-id'], id, 'data-jetpack-id attribute value is correct'); + + // test myHiddenInt + assert.equal(msg.myHiddenInt.type, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.pref, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.title, undefined, 'myHiddenInt was not displayed'); + assert.equal(msg.myHiddenInt.desc, undefined, 'myHiddenInt was not displayed'); + + // test sayHello + assert.equal(msg.sayHello['data-jetpack-id'], id, 'data-jetpack-id attribute value is correct'); + + tab.close(done); + } + }); + }) + } + + // run it again, to test against inline options document caching + // and duplication of <setting> nodes upon re-entry to about:addons + exports.testAgainstDocCaching = exports.testAOM; +} + +exports.testDefaultPreferencesBranch = function(assert) { + assert.equal(preferencesBranch, id, 'preferencesBranch default the same as self.id'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/simple-prefs/package.json b/addon-sdk/source/test/addons/simple-prefs/package.json new file mode 100644 index 000000000..6fc1df79a --- /dev/null +++ b/addon-sdk/source/test/addons/simple-prefs/package.json @@ -0,0 +1,32 @@ +{ + "id": "test-simple-prefs@jetpack", + "preferences": [{ + "name": "somePreference", + "title": "some-title", + "description": "Some short description for the preference", + "type": "string", + "value": "TEST" + }, + { + "description": "How many of them we have.", + "name": "myInteger", + "type": "integer", + "value": 8, + "title": "my-int" + }, + { + "name": "sayHello", + "type": "control", + "label": "Click me!", + "title": "hello" + }, + { + "name": "myHiddenInt", + "type": "integer", + "hidden": true, + "value": 5, + "title": "hidden-int" + }], + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/standard-id/lib/main.js b/addon-sdk/source/test/addons/standard-id/lib/main.js new file mode 100644 index 000000000..bd1b5f9ce --- /dev/null +++ b/addon-sdk/source/test/addons/standard-id/lib/main.js @@ -0,0 +1,30 @@ +/* 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/. */ + +'use strict'; + +const { id, preferencesBranch } = require('sdk/self'); +const simple = require('sdk/simple-prefs'); +const service = require('sdk/preferences/service'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testStandardID = function(assert) { + assert.equal(id, 'standard-id@jetpack', 'standard ID is standard'); + + assert.equal(simple.prefs.test13, 26, 'test13 is 26'); + + simple.prefs.test14 = '15'; + assert.equal(service.get('extensions.standard-id@jetpack.test14'), '15', 'test14 is 15'); + assert.equal(service.get('extensions.standard-id@jetpack.test14'), simple.prefs.test14, 'simple test14 also 15'); +} + +// from `/test/test-self.js`, adapted to `sdk/test/assert` API +exports.testSelfID = function*(assert) { + assert.equal(typeof(id), 'string', 'self.id is a string'); + assert.ok(id.length > 0, 'self.id not empty'); + let addon = yield getAddonByID(id); + assert.ok(addon, 'found addon with self.id'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/standard-id/package.json b/addon-sdk/source/test/addons/standard-id/package.json new file mode 100644 index 000000000..7a8f7a77c --- /dev/null +++ b/addon-sdk/source/test/addons/standard-id/package.json @@ -0,0 +1,13 @@ +{ + "id": "standard-id@jetpack", + "fullName": "standard ID test", + "author": "Tomislav Jovanovic", + "preferences": [{ + "name": "test13", + "type": "integer", + "title": "test13", + "value": 26 + }], + "main": "./lib/main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/tab-close-on-startup/main.js b/addon-sdk/source/test/addons/tab-close-on-startup/main.js new file mode 100644 index 000000000..ad1e039e6 --- /dev/null +++ b/addon-sdk/source/test/addons/tab-close-on-startup/main.js @@ -0,0 +1,31 @@ +/* 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/. */ +'use strict'; + +const { setTimeout } = require('sdk/timers'); +const tabs = require('sdk/tabs'); + +var closeEvents = 0; +const closeEventDetector = _ => closeEvents++; + +exports.testNoTabCloseOnStartup = function(assert, done) { + setTimeout(_ => { + assert.equal(closeEvents, 0, 'there were no tab close events detected'); + tabs.open({ + url: 'about:mozilla', + inBackground: true, + onReady: tab => tab.close(), + onClose: _ => { + assert.equal(closeEvents, 1, 'there was one tab close event detected'); + done(); + } + }) + }); +} + +exports.main = function() { + tabs.on('close', closeEventDetector); + + require("sdk/test/runner").runTestsFromModule(module); +} diff --git a/addon-sdk/source/test/addons/tab-close-on-startup/package.json b/addon-sdk/source/test/addons/tab-close-on-startup/package.json new file mode 100644 index 000000000..926f28085 --- /dev/null +++ b/addon-sdk/source/test/addons/tab-close-on-startup/package.json @@ -0,0 +1,5 @@ +{ + "id": "test-tabs@jetpack", + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/toolkit-require-reload/main.js b/addon-sdk/source/test/addons/toolkit-require-reload/main.js new file mode 100644 index 000000000..5f5827f97 --- /dev/null +++ b/addon-sdk/source/test/addons/toolkit-require-reload/main.js @@ -0,0 +1,77 @@ +/* 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/. */ +"use strict"; + +const { Cu } = require("chrome"); + +const toolkit = require("toolkit/require"); + +const {tmpdir} = require("node/os"); +const {join} = require("sdk/fs/path"); +const {writeFile, unlink} = require("sdk/io/fs"); +const {fromFilename} = require("sdk/url"); + +const makeCallback = (resolve, reject) => (error, result) => { + if (error) reject(error); + else resolve(result); +}; + +const remove = path => new Promise((resolve, reject) => + unlink(path, makeCallback(resolve, reject))); + +const write = (...params) => new Promise((resolve, reject) => + writeFile(...params, makeCallback(resolve, reject))); + +exports.testReload = function*(assert) { + const modulePath = join(tmpdir(), "toolkit-require-reload.js"); + const moduleURI = fromFilename(modulePath); + + yield write(modulePath, `exports.version = () => 1;`); + + const v1 = toolkit.require(moduleURI); + + assert.equal(v1.version(), 1, "module exports version"); + + yield write(modulePath, `exports.version = () => 2;`); + + assert.equal(v1, toolkit.require(moduleURI), + "require does not reload modules"); + + const v2 = toolkit.require(moduleURI, {reload: true}); + assert.equal(v2.version(), 2, "module was updated"); + + yield remove(modulePath); +}; + +exports.testReloadAll = function*(assert) { + const parentPath = join(tmpdir(), "toolkit-require-reload-parent.js"); + const childPath = join(tmpdir(), "toolkit-require-reload-child.js"); + + const parentURI = fromFilename(parentPath); + const childURI = fromFilename(childPath); + + yield write(childPath, `exports.name = () => "child"`); + yield write(parentPath, `const child = require("./toolkit-require-reload-child"); + exports.greet = () => "Hello " + child.name();`); + + const parent1 = toolkit.require(parentURI); + assert.equal(parent1.greet(), "Hello child"); + + yield write(childPath, `exports.name = () => "father"`); + yield write(parentPath, `const child = require("./toolkit-require-reload-child"); + exports.greet = () => "Hello " + child.name() + "!";`); + + const parent2 = toolkit.require(parentURI, {reload: true}); + assert.equal(parent2.greet(), "Hello child!", + "only parent changes were picked up"); + + const parent3 = toolkit.require(parentURI, {reload: true, all: true}); + assert.equal(parent3.greet(), "Hello father!", + "all changes were picked up"); + + yield remove(childPath); + yield remove(parentPath); +}; + +exports.main = _ => require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/toolkit-require-reload/package.json b/addon-sdk/source/test/addons/toolkit-require-reload/package.json new file mode 100644 index 000000000..25e885340 --- /dev/null +++ b/addon-sdk/source/test/addons/toolkit-require-reload/package.json @@ -0,0 +1,5 @@ +{ + "id": "@toolkit-require-reload", + "version": "0.0.1", + "main": "main.js" +} diff --git a/addon-sdk/source/test/addons/translators/main.js b/addon-sdk/source/test/addons/translators/main.js new file mode 100644 index 000000000..9c7cfff09 --- /dev/null +++ b/addon-sdk/source/test/addons/translators/main.js @@ -0,0 +1,20 @@ +/* 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/. */ +'use strict'; + +const { id } = require('sdk/self'); +const { getAddonByID } = require('sdk/addon/manager'); + +exports.testTranslators = function*(assert) { + let addon = yield getAddonByID(id); + let count = 0; + addon.translators.forEach(function({ name }) { + count++; + assert.equal(name, 'Erik Vold', 'The translator keys are correct'); + }); + assert.equal(count, 1, 'The translator key count is correct'); + assert.equal(addon.translators.length, 1, 'The translator key length is correct'); +} + +require('sdk/test/runner').runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/translators/package.json b/addon-sdk/source/test/addons/translators/package.json new file mode 100644 index 000000000..56fc2f266 --- /dev/null +++ b/addon-sdk/source/test/addons/translators/package.json @@ -0,0 +1,8 @@ +{ + "id": "test-translators@jetpack", + "translators": [ + "Erik Vold" + ], + "main": "./main.js", + "version": "0.0.1" +} diff --git a/addon-sdk/source/test/addons/unsafe-content-script/main.js b/addon-sdk/source/test/addons/unsafe-content-script/main.js new file mode 100644 index 000000000..b06810117 --- /dev/null +++ b/addon-sdk/source/test/addons/unsafe-content-script/main.js @@ -0,0 +1,68 @@ +/* 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/. */ +"use strict"; + +const { create: makeFrame } = require("sdk/frame/utils"); +const { window } = require("sdk/addon/window"); +const { Loader } = require('sdk/test/loader'); + +exports.testMembranelessMode = function(assert, done) { + const loader = Loader(module); + const Worker = loader.require("sdk/content/worker").Worker; + + let url = "data:text/html;charset=utf-8," + encodeURIComponent( + '<script>' + + 'function runTest() {' + + ' assert(fuu.bar == 42, "Content-script objects should be accessible to content with' + + ' the unsafe-content-script flag on.");' + + '}' + + '</script>' + ); + + let element = makeFrame(window.document, { + nodeName: "iframe", + type: "content", + allowJavascript: true, + allowPlugins: true, + allowAuth: true, + uri: url + }); + + element.addEventListener("DOMContentLoaded", onDOMReady, false); + + function onDOMReady() { + let worker = Worker({ + window: element.contentWindow, + contentScript: + 'new ' + function () { + var assert = function assert(v, msg) { + self.port.emit("assert", { assertion: v, msg: msg }); + } + var done = function done() { + self.port.emit("done"); + } + window.wrappedJSObject.fuu = { bar: 42 }; + window.wrappedJSObject.assert = assert; + window.wrappedJSObject.runTest(); + done(); + } + }); + + worker.port.on("done", () => { + // cleanup + element.parentNode.removeChild(element); + worker.destroy(); + loader.unload(); + + done(); + }); + + worker.port.on("assert", function (data) { + assert.ok(data.assertion, data.msg); + }); + + } +}; + +require("sdk/test/runner").runTestsFromModule(module); diff --git a/addon-sdk/source/test/addons/unsafe-content-script/package.json b/addon-sdk/source/test/addons/unsafe-content-script/package.json new file mode 100644 index 000000000..4e671dfc4 --- /dev/null +++ b/addon-sdk/source/test/addons/unsafe-content-script/package.json @@ -0,0 +1,8 @@ +{ + "id": "content-permissions@jetpack", + "permissions": { + "unsafe-content-script": true + }, + "main": "./main.js", + "version": "0.0.1" +} |