diff --git a/Netdisco/t/11-portsort.t b/Netdisco/t/11-portsort.t
new file mode 100755
index 00000000..3563f3b6
--- /dev/null
+++ b/Netdisco/t/11-portsort.t
@@ -0,0 +1,18 @@
+#!/usr/bin/perl
+use strict;
+use warnings;
+
+use Test::More;
+use Env::Path;
+use FindBin qw( $Bin );
+
+my @phantomjs = Env::Path->PATH->Whence('phantomjs');
+my $phantomjs = scalar @phantomjs ? $phantomjs[0] : $ENV{ND_PHANTOMJS};
+
+if ( !-x $phantomjs ) {
+ plan skip_all =>
+ "phantomjs not found, please set ND_PHANTOMJS or install phantomjs to the default location";
+}
+else {
+ exec( $phantomjs, "$Bin/js/run_qunit.js", "$Bin/html/portsort.html" );
+}
diff --git a/Netdisco/t/html/portsort.html b/Netdisco/t/html/portsort.html
new file mode 100644
index 00000000..cfa2cd99
--- /dev/null
+++ b/Netdisco/t/html/portsort.html
@@ -0,0 +1,243 @@
+
+
+
+
+ portsort.js test suite
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Netdisco/t/js/qunit-tap.js b/Netdisco/t/js/qunit-tap.js
new file mode 100644
index 00000000..7031e6c1
--- /dev/null
+++ b/Netdisco/t/js/qunit-tap.js
@@ -0,0 +1,378 @@
+/**
+ * QUnit-TAP - A TAP Output Producer Plugin for QUnit
+ *
+ * https://github.com/twada/qunit-tap
+ * version: 1.4.2
+ *
+ * Copyright (c) 2010-2014 Takuto Wada
+ * Dual licensed under the MIT and GPLv2 licenses.
+ * https://raw.github.com/twada/qunit-tap/master/MIT-LICENSE.txt
+ * https://raw.github.com/twada/qunit-tap/master/GPL-LICENSE.txt
+ *
+ * A part of extend function is:
+ * Copyright 2012 jQuery Foundation and other contributors
+ * Released under the MIT license.
+ * http://jquery.org/license
+ *
+ * A part of stripTags function is:
+ * Copyright (c) 2005-2010 Sam Stephenson
+ * Released under the MIT license.
+ * http://prototypejs.org
+ */
+(function (root, factory) {
+ 'use strict';
+
+ // using returnExports UMD pattern
+ if (typeof define === 'function' && define.amd) {
+ define(factory);
+ } else if (typeof exports === 'object') {
+ module.exports = factory();
+ } else {
+ root.qunitTap = factory();
+ }
+}(this, function () {
+ 'use strict';
+
+ var qunitTapVersion = '1.4.2',
+ slice = Array.prototype.slice;
+
+ // borrowed from qunit.js
+ function extend (a, b) {
+ var prop;
+ for (prop in b) {
+ if (b.hasOwnProperty(prop)) {
+ if (typeof b[prop] === 'undefined') {
+ delete a[prop];
+ } else {
+ a[prop] = b[prop];
+ }
+ }
+ }
+ return a;
+ }
+
+ function indexOf (ary, element) {
+ var i;
+ for (i = 0; i < ary.length; i += 1) {
+ if (ary[i] === element) {
+ return i;
+ }
+ }
+ return -1;
+ }
+
+ function removeElement (ary, element) {
+ var index = indexOf(ary, element);
+ if (index !== -1) {
+ return ary.splice(index, 1);
+ } else {
+ return [];
+ }
+ }
+
+ function isPlanRequired (conf) {
+ return (typeof conf !== 'undefined' && typeof conf.requireExpects !== 'undefined' && conf.requireExpects);
+ }
+
+ function isPassed (details) {
+ return !!(details.result);
+ }
+
+ function isFailed (details) {
+ return !(isPassed(details));
+ }
+
+ function isAssertOkFailed (details) {
+ return isFailed(details) && typeof details.expected === 'undefined' && typeof details.actual === 'undefined';
+ }
+
+ // borrowed from prototype.js
+ // not required since QUnit.log receives raw data (details). see jquery/qunit@c2cde34
+ function stripTags (str) {
+ if (!str) {
+ return str;
+ }
+ return str.replace(/<\w+(\s+("[^"]*"|'[^']*'|[^>])+)?>|<\/\w+>/gi, '');
+ }
+
+ function escapeLineEndings (str) {
+ return str.replace(/(\r?\n)/g, '$ ');
+ }
+
+ function ltrim (str) {
+ return str.replace(/^\s+/, '');
+ }
+
+ function noop (obj) {
+ return obj;
+ }
+
+ function render (desc, fieldName, fieldValue, formatter) {
+ desc.push(fieldName + ': ' + formatter(fieldValue));
+ }
+
+ function renderIf (shouldRender, desc, fieldName, fieldValue, formatter) {
+ if (!shouldRender || typeof fieldValue === 'undefined') {
+ return;
+ }
+ render(desc, fieldName, fieldValue, formatter);
+ }
+
+ function formatTestLine (testLine, rest) {
+ if (!rest) {
+ return testLine;
+ }
+ return testLine + ' - ' + escapeLineEndings(rest);
+ }
+
+ var extractDetailsFrom = (function () {
+ var detailsExtractor;
+
+ function setupExtractor (logArguments) {
+ switch (logArguments.length) {
+ case 1: // details
+ detailsExtractor = function (args) { return args[0]; };
+ break;
+ case 2: // result, message(with tags)
+ detailsExtractor = function (args) { return {result: args[0], message: stripTags(args[1])}; };
+ break;
+ case 3: // result, message, details
+ detailsExtractor = function (args) { return args[2]; };
+ break;
+ default:
+ throw new Error('QUnit-TAP does not support QUnit#log arguments like this.');
+ }
+ }
+
+ return function (logArguments) {
+ if (detailsExtractor) {
+ return detailsExtractor(logArguments);
+ }
+ setupExtractor(logArguments);
+ return detailsExtractor(logArguments);
+ };
+ })();
+
+ var createCallbackAppenderFor = function (qu) {
+ // detect QUnit's multipleCallbacks feature. see jquery/qunit@34f6bc1
+ var isMultipleLoggingCallbacksSupported =
+ (typeof qu.config !== 'undefined' &&
+ typeof qu.config.log !== 'undefined' &&
+ typeof qu.config.done !== 'undefined' &&
+ typeof qu.config.testDone !== 'undefined' &&
+ typeof qu.config.moduleStart !== 'undefined' &&
+ typeof qu.config.testStart !== 'undefined');
+ return function (subject, observer, event) {
+ var originalLoggingCallback = subject[event],
+ callback;
+ if (isMultipleLoggingCallbacksSupported) {
+ callback = function () {
+ // make listener methods (moduleStart,testStart,log, ...) overridable.
+ observer[event].apply(observer, slice.apply(arguments));
+ };
+ originalLoggingCallback(callback);
+ } else if (typeof originalLoggingCallback === 'function') {
+ // do not overwrite old-style logging callbacks
+ callback = function () {
+ var args = slice.apply(arguments);
+ originalLoggingCallback.apply(subject, args);
+ observer[event].apply(observer, args);
+ };
+ subject[event] = callback;
+ }
+ return callback;
+ };
+ };
+
+
+ /**
+ * QUnit-TAP - A TAP Output Producer Plugin for QUnit
+ * @param qunitObject QUnit object reference.
+ * @param printLikeFunction print-like function for TAP output (assumes line-separator is added by this function for each call).
+ * @param options configuration options to customize default behavior.
+ * @return object to provide QUnit-TAP API and customization subject.
+ */
+ function qunitTap(qunitObject, printLikeFunction, options) {
+ if (!qunitObject) {
+ throw new Error('should pass QUnit object reference. Please check QUnit\'s "require" path if you are using Node.js (or any CommonJS env).');
+ } else if (typeof printLikeFunction !== 'function') {
+ throw new Error('should pass print-like function');
+ }
+
+ var qu = qunitObject,
+ tap = {},
+ jsDumpExists = (typeof qu.jsDump !== 'undefined' && typeof qu.jsDump.parse === 'function'),
+ explain = (jsDumpExists ? function explain (obj) { return qu.jsDump.parse(obj); } : noop),
+ deprecateOption = function deprecateOption (optionName, fallback) {
+ // option deprecation and fallback function
+ if (!options || typeof options !== 'object') {
+ return;
+ }
+ if (typeof options[optionName] === 'undefined') {
+ return;
+ }
+ printLikeFunction('# WARNING: Option "' + optionName + '" is deprecated and will be removed in future version.');
+ fallback(options[optionName]);
+ },
+ targetEvents = [
+ 'moduleStart',
+ 'testStart',
+ 'log',
+ 'testDone',
+ 'done'
+ ],
+ registeredCallbacks = {};
+
+
+ tap.config = extend(
+ {
+ initialCount: 1,
+ showModuleNameOnFailure: true,
+ showTestNameOnFailure: true,
+ showExpectationOnFailure: true,
+ showSourceOnFailure: true
+ },
+ options
+ );
+ deprecateOption('noPlan', function (flag) {
+ printLikeFunction('# Now QUnit-TAP works as with "noPlan: true" by default. If you want to delare plan explicitly, please use "QUnit.config.requireExpects" option instead.');
+ tap.config.noPlan = flag;
+ });
+ deprecateOption('count', function (count) {
+ tap.config.initialCount = (count + 1);
+ });
+ deprecateOption('showDetailsOnFailure', function (flag) {
+ tap.config.showModuleNameOnFailure = flag;
+ tap.config.showTestNameOnFailure = flag;
+ tap.config.showExpectationOnFailure = flag;
+ tap.config.showSourceOnFailure = flag;
+ });
+ tap.VERSION = qunitTapVersion;
+ tap.puts = printLikeFunction;
+ tap.count = tap.config.initialCount - 1;
+ tap.expectedCount = tap.config.initialCount - 1;
+
+ function isEnabled (configName) {
+ return tap.config[configName];
+ }
+
+ function formatDetails (details) {
+ if (isPassed(details)) {
+ return details.message;
+ }
+ var desc = [];
+ if (details.message) {
+ desc.push(details.message);
+ }
+ if (isEnabled('showExpectationOnFailure') && !(isAssertOkFailed(details))) {
+ render(desc, 'expected', details.expected, explain);
+ render(desc, 'got', details.actual, explain);
+ }
+ renderIf(isEnabled('showTestNameOnFailure'), desc, 'test', details.name, noop);
+ renderIf(isEnabled('showModuleNameOnFailure'), desc, 'module', details.module, noop);
+ renderIf(isEnabled('showSourceOnFailure'), desc, 'source', details.source, ltrim);
+ return desc.join(', ');
+ }
+
+ function printPlanLine (toCount) {
+ tap.puts(tap.config.initialCount + '..' + toCount);
+ }
+
+ function unsubscribeEvent (eventName) {
+ var listeners;
+ if (indexOf(targetEvents, eventName) === -1) {
+ return;
+ }
+ listeners = qu.config[eventName];
+ if (typeof listeners === 'undefined') {
+ return;
+ }
+ removeElement(listeners, registeredCallbacks[eventName]);
+ }
+
+ function unsubscribeEvents (eventNames) {
+ var i;
+ for (i = 0; i < eventNames.length; i += 1) {
+ unsubscribeEvent(eventNames[i]);
+ }
+ }
+
+ tap.explain = explain;
+
+ tap.note = function note (obj) {
+ tap.puts(escapeLineEndings('# ' + obj));
+ };
+
+ tap.diag = function diag (obj) {
+ tap.note(obj);
+ return false;
+ };
+
+ tap.moduleStart = function moduleStart (arg) {
+ var name = (typeof arg === 'string') ? arg : arg.name;
+ tap.note('module: ' + name);
+ };
+
+ tap.testStart = function testStart (arg) {
+ var name = (typeof arg === 'string') ? arg : arg.name;
+ tap.note('test: ' + name);
+ };
+
+ tap.log = function log () {
+ var details = extractDetailsFrom(arguments),
+ testLine = '';
+ tap.count += 1;
+ if (isFailed(details)) {
+ testLine += 'not ';
+ }
+ testLine += ('ok ' + tap.count);
+ tap.puts(formatTestLine(testLine, formatDetails(details)));
+ };
+
+ tap.testDone = function testDone () {
+ if (isPlanRequired(qu.config)) {
+ tap.expectedCount += qu.config.current.expected;
+ }
+ };
+
+ tap.done = function done () {
+ if (typeof tap.config.noPlan !== 'undefined' && !(tap.config.noPlan)) {
+ // Do nothing until removal of 'noPlan' option.
+ } else if (isPlanRequired(qu.config)) {
+ printPlanLine(tap.expectedCount);
+ } else {
+ printPlanLine(tap.count);
+ }
+ };
+
+ tap.unsubscribe = function unsubscribe () {
+ if (typeof qu.config === 'undefined') {
+ return;
+ }
+ if (arguments.length === 0) {
+ unsubscribeEvents(targetEvents);
+ } else {
+ unsubscribeEvents(slice.apply(arguments));
+ }
+ };
+
+ (function () {
+ var appendCallback = createCallbackAppenderFor(qu),
+ eventName, i, callback;
+ for (i = 0; i < targetEvents.length; i += 1) {
+ eventName = targetEvents[i];
+ callback = appendCallback(qu, tap, eventName);
+ registeredCallbacks[eventName] = callback;
+ }
+ })();
+
+ return tap;
+ }
+
+ qunitTap.qunitTap = function () {
+ throw new Error('[BC BREAK] Since 1.4.0, QUnit-TAP exports single qunitTap function as module.exports. Therefore, require("qunit-tap") returns qunitTap function itself. Please fix your code if you are using Node.js (or any CommonJS env).');
+ };
+
+ // using substack pattern (export single function)
+ return qunitTap;
+}));
diff --git a/Netdisco/t/js/run_qunit.js b/Netdisco/t/js/run_qunit.js
new file mode 100644
index 00000000..c6d236f5
--- /dev/null
+++ b/Netdisco/t/js/run_qunit.js
@@ -0,0 +1,74 @@
+/**
+ * Wait until the test condition is true or a timeout occurs. Useful for waiting
+ * on a server response or for a ui change (fadeIn, etc.) to occur.
+ *
+ * @param testFx javascript condition that evaluates to a boolean,
+ * it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or
+ * as a callback function.
+ * @param onReady what to do when testFx condition is fulfilled,
+ * it can be passed in as a string (e.g.: "1 == 1" or "$('#bar').is(':visible')" or
+ * as a callback function.
+ * @param timeOutMillis the max amount of time to wait. If not specified, 3 sec is used.
+ */
+function waitFor(testFx, onReady, timeOutMillis) {
+ var maxtimeOutMillis = timeOutMillis ? timeOutMillis : 3001, //< Default Max Timout is 3s
+ start = new Date().getTime(),
+ condition = false,
+ interval = setInterval(function() {
+ if ( (new Date().getTime() - start < maxtimeOutMillis) && !condition ) {
+ // If not time-out yet and condition not yet fulfilled
+ condition = (typeof(testFx) === "string" ? eval(testFx) : testFx()); //< defensive code
+ } else {
+ if(!condition) {
+ // If condition still not fulfilled (timeout but condition is 'false')
+ console.log("# 'waitFor()' timeout");
+ phantom.exit(1);
+ } else {
+ // Condition fulfilled (timeout and/or condition is 'true')
+ // console.log("'waitFor()' finished in " + (new Date().getTime() - start) + "ms.");
+ typeof(onReady) === "string" ? eval(onReady) : onReady(); //< Do what it's supposed to do once the condition is fulfilled
+ clearInterval(interval); //< Stop this interval
+ }
+ }
+ }, 100); //< repeat check every 250ms
+};
+
+
+if (phantom.args.length === 0 || phantom.args.length > 2) {
+ console.log('Usage: run-qunit.js URL');
+ phantom.exit(1);
+}
+
+var page = new WebPage();
+
+// Route "console.log()" calls from within the Page context to the main Phantom context (i.e. current "this")
+page.onConsoleMessage = function(msg) {
+ console.log(msg);
+};
+
+page.open(phantom.args[0], function(status){
+ if (status !== "success") {
+ console.log("Unable to access network");
+ phantom.exit(1);
+ } else {
+ waitFor(function(){
+ return page.evaluate(function(){
+ var el = document.getElementById('qunit-testresult');
+ if (el && el.innerText.match('completed')) {
+ return true;
+ }
+ return false;
+ });
+ }, function(){
+ var failedNum = page.evaluate(function(){
+ var el = document.getElementById('qunit-testresult');
+ // console.log(el.innerText);
+ try {
+ return el.getElementsByClassName('failed')[0].innerHTML;
+ } catch (e) { }
+ return 10000;
+ });
+ phantom.exit((parseInt(failedNum, 10) > 0) ? 1 : 0);
+ });
+ }
+});