yeti
Version:
599 lines (538 loc) • 22.3 kB
JavaScript
;
var PHANTOMJS_MIN_VERSION = "1.6.0";
var vows = require("vows");
var assert = require("assert");
var path = require("path");
var fs = require("graceful-fs");
var http = require("http");
var child_process = require("child_process");
var semver = require("semver");
var Hub = require("../../lib/hub");
var hub = require("../lib/hub");
if (process.env.TRAVIS) {
// Debug test errors that only occur on Travis CI.
var _exit = process.exit;
process.exit = function (code) {
var e = new Error();
console.warn("TRAVIS: process.exit was called! Code:", code, "Stack:", e.stack);
return _exit(code);
};
}
// PhantomJS version check
child_process.exec("phantomjs -v", function (err, stdout) {
var message;
if (err) {
message = "Failed to start PhantomJS > {version}, error given: " + err;
} else if (!semver.satisfies(stdout, ">=" + PHANTOMJS_MIN_VERSION)) {
message = "Tests require PhantomJS {version} or newer. " +
"Please upgrade PhantomJS by visiting phantomjs.org";
}
if (message) {
throw new Error(message.replace(/\{version\}/, PHANTOMJS_MIN_VERSION));
}
});
function didNotThrow(topic) {
if (topic instanceof Error) {
assert.fail(topic, {}, "Topic error: " + topic.stack);
}
}
function getPathname() {
/*global window:true */
// This function runs in the scope of the web page.
return window.location.pathname;
}
function captureContext(batchContext) {
return {
topic: function (browser, lastTopic) {
var vow = this;
browser.createPage(function (err, page) {
var timeout = setTimeout(function () {
vow.callback(new Error("The capture page took too long to load."));
}, 10000),
openAttempts = 0,
loaded = false;
lastTopic.client.once("agentConnect", function (agent) {
lastTopic.client.once("agentSeen", function () {
page.evaluate(getPathname, function (err, url) {
clearTimeout(timeout);
loaded = true;
vow.callback(null, {
url: url,
page: page,
agent: agent
});
});
});
});
if (process.env.TRAVIS) {
page.onConsoleMessage = function () {
console.log.apply(this, [
"PhantomJS console message:"
].concat(Array.prototype.slice.apply(arguments)));
};
}
page.onError = function () {
console.log.apply(this, [
"PhantomJS error message:"
].concat(Array.prototype.slice.apply(arguments)));
};
if (process.env.RESOURCE_DEBUG) {
page.onResourceRequested = function () {
console.log.apply(this, [
"PhantomJS resource requested:"
].concat(Array.prototype.slice.apply(arguments)));
};
}
(function opener() {
page.open(lastTopic.url, function (err, status) {
if (status !== "success") {
openAttempts += 1;
if (openAttempts > 5) {
vow.callback(new Error("Failed to load page, URL: " + lastTopic.url +
", status: " + status));
return;
}
if (!loaded) {
if (process.env.TRAVIS) {
console.log("Failed to open load page, URL: " + lastTopic.url +
", attempt " + openAttempts +
", scheduling next attempt in 500ms.");
}
setTimeout(opener, 500);
}
}
});
}());
});
},
"did not throw": didNotThrow,
"is ok": function (pageTopic) {
assert.ok(pageTopic.page);
},
"which fires agentConnect with the agent details": function (pageTopic) {
assert.isString(pageTopic.agent);
},
"when querying for connected agents": {
topic: function (pageTopic, browserTopic, yetiTopic) {
var vow = this;
yetiTopic.client.getAgents(function (err, agents) {
vow.callback(err, {
getAgentsResult: agents,
agentName: pageTopic.agent
});
});
},
"the result is an array": function (topic) {
assert.isArray(topic.getAgentsResult);
},
"this agent is in the list": function (topic) {
assert.strictEqual(topic.getAgentsResult[0], topic.agentName);
}
},
"for a batch": batchContext
};
/* TODO agentDisconnect is not yet implemented.
"visits Yeti briefly for the agentDisconnect event": {
topic: function (browser, lastTopic) {
var vow = this;
browser.createPage(function (page) {
var timeout = setTimeout(function () {
vow.callback(new Error("Timed out."));
}, 500);
lastTopic.client.once("agentDisconnect", function (agent) {
clearTimeout(timeout);
vow.callback(null, agent);
});
page.open(lastTopic.url, function (status) {
if (status !== "success") {
vow.callback(new Error("Failed to load page."));
}
lastTopic.client.once("agentConnect", function (agent) {
page.close();
});
});
});
},
"which fires with the agent details": function (agent) {
assert.isString(agent);
}
} */
}
function createBatchTopic(createBatchConfiguration) {
return function (pageTopic, browser, lastTopic) {
var vow = this,
results = [],
agentCompleteFires = 0,
agentErrorFires = 0,
agentSeenFires = 0,
agentBeatFires = 0,
timeout = setTimeout(function () {
vow.callback(new Error("Batch dispatch failed for " + lastTopic.url));
process.exit(1);
}, 20000),
batch = lastTopic.client.createBatch(createBatchConfiguration);
batch.on("agentResult", function (agent, details) {
results.push(details);
});
batch.on("agentScriptError", function (agent, details) {
vow.callback(new Error("Unexpected script error: " + details.message));
});
batch.on("agentError", function (agent, details) {
agentErrorFires = agentErrorFires + 1;
});
batch.on("agentBeat", function (agent, details) {
agentBeatFires = agentBeatFires + 1;
});
lastTopic.client.on("agentSeen", function (agent) {
agentSeenFires = agentSeenFires + 1;
});
batch.on("agentComplete", function (agent) {
agentCompleteFires = agentCompleteFires + 1;
});
batch.on("complete", function () {
lastTopic.client.once("agentSeen", function (agent) {
clearTimeout(timeout);
pageTopic.page.evaluate(getPathname, function (err, pathname) {
pageTopic.page.close();
vow.callback(null, {
expectedPathname: pageTopic.url,
finalPathname: pathname,
agentResults: results,
agentBeats: agentBeatFires,
agentSeenFires: agentSeenFires,
agentErrorFires: agentErrorFires,
agentCompleteFires: agentCompleteFires
});
});
});
});
};
}
function waitForPathChange(page, cb) {
function respond() {
page.evaluate(getPathname, function (err, pathname) {
cb(pathname);
});
}
respond(); // Record first URL.
page.onUrlChanged = respond;
}
function clientFailureContext(createBatchConfiguration) {
return captureContext({
topic: function (pageTopic, browser, lastTopic, hub) {
var vow = this,
results = [],
firstPathname = null,
finalPathname = null,
sessionEndFires = 0,
agentErrorFires = 0,
agentSeenFires = 0,
agentBeatFires = 0,
timeout = setTimeout(function () {
vow.callback(new Error("Recovery to capture page failed for " + lastTopic.url));
process.exit(1);
}, 20000),
visitedPaths = [],
clientSession,
batch;
function maybeCallback() {
if (sessionEndFires && finalPathname) {
vow.callback(null, {
hub: hub,
expectedPathname: firstPathname,
finalPathname: finalPathname,
sessionEndFires: sessionEndFires,
visitedPaths: visitedPaths
});
}
}
// Recall that:
// Client (test provider) <-> Hub (server) <-> Agent (browser)
//
// In this test, we will disconnect the client
// very soon after submitting a batch to the hub
// and then make sure the agent has moved back to
// the capture page.
waitForPathChange(pageTopic.page, function (pathname) {
visitedPaths.push(pathname);
if (firstPathname === null) {
firstPathname = pathname;
}
// Capture page + tests + Capture page
// 2 + tests = full test cycle
if (visitedPaths.length >= 2 + createBatchConfiguration.tests.length) {
clearTimeout(timeout);
pageTopic.page.close();
finalPathname = pathname;
maybeCallback();
} else if (pathname.indexOf("fixture") !== -1) {
// The URL is a test page.
// Kill the Yeti Client session.
// We should expect the Hub to send
// the user back to the capture page.
lastTopic.client.end();
// Note: we call end() before the
// browser actually can listen to events;
// loading has only just begun at this point.
// Thus, we must buffer events for sending later.
}
});
batch = lastTopic.client.createBatch(createBatchConfiguration);
lastTopic.session.on("end", function () {
// Hub reports a client session disconnection.
sessionEndFires += 1;
maybeCallback();
});
},
"the agent returned to the capture page": function (topic) {
assert.strictEqual(topic.finalPathname, topic.expectedPathname);
},
"the session end event fired once": function (topic) {
assert.strictEqual(topic.sessionEndFires, 1);
}
});
}
function clientTimeoutContext(createBatchConfiguration) {
return captureContext({
topic: createBatchTopic(createBatchConfiguration),
"did not throw": didNotThrow,
"the browser returned to the capture page": function (topic) {
assert.strictEqual(topic.finalPathname, topic.expectedPathname);
},
"the agentComplete event fired once": function (topic) {
assert.strictEqual(topic.agentCompleteFires, 1);
},
"the agentError event fired once": function (topic) {
assert.strictEqual(topic.agentErrorFires, 1);
}
});
}
function visitorContext(createBatchConfiguration) {
return captureContext({
topic: createBatchTopic(createBatchConfiguration),
"did not throw": didNotThrow,
"the browser returned to the capture page": function (topic) {
assert.strictEqual(topic.finalPathname, topic.expectedPathname);
},
"the agentComplete event fired once": function (topic) {
assert.strictEqual(topic.agentCompleteFires, 1);
},
"the agentSeen event fired for each test and for capture pages": function (topic) {
// Test pages. + Return to capture page.
// (Batch tests) + 1 = Expected fires.
assert.strictEqual(topic.agentSeenFires, createBatchConfiguration.tests.length + 1);
},
"the agentResults are well-formed": function (topic) {
assert.isArray(topic.agentResults);
assert.strictEqual(topic.agentResults.length, createBatchConfiguration.tests.length);
var result = topic.agentResults[0];
assert.include(result, "passed");
assert.include(result, "failed");
assert.include(result, "total");
assert.include(result, "ignored");
assert.include(result, "duration");
assert.include(result, "name");
assert.include(result, "timestamp");
},
"the client-side test passed": function (topic) {
assert.strictEqual(topic.agentResults[0].passed, 1);
assert.strictEqual(topic.agentResults[0].failed, 0);
},
"the agentBeat event fired for each beat received": function (topic) {
// Beats are subjective, they are a ping and not really trackable
// since they may be throttled they may not match up to the actual
// number of tests being executed, so we just need to make sure
// a ping actually happened.
assert.ok(topic.agentBeats);
//There should be at least one beat per test executed, maybe more
assert.ok(topic.agentBeats >= createBatchConfiguration.tests.length);
}
});
}
function errorContext(createBatchConfiguration) {
return captureContext({
topic: createBatchTopic(createBatchConfiguration),
"did not throw": didNotThrow,
"the browser returned to the capture page": function (topic) {
assert.strictEqual(topic.finalPathname, topic.expectedPathname);
},
"the agentComplete event fired once": function (topic) {
assert.strictEqual(topic.agentCompleteFires, 1);
},
"the agentError event fired for all tests": function (topic) {
assert.strictEqual(topic.agentCompleteFires, createBatchConfiguration.tests.length);
},
"the agentSeen event fired for capture pages": function (topic) {
// (Nothing; all tests invalid) + Return to capture page.
// 1 = Expected fires.
assert.strictEqual(topic.agentSeenFires, 1);
},
"the agentResults is an empty array": function (topic) {
assert.isArray(topic.agentResults);
assert.strictEqual(topic.agentResults.length, 0);
}
});
}
var DUMMY_PROTOCOL = "YetiDummyProtocol/1.0";
var SERVER_TEST_FIXTURE = fs.readFileSync(path.join(__dirname, "fixture/attach-server.html"), "utf8");
var YUI_TEST_FIXTURE = fs.readFileSync(path.resolve(__dirname, "../../dep/dev/yui-test.js"), "utf8");
function attachServerContext(testContext, explicitRoute) {
var route, testFixture;
if (explicitRoute) {
route = explicitRoute;
} else {
route = "/yeti";
}
testFixture = SERVER_TEST_FIXTURE.replace(/\{route\}/g, route);
return {
topic: function () {
var vow = this,
server = http.createServer(function (req, res) {
if (req.url === "/fixture") {
res.writeHead(200, {
"Content-Type": "text/html"
});
res.end(testFixture);
} else if (req.url === "/yui") {
res.writeHead(200, {
"Content-Type": "application/javascript"
});
res.end(YUI_TEST_FIXTURE);
} else {
res.writeHead(404, {
"Content-Type": "text/plain"
});
res.end("You failed.");
}
});
server.on("upgrade", function (req, socket, head) {
if (req.headers.upgrade === DUMMY_PROTOCOL) {
socket.write([
"HTTP/1.1 101 Why not?",
"Upgrade: " + DUMMY_PROTOCOL,
"Connection: Upgrade",
"",
"dogcow"
].join("\r\n"));
}
});
server.listen(function () {
vow.callback(null, server);
});
},
teardown: function (server) {
server.close();
},
"did not throw": didNotThrow,
"is connected": function (server) {
assert.isNumber(server.address().port);
},
"attached to a Yeti Hub": {
topic: function (server) {
var vow = this,
hub = new Hub();
if (!explicitRoute) {
hub.attachServer(server);
} else {
hub.attachServer(server, route);
}
return hub;
},
"did not throw": didNotThrow,
"is ok": function (hub) {
assert.ok(hub.server);
},
"when sending a non-Yeti upgrade request": {
topic: function (hub) {
var vow = this,
req = http.request({
port: hub.hubListener.server.address().port,
host: "localhost",
headers: {
"Connection": "Upgrade",
"Upgrade": DUMMY_PROTOCOL
}
});
req.end();
req.on("error", vow.callback);
req.on("upgrade", function (res, socket, head) {
socket.end();
vow.callback(null, {
res: res,
head: head
});
});
},
"the data is correct": function (topic) {
assert.strictEqual(topic.head.toString("utf8"), "dogcow");
}
},
"used by the Hub Client": {
// TODO: Handle without trailing slash.
topic: hub.clientTopic(route + "/"),
teardown: function (topic) {
topic.client.end();
},
"a browser for testing": hub.phantomContext({
"visits Yeti": testContext
})
}
}
};
}
function attachServerBatch(definition) {
var batch = {},
routeWords = ["foo", "bar", "baz", "quux"];
Object.keys(definition).forEach(function (name) {
var options = definition[name],
route = "/" + routeWords.sort(function () {
return 1 - Math.random() * 2;
}).join("-");
batch[name] = attachServerContext(visitorContext(options));
batch[name + " with a custom route"] = attachServerContext(visitorContext(options), route);
});
return batch;
}
var basedir = path.join(__dirname, "..", "..");
function fixtures(basenames) {
return basenames.map(function (basename) {
return path.join(__dirname, "fixture", basename);
});
}
function withTests() {
return {
basedir: basedir,
tests: fixtures(Array.prototype.slice.call(arguments))
};
}
vows.describe("Yeti Functional")
.addBatch(hub.functionalContext({
"visits Yeti": visitorContext(withTests("basic.html", "local-js.html", "404-script.html"))
}))
.addBatch(hub.functionalContext({
"visits Yeti with a query string parameter": visitorContext({
basedir: basedir,
tests: fixtures(["query-string.html"]),
query: "dogcow=moof"
})
}))
.addBatch(hub.functionalContext({
"visits Yeti with test that will timeout": clientTimeoutContext({
basedir: basedir,
tests: fixtures(["long-async.html", "basic.html"]),
timeout: 3 // long-async.html takes 10s to run, we expect it to be skipped
})
}))
.addBatch(hub.functionalContext({
"visits Yeti then aborts during the batch": clientFailureContext(withTests("long-async.html"))
}))
.addBatch(hub.functionalContext({
"visits Yeti with invalid files": errorContext(withTests("this-file-does-not-exist.html"))
}))
.addBatch(attachServerBatch({
"A HTTP server with an upgrade listener (for Yeti paths)": {
tests: ["/fixture"],
useProxy: false
}
}))
.export(module);