/* globals add_completion_callback, Promise, showdown, done, assert_true, Ajv, on_event */ /** * Creates a JSONtest object. If the parameters are supplied * it also loads a referenced testFile, processes that file, loads any * referenced external assertions, and sets up event listeners to process the * user's test data. The loading is done asynchronously via Promises. The test * button's text is changed to Loading while it is processing, and to "Check * JSON" once the data is loaded. * * @constructor * @param {object} params * @param {string} [params.test] - object containing JSON test definition * @param {string} [params.testFile] - URI of a file with JSON test definition * @param {string} params.runTest - IDREF of an element that when clicked will run the test * @param {string} params.testInput - IDREF of an element that contains the JSON(-LD) to evaluate against the assertions in the test / testFile * @event DOMContentLoaded Calls init once DOM is fully loaded * @returns {object} Reference to the new object */ function JSONtest(params) { 'use strict'; this.Assertions = []; // object that will contain the assertions to process this.AssertionText = ""; // string that holds the titles of all the assertions in use this.DescriptionText = ""; this.Base = null; // URI "base" for the test suite being run this.TestDir = null; // URI "base" for the test case being run this.Params = null; // paramaters passed in this.Promise = null; // master Promise that resolves when intialization is complete this.Properties = null; // testharness_properties from the opening window this.SkipFailures = []; // list of assertionType values that should be skipped if their test would fail this.Test = null; // test being run this.AssertionCounter = 0;// keeps track of which assertion is being processed this._assertionCache = [];// Array to put loaded assertions into this._assertionText = []; // Array of text or nested arrays of assertions this._loading = true; showdown.extension('strip', function() { return [ { type: 'output', regex: /

/, replace: '' }, { type: 'output', regex: /<\/p>$/, replace: '' } ]; }); this.markdown = new showdown.Converter({ extensions: [ 'strip' ] }) ; var pending = [] ; // set up in case DOM finishes loading early pending.push(new Promise(function(resolve) { on_event(document, "DOMContentLoaded", function() { resolve(true); }.bind(this)); }.bind(this))); // create an ajv object that will stay around so that caching // of schema that are compiled just works this.ajv = new Ajv({allErrors: true, validateSchema: false}) ; // determine the base URI for the test collection. This is // the top level folder in the test "document.location" var l = document.location; var p = l.pathname; this.TestDir = p.substr(0, 1+p.lastIndexOf('/')); this.Base = p.substr(0, 1+p.indexOf('/', 1)); // if we are under runner, then there are props in the parent window // // if "output" is set in that, then pause at the end of running so the output // can be analyzed. @@@TODO@@@ if (window && window.opener && window.opener.testharness_properties) { this.Properties = window.opener.testharness_properties; } this.Params = params; // if there is a list of definitions in the params, // include them if (this.Params.schemaDefs) { var defPromise = new Promise(function(resolve, reject) { var promisedSchema = this.Params.schemaDefs.map(function(item) { return this.loadDefinition(item); }.bind(this)); // Once all the loadAssertion promises resolve... Promise.all(promisedSchema) .then(function (schemaContents) { this.ajv.addSchema(schemaContents); resolve(true); }.bind(this)) .catch(function(err) { reject(err); }.bind(this)); }.bind(this)); // these schema need to load up too pending.push(defPromise) ; } // start by loading the test (it might be inline, but // loadTest deals with that pending.push(this.loadTest(params) .then(function(test) { // if the test is NOT an object, turn it into one if (typeof test === 'string') { test = JSON.parse(test) ; } this.Test = test; // Test should have information that we can put in the template if (test.description) { this.DescriptionText = test.description; } if (test.hasOwnProperty("skipFailures") && Array.isArray(test.skipFailures) ) { this.SkipFailures = test.skipFailures; } if (test.content) { // we have content if (typeof test.content === "string") { // the test content is a string - meaning it is a reference to a file of content var cPromise = new Promise(function(resolve, reject) { this.loadDefinition(test.content) .then(function(content) { if (typeof content === 'string') { content = JSON.parse(content) ; } test.content = content; resolve(true); }.bind(this)) .catch(function(err) { reject("Loading " + test.content + ": " + JSON.stringify(err)); }); }.bind(this)); pending.push(cPromise); } } return new Promise(function(resolve, reject) { if (test.assertions && typeof test.assertions === "object") { // we have at least one assertion // get the inline contents and the references to external files var assertFiles = this._assertionRefs(test.assertions); var promisedAsserts = assertFiles.map(function(item) { return this.loadAssertion(item); }.bind(this)); // Once all the loadAssertion promises resolve... Promise.all(promisedAsserts) .then(function (assertContents) { // assertContents has assertions in document order var typeMap = { 'must' : "[MANDATORY] ", 'may' : "[OPTIONAL] ", 'should' : "[RECOMMENDED] " }; var assertIdx = 0; // populate the display of assertions that are being exercised // returns the list of top level assertions to walk through var buildList = function(assertions, level) { if (level === undefined) { level = 1; } // accumulate the assertions - but only when level is 0 var list = [] ; var type = ""; if (assertions) { if (typeof assertions === "object" && assertions.hasOwnProperty('assertions')) { // this is a conditionObject if (level === 0) { list.push(assertContents[assertIdx]); } type = assertContents[assertIdx].hasOwnProperty('assertionType') ? assertContents[assertIdx].assertionType : "must" ; // ensure type defaults to must if (!typeMap.hasOwnProperty(type)) { type = "must"; } this.AssertionText += "

  • " + typeMap[type] + this.markdown.makeHtml(assertContents[assertIdx++].title); this.AssertionText += "
      "; buildList(assertions.assertions, level+1) ; this.AssertionText += "
  • \n"; } else { // it is NOT a conditionObject - must be an array assertions.forEach( function(assert) { if (typeof assert === "object" && Array.isArray(assert)) { this.AssertionText += "
      "; // it is a nested list - recurse buildList(assert, level+1) ; this.AssertionText += "
    \n"; } else if (typeof assert === "object" && !Array.isArray(assert) && assert.hasOwnProperty('assertions')) { if (level === 0) { list.push(assertContents[assertIdx]); } type = assertContents[assertIdx].hasOwnProperty('assertionType') ? assertContents[assertIdx].assertionType : "must" ; // ensure type defaults to must if (!typeMap.hasOwnProperty(type)) { type = "must"; } // there is a condition object in the array this.AssertionText += "
  • " + typeMap[type] + this.markdown.makeHtml(assertContents[assertIdx++].title); this.AssertionText += "
      "; buildList(assert, level+1) ; // capture the children too this.AssertionText += "
  • \n"; } else { if (level === 0) { list.push(assertContents[assertIdx]); } type = assertContents[assertIdx].hasOwnProperty('assertionType') ? assertContents[assertIdx].assertionType : "must" ; // ensure type defaults to must if (!typeMap.hasOwnProperty(type)) { type = "must"; } this.AssertionText += "
  • " + typeMap[type] + this.markdown.makeHtml(assertContents[assertIdx++].title) + "
  • \n"; } }.bind(this)); } } return list; }.bind(this); // Assertions will ONLY contain the top level assertions this.Assertions = buildList(test.assertions, 0); resolve(true); }.bind(this)) .catch(function(err) { reject(err); }.bind(this)); } else { if (!test.assertions) { reject("Test has no assertion property"); } else { reject("Test assertion property is not an Array"); } } }.bind(this)); }.bind(this))); this.Promise = new Promise(function(resolve, reject) { // once the DOM and the test / assertions are loaded... set us up Promise.all(pending) .then(function() { this.loading = false; this.init(); resolve(this); }.bind(this)) .catch(function(err) { // loading the components failed somehow - report the errors and mark the test failed test( function() { assert_true(false, "Loading of test components failed: " +JSON.stringify(err)) ; }, "Loading test components"); done() ; reject("Loading of test components failed: "+JSON.stringify(err)); return ; }.bind(this)); }.bind(this)); return this; } JSONtest.prototype = { /** * @listens click */ init: function() { 'use strict'; // set up a handler var runButton = document.getElementById(this.Params.runTest) ; var closeButton = document.getElementById(this.Params.closeWindow) ; var testInput = document.getElementById(this.Params.testInput) ; var assertion = document.getElementById("assertion") ; var desc = document.getElementById("testDescription") ; if (!this.loading) { if (runButton) { runButton.disabled = false; runButton.value = "Check JSON"; } if (desc) { desc.innerHTML = this.DescriptionText; } if (assertion) { assertion.innerHTML = "
      " + this.AssertionText + "
    \n"; } } else { window.alert("Loading did not finish before init handler was called!"); } // @@@TODO@@@ implement the output showing handler if (0 && this.Properties && this.Properties.output && closeButton) { // set up a callback add_completion_callback( function() { var p = new Promise(function(resolve) { closeButton.style.display = "inline"; closeButton.disabled = false; on_event(closeButton, "click", function() { resolve(true); }); }.bind(this)); p.then(); }.bind(this)); } if (runButton) { on_event(runButton, "click", function() { // user clicked var content = testInput.value; runButton.disabled = true; // make sure content is an object if (typeof content === "string") { try { content = JSON.parse(content) ; } catch(err) { // if the parsing failed, create a special test and mark it failed test( function() { assert_true(false, "Parse of JSON failed: " + err) ; }, "Parsing submitted input"); // and just give up done(); return ; } } // iterate over all of the tests for this instance this.runTests(this.Assertions, content); // explicitly tell the test framework we are done done(); }.bind(this)); } }, // runTests - process tests /** * @param {object} assertions - List of assertions to process * @param {string} content - JSON(-LD) to be evaluated * @param {string} [testAction='continue'] - state of test processing (in parent when recursing) * @param {integer} [level=0] - depth of recursion since assertion lists can nest * @param {string} [compareWith='and'] - the way the results of the referenced assertions should be compared * @returns {string} - the testAction resulting from evaluating all of the assertions */ runTests: function(assertions, content, testAction, level, compareWith) { 'use strict'; // level if (level === undefined) { level = 1; } // testAction if (testAction === undefined) { testAction = 'continue'; } // compareWith if (compareWith === undefined) { compareWith = 'and'; } var typeMap = { 'must' : "", 'may' : "INFORMATIONAL: ", 'should' : "WARNING: " }; // for each assertion (in order) load the external json schema if // one is referenced, or use the inline schema if supplied // validate content against the referenced schema var theResults = [] ; if (assertions) { assertions.forEach( function(assert, num) { var expected = assert.hasOwnProperty('expectedResult') ? assert.expectedResult : 'valid' ; var message = assert.hasOwnProperty('errorMessage') ? assert.errorMessage : "Result was not " + expected; var type = assert.hasOwnProperty('assertionType') ? assert.assertionType.toLowerCase() : "must" ; if (!typeMap.hasOwnProperty(type)) { type = "must"; } // first - what is the type of the assert if (typeof assert === "object" && !Array.isArray(assert)) { if (assert.hasOwnProperty("compareWith") && assert.hasOwnProperty("assertions") && Array.isArray(assert.assertions) ) { // this is a comparisonObject var r = this.runTests(assert.assertions, content, testAction, level+1, assert.compareWith); // r is an object that contains, among other things, an array of results from the child assertions testAction = r.action; // evaluate the results against the compareWith setting var result = true; var data = r.results ; var i; if (assert.compareWith === "or") { result = false; for(i = 0; i < data.length; i++) { if (data[i]) { result = true; } } } else { for(i = 0; i < data.length; i++) { if (!data[i]) { result = false; } } } // create a test and push the result test(function() { var newAction = this.determineAction(assert, result) ; // next time around we will use this action testAction = newAction; var err = ";"; if (testAction === 'abort') { err += "; Aborting execution of remaining assertions;"; } else if (testAction === 'skip') { err += "; Skipping execution of remaining assertions at level " + level + ";"; } if (result === false) { // test result was unexpected; use message assert_true(result, message + err); } else { assert_true(result, err) ; } }.bind(this), "" + level + ":" + (num+1) + " " + assert.title); // we are going to return out of this return; } } else if (typeof assert === "object" && Array.isArray(assert)) { // it is a nested list - recurse var o = this.runTests(assert, content, testAction, level+1); if (o.result && o.result === 'abort') { // we are bailing out testAction = 'abort'; } } if (testAction === 'abort') { return {action: 'abort' }; } var schemaName = "inline " + level + ":" + (num+1); if (typeof assert === "string") { // the assertion passed in is a file name; find it in the cache if (this._assertionCache[assert]) { assert = this._assertionCache[assert]; } else { test( function() { assert_true(false, "Reference to assertion " + assert + " at level " + level + ":" + (num+1) + " unresolved") ; }, "Processing " + assert); return ; } } if (assert.assertionFile) { schemaName = "external file " + assert.assertionFile + " " + level + ":" + (num+1); } var validate = null; try { validate = this.ajv.compile(assert); } catch(err) { test( function() { assert_true(false, "Compilation of schema " + level + ":" + (num+1) + " failed: " + err) ; }, "Compiling " + schemaName); return ; } if (testAction === 'continue') { // a previous test told us to not run this test; skip it // test(function() { }, "SKIPPED: " + assert.title); // start an actual sub-test var valid = validate(content) ; var theResult = this.determineResult(assert, valid) ; // remember the result theResults.push(theResult); var newAction = this.determineAction(assert, theResult) ; // next time around we will use this action testAction = newAction; // only run the test if we are NOT skipping fails for some types // or the result is expected if ( theResult === true || !this.SkipFailures.includes(type) ) { test(function() { var err = ";"; if (validate.errors !== null && !assert.hasOwnProperty("errorMessage")) { err = "; Errors: " + this.ajv.errorsText(validate.errors) + ";" ; } if (testAction === 'abort') { err += "; Aborting execution of remaining assertions;"; } else if (testAction === 'skip') { err += "; Skipping execution of remaining assertions at level " + level + ";"; } if (theResult === false) { // test result was unexpected; use message assert_true(theResult, typeMap[type] + message + err); } else { assert_true(theResult, err) ; } }.bind(this), "" + level + ":" + (num+1) + " " + assert.title); } } }.bind(this)); } return { action: testAction, results: theResults} ; }, determineResult: function(schema, valid) { 'use strict'; var r = 'valid' ; if (schema.hasOwnProperty('expectedResult')) { r = schema.expectedResult; } if (r === 'valid' && valid || r === 'invalid' && !valid) { return true; } else { return false; } }, determineAction: function(schema, result) { 'use strict'; // mapping from results to actions var mapping = { 'failAndContinue' : 'continue', 'failAndSkip' : 'skip', 'failAndAbort' : 'abort', 'passAndContinue': 'continue', 'passAndSkip' : 'skip', 'passAndAbort' : 'abort' }; // if the result was as expected, then just keep going if (result) { return 'continue'; } var a = 'failAndContinue'; if (schema.hasOwnProperty('onUnexpectedResult')) { a = schema.onUnexpectedResult; } if (mapping[a]) { return mapping[a]; } else { return 'continue'; } }, // loadAssertion - load an Assertion from an external JSON file // // returns a promise that resolves with the contents of the assertion file loadAssertion: function(afile) { 'use strict'; if (typeof(afile) === 'string') { var theFile = this._parseURI(afile); // it is a file reference - load it return new Promise(function(resolve, reject) { this._loadFile("GET", theFile, true) .then(function(data) { data.assertionFile = afile; this._assertionCache[afile] = data; resolve(data); }.bind(this)) .catch(function(err) { if (typeof err === "object") { err.theFile = theFile; } reject(err); }); }.bind(this)); } else if (afile.hasOwnProperty("assertionFile")) { // this object is referecing an external assertion return new Promise(function(resolve, reject) { var theFile = this._parseURI(afile.assertionFile); this._loadFile("GET", theFile, true) .then(function(external) { // okay - we have an external object Object.keys(afile).forEach(function(key) { if (key !== 'assertionFile') { external[key] = afile[key]; } }); resolve(external); }.bind(this)) .catch(function(err) { if (typeof err === "object") { err.theFile = theFile; } reject(err); }); }.bind(this)); } else { // it is already a loaded assertion - just use it return new Promise(function(resolve) { resolve(afile); }); } }, // loadDefinition - load a JSON Schema definition from an external JSON file // // returns a promise that resolves with the contents of the definition file loadDefinition: function(dfile) { 'use strict'; return new Promise(function(resolve, reject) { this._loadFile("GET", this._parseURI(dfile), true) .then(function(data) { resolve(data); }.bind(this)) .catch(function(err) { reject(err); }); }.bind(this)); }, // loadTest - load a test from an external JSON file // // returns a promise that resolves with the contents of the // test loadTest: function(params) { 'use strict'; if (params.hasOwnProperty('testFile')) { // the test is referred to by a file name return this._loadFile("GET", params.testFile); } // else return new Promise(function(resolve, reject) { if (params.hasOwnProperty('test')) { resolve(params.test); } else { reject("Must supply a 'test' or 'testFile' parameter"); } }); }, _parseURI: function(theURI) { 'use strict'; // determine what the top level URI should be if (theURI.indexOf('/') === -1) { // no slash - it's relative to where we are // so just use it return this.TestDir + theURI; } else if (theURI.indexOf('/') === 0 || theURI.indexOf('http:') === 0 || theURI.indexOf('https:') === 0) { // it is an absolute URI so just use it return theURI; } else { // it is relative and contains a slash. // make it relative to the current test root return this.Base + theURI; } }, /** * return a list of all inline assertions or references * * @param {array} assertions list of assertions to examine */ _assertionRefs: function(assertions) { 'use strict'; var ret = [] ; // when the reference is to an object that has an array of assertions in it (a conditionObject) // then remember that one and loop over its embedded assertions if (typeof(assertions) === "object" && !Array.isArray(assertions) && assertions.hasOwnProperty('assertions')) { ret.push(assertions) ; assertions = assertions.assertions; } if (typeof(assertions) === "object" && Array.isArray(assertions)) { assertions.forEach( function(assert) { // first - what is the type of the assert if (typeof assert === "object" && Array.isArray(assert)) { // it is a nested list - recurse this._assertionRefs(assert).forEach( function(item) { ret.push(item); }.bind(this)); } else if (typeof assert === "object") { ret.push(assert) ; if (assert.hasOwnProperty("assertions")) { // there are embedded assertions; get those too ret.concat(this._assertionRefs(assert.assertions)); } } else { // it is a file name ret.push(assert) ; } }.bind(this)); } return ret; }, // _loadFile - return a promise loading a file // _loadFile: function(method, url, parse) { 'use strict'; if (parse === undefined) { parse = true; } return new Promise(function (resolve, reject) { if (document.location.search) { var s = document.location.search; s = s.replace(/^\?/, ''); if (url.indexOf('?') !== -1) { url += "&" + s; } else { url += "?" + s; } } var xhr = new XMLHttpRequest(); xhr.open(method, url); xhr.onload = function () { if (this.status >= 200 && this.status < 300) { var d = xhr.response; if (parse) { try { d = JSON.parse(d); resolve(d); } catch(err) { reject({ status: this.status, statusText: "Parsing of " + url + " failed: " + err } ); } } else { resolve(d); } } else { reject({ status: this.status, statusText: xhr.statusText }); } }; xhr.onerror = function () { reject({ status: this.status, statusText: xhr.statusText }); }; xhr.send(); }); }, };