discourse/vendor/assets/javascripts/pretender.js
Osama Sayegh 7217dcb67a
FEATURE: Allow theme tests to be run in production (#12815)
This commit allows site admins to run theme tests in production via a new `/theme-qunit` route. When you visit `/theme-qunit`, you'll see a list of the themes/components installed on your site that have tests, and from there you can select a theme or component that you run its tests.

We also have a new rake task `themes:install_and_test` that can be used to install a list of themes/components on a temporary database and run the tests of the themes/components that are installed. This rake task can be useful when upgrading/deploying a Discourse instance to make sure that the installed themes/components are compatible with the new Discourse version being deployed, and if the tests fail you can abort the build/deploy process so you don't end up with a broken site.
2021-04-26 12:56:45 +03:00

488 lines
15 KiB
JavaScript

(function(self) {
'use strict';
var appearsBrowserified = typeof self !== 'undefined' &&
typeof process !== 'undefined' &&
Object.prototype.toString.call(process) === '[object Object]';
var RouteRecognizer = appearsBrowserified ? require('route-recognizer') : self.RouteRecognizer;
var FakeXMLHttpRequest = appearsBrowserified ? require('fake-xml-http-request') : self.FakeXMLHttpRequest;
/**
* parseURL - decompose a URL into its parts
* @param {String} url a URL
* @return {Object} parts of the URL, including the following
*
* 'https://www.yahoo.com:1234/mypage?test=yes#abc'
*
* {
* host: 'www.yahoo.com:1234',
* protocol: 'https:',
* search: '?test=yes',
* hash: '#abc',
* href: 'https://www.yahoo.com:1234/mypage?test=yes#abc',
* pathname: '/mypage',
* fullpath: '/mypage?test=yes'
* }
*/
function parseURL(url) {
// TODO: something for when document isn't present... #yolo
var anchor = document.createElement('a');
anchor.href = url;
if (!anchor.host) {
anchor.href = anchor.href; // IE: load the host and protocol
}
var pathname = anchor.pathname;
if (pathname.charAt(0) !== '/') {
pathname = '/' + pathname; // IE: prepend leading slash
}
var host = anchor.host;
if (anchor.port === '80' || anchor.port === '443') {
host = anchor.hostname; // IE: remove default port
}
return {
host: host,
protocol: anchor.protocol,
search: anchor.search,
hash: anchor.hash,
href: anchor.href,
pathname: pathname,
fullpath: pathname + (anchor.search || '') + (anchor.hash || '')
};
}
/**
* Registry
*
* A registry is a map of HTTP verbs to route recognizers.
*/
function Registry(/* host */) {
// Herein we keep track of RouteRecognizer instances
// keyed by HTTP method. Feel free to add more as needed.
this.verbs = {
GET: new RouteRecognizer(),
PUT: new RouteRecognizer(),
POST: new RouteRecognizer(),
DELETE: new RouteRecognizer(),
PATCH: new RouteRecognizer(),
HEAD: new RouteRecognizer(),
OPTIONS: new RouteRecognizer()
};
}
/**
* Hosts
*
* a map of hosts to Registries, ultimately allowing
* a per-host-and-port, per HTTP verb lookup of RouteRecognizers
*/
function Hosts() {
this._registries = {};
}
/**
* Hosts#forURL - retrieve a map of HTTP verbs to RouteRecognizers
* for a given URL
*
* @param {String} url a URL
* @return {Registry} a map of HTTP verbs to RouteRecognizers
* corresponding to the provided URL's
* hostname and port
*/
Hosts.prototype.forURL = function(url) {
var host = parseURL(url).host;
var registry = this._registries[host];
if (registry === undefined) {
registry = (this._registries[host] = new Registry(host));
}
return registry.verbs;
};
function Pretender(/* routeMap1, routeMap2, ..., options*/) {
this.hosts = new Hosts();
var lastArg = arguments[arguments.length - 1];
var options = typeof lastArg === 'object' ? lastArg : null;
var shouldNotTrack = options && (options.trackRequests === false);
var noopArray = { push: function() {}, length: 0 };
this.handlers = [];
this.handledRequests = shouldNotTrack ? noopArray: [];
this.passthroughRequests = shouldNotTrack ? noopArray: [];
this.unhandledRequests = shouldNotTrack ? noopArray: [];
this.requestReferences = [];
this.forcePassthrough = options && (options.forcePassthrough === true);
this.disableUnhandled = options && (options.disableUnhandled === true);
// reference the native XMLHttpRequest object so
// it can be restored later
this._nativeXMLHttpRequest = self.XMLHttpRequest;
this.running = false;
var ctx = { pretender: this };
this.ctx = ctx;
// capture xhr requests, channeling them into
// the route map.
self.XMLHttpRequest = interceptor(ctx);
// 'start' the server
this.running = true;
// trigger the route map DSL.
var argLength = options ? arguments.length - 1 : arguments.length;
for (var i = 0; i < argLength; i++) {
this.map(arguments[i]);
}
}
function interceptor(ctx) {
function FakeRequest() {
// super()
FakeXMLHttpRequest.call(this);
}
FakeRequest.prototype = Object.create(FakeXMLHttpRequest.prototype);
FakeRequest.prototype.constructor = FakeRequest;
// extend
FakeRequest.prototype.send = function send() {
if (!ctx.pretender.running) {
throw new Error('You shut down a Pretender instance while there was a pending request. ' +
'That request just tried to complete. Check to see if you accidentally shut down ' +
'a pretender earlier than you intended to');
}
FakeXMLHttpRequest.prototype.send.apply(this, arguments);
if (ctx.pretender.checkPassthrough(this)) {
var xhr = createPassthrough(this);
xhr.send.apply(xhr, arguments);
} else {
ctx.pretender.handleRequest(this);
}
};
function createPassthrough(fakeXHR) {
// event types to handle on the xhr
var evts = ['error', 'timeout', 'abort', 'readystatechange'];
// event types to handle on the xhr.upload
var uploadEvents = [];
// properties to copy from the native xhr to fake xhr
var lifecycleProps = ['readyState', 'responseText', 'responseXML', 'status', 'statusText'];
var xhr = fakeXHR._passthroughRequest = new ctx.pretender._nativeXMLHttpRequest();
xhr.open(fakeXHR.method, fakeXHR.url, fakeXHR.async, fakeXHR.username, fakeXHR.password);
if (fakeXHR.responseType === 'arraybuffer') {
lifecycleProps = ['readyState', 'response', 'status', 'statusText'];
xhr.responseType = fakeXHR.responseType;
}
// use onload if the browser supports it
if ('onload' in xhr) {
evts.push('load');
}
// add progress event for async calls
// avoid using progress events for sync calls, they will hang https://bugs.webkit.org/show_bug.cgi?id=40996.
if (fakeXHR.async && fakeXHR.responseType !== 'arraybuffer') {
evts.push('progress');
uploadEvents.push('progress');
}
// update `propertyNames` properties from `fromXHR` to `toXHR`
function copyLifecycleProperties(propertyNames, fromXHR, toXHR) {
for (var i = 0; i < propertyNames.length; i++) {
var prop = propertyNames[i];
if (prop in fromXHR) {
toXHR[prop] = fromXHR[prop];
}
}
}
// fire fake event on `eventable`
function dispatchEvent(eventable, eventType, event) {
eventable.dispatchEvent(event);
if (eventable['on' + eventType]) {
eventable['on' + eventType](event);
}
}
// set the on- handler on the native xhr for the given eventType
function createHandler(eventType) {
xhr['on' + eventType] = function(event) {
copyLifecycleProperties(lifecycleProps, xhr, fakeXHR);
dispatchEvent(fakeXHR, eventType, event);
};
}
// set the on- handler on the native xhr's `upload` property for
// the given eventType
function createUploadHandler(eventType) {
if (xhr.upload) {
xhr.upload['on' + eventType] = function(event) {
dispatchEvent(fakeXHR.upload, eventType, event);
};
}
}
var i;
for (i = 0; i < evts.length; i++) {
createHandler(evts[i]);
}
for (i = 0; i < uploadEvents.length; i++) {
createUploadHandler(uploadEvents[i]);
}
if (fakeXHR.async) {
xhr.timeout = fakeXHR.timeout;
xhr.withCredentials = fakeXHR.withCredentials;
}
for (var h in fakeXHR.requestHeaders) {
xhr.setRequestHeader(h, fakeXHR.requestHeaders[h]);
}
return xhr;
}
FakeRequest.prototype._passthroughCheck = function(method, args) {
if (this._passthroughRequest) {
return this._passthroughRequest[method].apply(this._passthroughRequest, args);
}
return FakeXMLHttpRequest.prototype[method].apply(this, args);
};
FakeRequest.prototype.abort = function abort() {
return this._passthroughCheck('abort', arguments);
};
FakeRequest.prototype.getResponseHeader = function getResponseHeader() {
return this._passthroughCheck('getResponseHeader', arguments);
};
FakeRequest.prototype.getAllResponseHeaders = function getAllResponseHeaders() {
return this._passthroughCheck('getAllResponseHeaders', arguments);
};
if (ctx.pretender._nativeXMLHttpRequest.prototype._passthroughCheck) {
console.warn('You created a second Pretender instance while there was already one running. ' +
'Running two Pretender servers at once will lead to unexpected results and will ' +
'be removed entirely in a future major version.' +
'Please call .shutdown() on your instances when you no longer need them to respond.');
}
return FakeRequest;
}
function verbify(verb) {
return function(path, handler, async) {
return this.register(verb, path, handler, async);
};
}
function scheduleProgressEvent(request, startTime, totalTime) {
setTimeout(function() {
if (!request.aborted && !request.status) {
var ellapsedTime = new Date().getTime() - startTime.getTime();
request.upload._progress(true, ellapsedTime, totalTime);
request._progress(true, ellapsedTime, totalTime);
scheduleProgressEvent(request, startTime, totalTime);
}
}, 50);
}
function isArray(array) {
return Object.prototype.toString.call(array) === '[object Array]';
}
var PASSTHROUGH = {};
Pretender.prototype = {
get: verbify('GET'),
post: verbify('POST'),
put: verbify('PUT'),
'delete': verbify('DELETE'),
patch: verbify('PATCH'),
head: verbify('HEAD'),
options: verbify('OPTIONS'),
map: function(maps) {
maps.call(this);
},
register: function register(verb, url, handler, async) {
if (!handler) {
throw new Error('The function you tried passing to Pretender to handle ' +
verb + ' ' + url + ' is undefined or missing.');
}
handler.numberOfCalls = 0;
handler.async = async;
this.handlers.push(handler);
var registry = this.hosts.forURL(url)[verb];
registry.add([{
path: parseURL(url).fullpath,
handler: handler
}]);
return handler;
},
passthrough: PASSTHROUGH,
checkPassthrough: function checkPassthrough(request) {
var verb = request.method.toUpperCase();
var path = parseURL(request.url).fullpath;
var recognized = this.hosts.forURL(request.url)[verb].recognize(path);
var match = recognized && recognized[0];
if ((match && match.handler === PASSTHROUGH) || this.forcePassthrough) {
this.passthroughRequests.push(request);
this.passthroughRequest(verb, path, request);
return true;
}
return false;
},
handleRequest: function handleRequest(request) {
var verb = request.method.toUpperCase();
var path = request.url;
var handler = this._handlerFor(verb, path, request);
if (handler) {
handler.handler.numberOfCalls++;
var async = handler.handler.async;
this.handledRequests.push(request);
var pretender = this;
var _handleRequest = function(statusHeadersAndBody) {
if (!isArray(statusHeadersAndBody)) {
var note = 'Remember to `return [status, headers, body];` in your route handler.';
throw new Error('Nothing returned by handler for ' + path + '. ' + note);
}
var status = statusHeadersAndBody[0],
headers = pretender.prepareHeaders(statusHeadersAndBody[1]),
body = pretender.prepareBody(statusHeadersAndBody[2], headers);
pretender.handleResponse(request, async, function() {
request.respond(status, headers, body);
pretender.handledRequest(verb, path, request);
});
};
try {
var result = handler.handler(request);
if (result && typeof result.then === 'function') {
// `result` is a promise, resolve it
result.then(function(resolvedResult) {
_handleRequest(resolvedResult);
});
} else {
_handleRequest(result);
}
} catch (error) {
this.erroredRequest(verb, path, request, error);
this.resolve(request);
}
} else {
if (!this.disableUnhandled) {
this.unhandledRequests.push(request);
this.unhandledRequest(verb, path, request);
}
}
},
handleResponse: function handleResponse(request, strategy, callback) {
var delay = typeof strategy === 'function' ? strategy() : strategy;
delay = typeof delay === 'boolean' || typeof delay === 'number' ? delay : 0;
if (delay === false) {
callback();
} else {
var pretender = this;
pretender.requestReferences.push({
request: request,
callback: callback
});
if (delay !== true) {
scheduleProgressEvent(request, new Date(), delay);
setTimeout(function() {
pretender.resolve(request);
}, delay);
}
}
},
resolve: function resolve(request) {
for (var i = 0, len = this.requestReferences.length; i < len; i++) {
var res = this.requestReferences[i];
if (res.request === request) {
res.callback();
this.requestReferences.splice(i, 1);
break;
}
}
},
requiresManualResolution: function(verb, path) {
var handler = this._handlerFor(verb.toUpperCase(), path, {});
if (!handler) { return false; }
var async = handler.handler.async;
return typeof async === 'function' ? async() === true : async === true;
},
prepareBody: function(body) { return body; },
prepareHeaders: function(headers) { return headers; },
handledRequest: function(/* verb, path, request */) { /* no-op */},
passthroughRequest: function(/* verb, path, request */) { /* no-op */},
unhandledRequest: function(verb, path/*, request */) {
throw new Error('Pretender intercepted ' + verb + ' ' +
path + ' but no handler was defined for this type of request');
},
erroredRequest: function(verb, path, request, error) {
error.message = 'Pretender intercepted ' + verb + ' ' +
path + ' but encountered an error: ' + error.message;
throw error;
},
_handlerFor: function(verb, url, request) {
var registry = this.hosts.forURL(url)[verb];
var matches = registry.recognize(parseURL(url).fullpath);
var match = matches ? matches[0] : null;
if (match) {
request.params = match.params;
request.queryParams = matches.queryParams;
}
return match;
},
shutdown: function shutdown() {
self.XMLHttpRequest = this._nativeXMLHttpRequest;
this.ctx.pretender = undefined;
// 'stop' the server
this.running = false;
}
};
Pretender.parseURL = parseURL;
Pretender.Hosts = Hosts;
Pretender.Registry = Registry;
if (typeof module === 'object') {
module.exports = Pretender;
} else if (typeof define !== 'undefined') {
define('pretender', [], function() {
return Pretender;
});
}
self.Pretender = Pretender;
}(self));