static/js/zamboni/validator.js (462 lines of code) (raw):
import $ from 'jquery';
import { sortBy } from 'underscore';
import { format } from '../lib/format';
$(document).ready(function () {
if ($('.addon-validator-suite').length) {
initValidator();
}
});
function initValidator($doc) {
$doc = $doc || $(document);
function inherit(OtherClass, constructor) {
let NewClass = function () {
OtherClass.apply(this, arguments);
if (typeof constructor !== 'undefined') {
constructor.apply(this, arguments);
}
};
$.extend(NewClass.prototype, OtherClass.prototype);
return NewClass;
}
function emptyFn() {
return null;
}
function ResultsTier($suite, tierId, options) {
if (typeof options === 'undefined') {
options = {};
}
if (typeof options.app === 'undefined') {
options.app = null;
}
if (typeof options.testsWereRun === 'undefined') {
options.testsWereRun = true;
}
this.$results = $('.results', $suite);
this.app = options.app;
this.testsWereRun = options.testsWereRun;
this.counts = { error: 0, warning: 0, notice: 0 };
this.tierId = tierId;
this.$suite = $suite;
this.$dom = $('#suite-results-tier-' + tierId, $suite);
if (!this.$dom.length) {
this.$dom = this.createDom();
this.$results.append(this.$dom);
}
this.$tierResults = $('.tier-results', this.$dom);
this.wakeUp();
}
ResultsTier.prototype.clear = function () {
this.$tierResults.empty();
};
ResultsTier.prototype.tallyMsgType = function (type_) {
this.counts[type_] += 1;
};
ResultsTier.prototype.createDom = function () {
let $tier = $($('.template', this.$suite).html().trim());
$tier.attr('id', 'suite-results-tier-' + this.tierId);
return $tier;
};
ResultsTier.prototype.summarize = function () {
let sm = resultSummary(
this.counts.error,
this.counts.warning,
this.counts.notice,
this.testsWereRun,
),
resultClass,
summaryMsg;
$('.result-summary', this.$dom)
.css('visibility', 'visible')
.empty()
.text(sm);
if (this.counts.error) {
resultClass = 'tests-failed';
} else if (this.counts.warning) {
resultClass = 'tests-passed-warnings';
} else if (this.counts.notice) {
resultClass = 'tests-passed-notices';
} else {
if (this.testsWereRun) {
summaryMsg = gettext('All tests passed successfully.');
resultClass = 'tests-passed';
} else {
summaryMsg = gettext('These tests were not run.');
resultClass = 'tests-notrun';
// No summary since no tests were run:
$('.result-summary', this.$dom).html(' ');
}
this.$tierResults.append('<span>' + summaryMsg + '</span>');
}
this.$tierResults
.removeClass(
'ajax-loading tests-failed ' +
'tests-passed tests-passed-warnings ' +
'tests-passed-notices tests-notrun',
)
.addClass(resultClass);
if ($('.test-tier', this.$suite).length) {
this.topSummary();
}
return this.counts;
};
ResultsTier.prototype.topSummary = function () {
let $top = $(
'[class~="test-tier"]' + '[data-tier="' + this.tierId + '"]',
this.$suite,
),
summaryMsg = resultSummary(
this.counts.error,
this.counts.warning,
this.counts.notice,
this.testsWereRun,
);
$('.tier-summary', $top).text(summaryMsg);
$top.removeClass(
'ajax-loading',
'tests-failed',
'tests-passed',
'tests-notrun',
);
if (this.counts.error > 0) {
$top.addClass('tests-failed');
} else if (this.counts.warning > 0) {
$top.addClass('tests-warnings');
} else if (this.testsWereRun) {
$top.addClass('tests-passed');
} else {
$top.addClass('tests-notrun');
}
};
ResultsTier.prototype.wakeUp = function () {
let $title = $('h4', this.$dom),
changeLink;
$('.tier-results', this.$dom).empty();
this.$dom.removeClass('hidden');
this.$dom.show();
if (this.app) {
// Override the title with a special app/version title
$title.text(
format(
'{0} {1} {2}',
this.app.trans[this.app.guid],
this.app.version,
gettext('Tests'),
),
);
changeLink =
this.app.versionChangeLinks[this.app.guid + ' ' + this.app.version];
if (changeLink) {
this.$dom.prepend(
format(
'<a class="version-change-link" href="{0}">{1}</a>',
changeLink,
// L10n: Example: Changes in Firefox 5
gettext(
format(
'Changes in {0} {1}',
this.app.trans[this.app.guid],
/\d+/.exec(this.app.version),
),
),
),
);
}
} else if (!$title.text()) {
$title.text(gettext('Tests'));
}
$('.tier-results', this.$dom).removeClass('ajax-loading');
};
function MsgVisitor(suite, data) {
this.$suite = suite;
this.data = data;
this.$results = $('.results', suite);
this.msgSet = {};
this.tiers = {};
this.appTrans = null;
this.versionChangeLinks = null;
this.allCounts = { error: 0, warning: 0 };
this.fileURL = suite.data('fileUrl');
this.fileID = suite.data('fileId');
}
MsgVisitor.prototype.createTier = function (tierId, options) {
let tier = new ResultsTier(this.$suite, tierId, this.tierOptions(options));
return tier;
};
MsgVisitor.prototype.finish = function () {
let self = this;
$('.result', this.$suite).each(function (i, res) {
if (!$('.msg', res).length) {
// No messages so no tier was created.
self.getTier($('.tier-results', res).attr('data-tier'));
}
});
$.each(this.tiers, function (tierId, tier) {
let tierSum = tier.summarize();
self.allCounts.error += tierSum.error;
self.allCounts.warning += tierSum.warning;
});
};
MsgVisitor.prototype.clear = function () {
$.each(this.tiers, function (tierId, tier) {
tier.clear();
});
};
MsgVisitor.prototype.getMsgType = function (msg) {
return msg['type'];
};
MsgVisitor.prototype.getTier = function (tierId, options) {
if (typeof options === 'undefined') {
options = { app: null };
}
if (
!options.app &&
this.data.validation.ending_tier &&
this.data.validation.ending_tier < tierId
) {
options.testsWereRun = false;
}
if (typeof this.tiers[tierId] === 'undefined') {
this.tiers[tierId] = this.createTier(tierId, options);
}
return this.tiers[tierId];
};
MsgVisitor.prototype.filterMessage = function (msg) {
return !(this.hideIgnored && msg.ignored);
};
MsgVisitor.prototype.message = function (msg, options) {
if (!this.filterMessage(msg)) {
return;
}
if (typeof this.msgSet[msg.uid] !== 'undefined') {
return;
}
this.msgSet[msg.uid] = true;
let tier = this.getTier(msg.tier, options),
msgDiv = $('<div class="msg"><h5></h5></div>'),
effectiveType = this.getMsgType(msg),
prefix = effectiveType == 'error' ? gettext('Error') : gettext('Warning');
tier.tallyMsgType(effectiveType);
msgDiv.attr('id', 'v-msg-' + msg.uid);
msgDiv.addClass('msg-' + effectiveType);
// The "message" and "description" properties are escaped and linkified
// before we receive them.
$('h5', msgDiv).html(msg.message); // Sanitized HTML value.
// The validator returns the "description" as either string, or
// arrays of strings. We turn it into arrays when sanitizing.
$.each(msg.description, function (i, val) {
let $desc = $('<p>').html(val); // Sanitized HTML value.
if (i === 0) {
$desc.prepend(format('<strong>{0}:</strong> ', prefix));
}
msgDiv.append($desc);
});
if (msg.file) {
let file = msg.file;
if (typeof file !== 'string') {
// For sub-packages, this will be a list of archive paths and
// a final file path, which we need to turn into a string.
// ['foo.xpi', 'chrome/thing.jar', 'content/file.js']
file = file.join('/');
}
let $link;
if (this.fileURL && this.fileID) {
let url = this.fileURL + '?path=' + file;
if (msg.line) {
url += '#L' + msg.line;
}
$link = $('<a>', {
href: url,
text: file,
target: 'file-viewer-' + this.fileID,
});
} else {
// There's no file browse URL for bare file uploads, so
// just display a path without a link to the sources.
$link = $('<span>', { text: file });
}
let $context = $('<div class="context">').append(
$('<div class="file">').append($link),
);
if (msg.context) {
let $code = $('<div class="code"></div>');
let $lines = $('<div class="lines"></div>');
let $innerCode = $('<div class="inner-code"></div>');
$code.append($lines, $innerCode);
// The line number in the message refers to the middle
// line of the context, so adjust accordingly.
let offset = Math.floor(msg.context.length / 2);
msg.context = formatCodeIndentation(msg.context);
$.each(msg.context, function (idx, code) {
if (code != null) {
$lines.append($('<div>', { text: msg.line + idx - offset }));
$innerCode.append($('<div>', { text: code }));
}
});
$context.append($code);
} else if (msg.line && typeof msg.column !== 'undefined') {
// Normally, the line number would be displayed with the
// context. If we have no context, display it with the
// filename.
$link.text(
format(gettext('{0} line {1} column {2}'), [
file,
msg.line,
msg.column,
]),
);
} else if (msg.line) {
$link.text(format(gettext('{0} line {1}'), [file, msg.line]));
}
msgDiv.append($context);
}
$('.tier-results', tier.$dom).append(msgDiv);
};
MsgVisitor.prototype.tierOptions = function (options) {
if (options && options.app) {
options.app.trans = this.appTrans;
options.app.versionChangeLinks = this.versionChangeLinks;
}
return options;
};
function buildResults(suite, data) {
let vis,
validation = data.validation,
summaryTxt;
function sortByType(messages) {
let ordering = ['error', 'warning', 'notice', undefined /* no type */];
return sortBy(messages, function (msg) {
return ordering.indexOf(msg.type);
});
}
function rebuildResults() {
vis = new MsgVisitor(suite, data);
$.each(sortByType(validation.messages), function (i, msg) {
vis.message(msg);
});
vis.finish();
if (validation.errors > 0) {
summaryTxt = gettext('Add-on failed validation.');
} else {
summaryTxt = gettext('Add-on passed validation.');
}
$('.suite-summary span', suite).text(summaryTxt);
$('.suite-summary', suite).show();
}
rebuildResults();
}
function resultSummary(numErrors, numWarnings, numNotices, testsWereRun) {
if (!testsWereRun) {
return gettext('These tests were not run.');
}
// e.g. '1 error, 3 warnings'
let errors = format(ngettext('{0} error', '{0} errors', numErrors), [
numErrors,
]),
warnings = format(ngettext('{0} warning', '{0} warnings', numWarnings), [
numWarnings,
]),
notices = format(ngettext('{0} notice', '{0} notices', numNotices), [
numNotices,
]);
return format('{0}, {1}, {2}', errors, warnings, notices);
}
function formatCodeIndentation(lines) {
// Replaces leading tabs with spaces, and then trims the
// smallest common indentation space from each line.
function retab(line, tabstops) {
// Replaces tabs with spaces, to match the given tab stops.
let SPACES = ' ';
tabstops = Math.min(tabstops || 4, SPACES.length);
function replace_tab(full_match, non_tab) {
if (non_tab) {
position += non_tab.length;
return non_tab;
} else {
let pos = position;
position += position % tabstops || tabstops;
return SPACES.substr(0, position - pos);
}
}
let position = 0;
return line.replace(/([^\t]+)|\t/g, replace_tab);
}
// Retab all lines and find the common indent.
let indent = Infinity;
lines = lines.map(function (line) {
// When the context line is at the start or end of the file,
// the line before or after the context line will be null.
if (line == null) {
return null;
}
// We need the replace function to run even if there's no
// whitespace, so `indent` is properly updated. Stick with
// \s* rather than \s+.
return line.replace(/^(\s*)/, function (match) {
match = retab(match);
indent = Math.min(indent, match.length);
return match;
});
});
// Trim off the common white space.
return lines.map(function (line) {
// Line may be null. Do not try to slice null.
return line && line.slice(indent);
});
}
$('.addon-validator-suite', $doc).on('validate', function (e) {
let el = $(this),
data = el.data();
if (data.annotateUrl) {
el.on('change', '.ignore-duplicates-checkbox', function (event) {
let $target = $(event.target);
$.ajax({
type: 'POST',
url: data.annotateUrl,
data: {
message: $target.attr('name'),
ignore_duplicates: $target.prop('checked') || undefined,
},
dataType: 'json',
});
});
}
if (data.validation) {
buildResults(el, { validation: data.validation });
return;
}
$('.test-tier,.tier-results', el).addClass('ajax-loading');
$.ajax({
type: 'POST',
url: data.validateurl,
data: {},
success: function (data) {
if (data.validation == '') {
// Note: traceback is in data.error
data.validation = {
ending_tier: 1,
messages: [
{
type: 'error',
message: gettext('Error'),
description: [
gettext(
'Validation task could not ' +
'complete or completed with ' +
'errors',
),
],
tier: 1,
uid: '__global_error__',
},
],
};
}
buildResults(el, data);
el.trigger('success.validation');
},
error: function (XMLHttpRequest, textStatus, errorThrown) {
buildResults(el, {
validation: {
ending_tier: 1,
messages: [
{
type: 'error',
message: gettext('Error'),
description: [gettext('Internal server error')],
tier: 1,
uid: '__global_error__',
},
],
},
});
el.trigger('badresponse.validation');
},
dataType: 'json',
});
});
// Validate when the page loads.
$('#addon-validator-suite').trigger('validate');
}