/* Copyright (c) 2019 The Khronos Group Inc. Use of this source code is governed by an MIT-style license that can be found in the LICENSE.txt file. */ // This is a test harness for running javascript tests in the browser. // The only identifier exposed by this harness is WebGLTestHarnessModule. // // To use it make an HTML page with an iframe. Then call the harness like this // // function reportResults(type, msg, success) { // ... // return true; // } // // var fileListURL = '00_test_list.txt'; // var testHarness = new WebGLTestHarnessModule.TestHarness( // iframe, // fileListURL, // reportResults, // options); // // The harness will load the fileListURL and parse it for the URLs, one URL // per line preceded by options, see below. URLs should be on the same domain // and at the same folder level or below the main html file. If any URL ends // in .txt it will be parsed as well so you can nest .txt files. URLs inside a // .txt file should be relative to that text file. // // During startup, for each page found the reportFunction will be called with // WebGLTestHarnessModule.TestHarness.reportType.ADD_PAGE and msg will be // the URL of the test. // // Each test is required to call testHarness.reportResults. This is most easily // accomplished by storing that value on the main window with // // window.webglTestHarness = testHarness // // and then adding these to functions to your tests. // // function reportTestResultsToHarness(success, msg) { // if (window.parent.webglTestHarness) { // window.parent.webglTestHarness.reportResults(success, msg); // } // } // // function notifyFinishedToHarness() { // if (window.parent.webglTestHarness) { // window.parent.webglTestHarness.notifyFinished(); // } // } // // This way your tests will still run without the harness and you can use // any testing framework you want. // // Each test should call reportTestResultsToHarness with true for success if it // succeeded and false if it fail followed and any message it wants to // associate with the test. If your testing framework supports checking for // timeout you can call it with success equal to undefined in that case. // // To run the tests, call testHarness.runTests(options); // // For each test run, before the page is loaded the reportFunction will be // called with WebGLTestHarnessModule.TestHarness.reportType.START_PAGE and msg // will be the URL of the test. You may return false if you want the test to be // skipped. // // For each test completed the reportFunction will be called with // with WebGLTestHarnessModule.TestHarness.reportType.TEST_RESULT, // success = true on success, false on failure, undefined on timeout // and msg is any message the test choose to pass on. // // When all the tests on the page have finished your page must call // notifyFinishedToHarness. If notifyFinishedToHarness is not called // the harness will assume the test timed out. // // When all the tests on a page have finished OR the page as timed out the // reportFunction will be called with // WebGLTestHarnessModule.TestHarness.reportType.FINISH_PAGE // where success = true if the page has completed or undefined if the page timed // out. // // Finally, when all the tests have completed the reportFunction will be called // with WebGLTestHarnessModule.TestHarness.reportType.FINISHED_ALL_TESTS. // // Harness Options // // These are passed in to the TestHarness as a JavaScript object // // version: (required!) // // Specifies a version used to filter tests. Tests marked as requiring // a version greater than this version will not be included. // // example: new TestHarness(...., {version: "3.1.2"}); // // minVersion: // // Specifies the minimum version a test must require to be included. // This basically flips the filter so that only tests marked with // --min-version will be included if they are at this minVersion or // greater. // // example: new TestHarness(...., {minVersion: "2.3.1"}); // // maxVersion: // // Specifies the maximum version a test must require to be included. // This basically flips the filter so that only tests marked with // --max-version will be included if they are at this maxVersion or // less. // // example: new TestHarness(...., {maxVersion: "2.3.1"}); // // fast: // // Specifies to skip any tests marked as slow. // // example: new TestHarness(..., {fast: true}); // // Test Options: // // Any test URL or .txt file can be prefixed by the following options // // min-version: // // Sets the minimum version required to include this test. A version is // passed into the harness options. Any test marked as requiring a // min-version greater than the version passed to the harness is skipped. // This allows you to add new tests to a suite of tests for a future // version of the suite without including the test in the current version. // If no -min-version is specified it is inheriited from the .txt file // including it. The default is 1.0.0 // // example: --min-version 2.1.3 sometest.html // // max-version: // // Sets the maximum version required to include this test. A version is // passed into the harness options. Any test marked as requiring a // max-version less than the version passed to the harness is skipped. // This allows you to test functionality that has been removed from later // versions of the suite. // If no -max-version is specified it is inherited from the .txt file // including it. // // example: --max-version 1.9.9 sometest.html // // slow: // // Marks a test as slow. Slow tests can be skipped by passing fastOnly: true // to the TestHarness. Of course you need to pass all tests but sometimes // you'd like to test quickly and run only the fast subset of tests. // // example: --slow some-test-that-takes-2-mins.html // WebGLTestHarnessModule = function() { /** * Wrapped logging function. */ var log = function(msg) { if (window.console && window.console.log) { window.console.log(msg); } }; /** * Loads text from an external file. This function is synchronous. * @param {string} url The url of the external file. * @param {!function(bool, string): void} callback that is sent a bool for * success and the string. */ var loadTextFileAsynchronous = function(url, callback) { log ("loading: " + url); var error = 'loadTextFileSynchronous failed to load url "' + url + '"'; var request; if (window.XMLHttpRequest) { request = new XMLHttpRequest(); if (request.overrideMimeType) { request.overrideMimeType('text/plain'); } } else { throw 'XMLHttpRequest is disabled'; } try { request.open('GET', url, true); request.onreadystatechange = function() { if (request.readyState == 4) { var text = ''; // HTTP reports success with a 200 status. The file protocol reports // success with zero. HTTP does not use zero as a status code (they // start at 100). // https://developer.mozilla.org/En/Using_XMLHttpRequest var success = request.status == 200 || request.status == 0; if (success) { text = request.responseText; } log("loaded: " + url); callback(success, text); } }; request.send(null); } catch (e) { log("failed to load: " + url); callback(false, ''); } }; /** * @param {string} versionString WebGL version string. * @return {number} Integer containing the WebGL major version. */ var getMajorVersion = function(versionString) { if (!versionString) { return 1; } return parseInt(versionString.split(" ")[0].split(".")[0], 10); }; /** * @param {string} url Base URL of the test. * @param {map} options Map of options to append to the URL's query string. * @return {string} URL that will run the test with the given WebGL version. */ var getURLWithOptions = function(url, options) { var queryArgs = 0; for (i in options) { url += queryArgs ? "&" : "?"; url += i + "=" + options[i]; queryArgs++; } return url; }; /** * Compare version strings. */ var greaterThanOrEqualToVersion = function(have, want) { have = have.split(" ")[0].split("."); want = want.split(" ")[0].split("."); //have 1.2.3 want 1.1 //have 1.1.1 want 1.1 //have 1.0.9 want 1.1 //have 1.1 want 1.1.1 for (var ii = 0; ii < want.length; ++ii) { var wantNum = parseInt(want[ii]); var haveNum = have[ii] ? parseInt(have[ii]) : 0 if (haveNum > wantNum) { return true; // 2.0.0 is greater than 1.2.3 } if (haveNum < wantNum) { return false; } } return true; }; /** * Reads a file, recursively adding files referenced inside. * * Each line of URL is parsed, comments starting with '#' or ';' * or '//' are stripped. * * arguments beginning with -- are extracted * * lines that end in .txt are recursively scanned for more files * other lines are added to the list of files. * * @param {string} url The url of the file to read. * @param {function(boolean, !Array.):void} callback * Callback that is called with true for success and an * array of filenames. * @param {Object} options Optional options * * Options: * version: {string} The version of the conformance test. * Tests with the argument --min-version will * be ignored version is less then * */ var getFileList = function(url, callback, options) { var files = []; var copyObject = function(obj) { return JSON.parse(JSON.stringify(obj)); }; var toCamelCase = function(str) { return str.replace(/-([a-z])/g, function (g) { return g[1].toUpperCase() }); }; var globalOptions = copyObject(options); globalOptions.defaultVersion = "1.0"; globalOptions.defaultMaxVersion = null; var getFileListImpl = function(prefix, line, lineNum, hierarchicalOptions, callback) { var files = []; var args = line.split(/\s+/); var nonOptions = []; var useTest = true; var testOptions = {}; for (var jj = 0; jj < args.length; ++jj) { var arg = args[jj]; if (arg[0] == '-') { if (arg[1] != '-') { throw ("bad option at in " + url + ":" + lineNum + ": " + arg); } var option = arg.substring(2); switch (option) { // no argument options. case 'slow': testOptions[toCamelCase(option)] = true; break; // one argument options. case 'min-version': case 'max-version': ++jj; testOptions[toCamelCase(option)] = args[jj]; break; default: throw ("bad unknown option '" + option + "' at in " + url + ":" + lineNum + ": " + arg); } } else { nonOptions.push(arg); } } var url = prefix + nonOptions.join(" "); if (url.substr(url.length - 4) != '.txt') { var minVersion = testOptions.minVersion; if (!minVersion) { minVersion = hierarchicalOptions.defaultVersion; } var maxVersion = testOptions.maxVersion; if (!maxVersion) { maxVersion = hierarchicalOptions.defaultMaxVersion; } var slow = testOptions.slow; if (!slow) { slow = hierarchicalOptions.defaultSlow; } if (globalOptions.fast && slow) { useTest = false; } else if (globalOptions.minVersion) { useTest = greaterThanOrEqualToVersion(minVersion, globalOptions.minVersion); } else if (globalOptions.maxVersion && maxVersion) { useTest = greaterThanOrEqualToVersion(globalOptions.maxVersion, maxVersion); } else { useTest = greaterThanOrEqualToVersion(globalOptions.version, minVersion); if (maxVersion) { useTest = useTest && greaterThanOrEqualToVersion(maxVersion, globalOptions.version); } } } if (!useTest) { callback(true, []); return; } if (url.substr(url.length - 4) == '.txt') { // If a version was explicity specified pass it down. if (testOptions.minVersion) { hierarchicalOptions.defaultVersion = testOptions.minVersion; } if (testOptions.maxVersion) { hierarchicalOptions.defaultMaxVersion = testOptions.maxVersion; } if (testOptions.slow) { hierarchicalOptions.defaultSlow = testOptions.slow; } loadTextFileAsynchronous(url, function() { return function(success, text) { if (!success) { callback(false, ''); return; } var lines = text.split('\n'); var prefix = ''; var lastSlash = url.lastIndexOf('/'); if (lastSlash >= 0) { prefix = url.substr(0, lastSlash + 1); } var fail = false; var count = 1; var index = 0; for (var ii = 0; ii < lines.length; ++ii) { var str = lines[ii].replace(/^\s\s*/, '').replace(/\s\s*$/, ''); if (str.length > 4 && str[0] != '#' && str[0] != ";" && str.substr(0, 2) != "//") { ++count; getFileListImpl(prefix, str, ii + 1, copyObject(hierarchicalOptions), function(index) { return function(success, new_files) { //log("got files: " + new_files.length); if (success) { files[index] = new_files; } finish(success); }; }(index++)); } } finish(true); function finish(success) { if (!success) { fail = true; } --count; //log("count: " + count); if (!count) { callback(!fail, files); } } } }()); } else { files.push(url); callback(true, files); } }; getFileListImpl('', url, 1, globalOptions, function(success, files) { // flatten var flat = []; flatten(files); function flatten(files) { for (var ii = 0; ii < files.length; ++ii) { var value = files[ii]; if (typeof(value) == "string") { flat.push(value); } else { flatten(value); } } } callback(success, flat); }); }; var FilterURL = (function() { var prefix = window.location.pathname; prefix = prefix.substring(0, prefix.lastIndexOf("/") + 1); return function(url) { if (url.substring(0, prefix.length) == prefix) { url = url.substring(prefix.length); } return url; }; }()); var TestFile = function(url) { this.url = url; }; var Test = function(file) { this.file = file; }; var TestHarness = function(iframe, filelistUrl, reportFunc, options) { this.window = window; this.iframes = iframe.length ? iframe : [iframe]; this.reportFunc = reportFunc; this.timeoutDelay = 20000; this.files = []; this.allowSkip = options.allowSkip; this.webglVersion = getMajorVersion(options.version); this.dumpShaders = options.dumpShaders; this.quiet = options.quiet; var that = this; getFileList(filelistUrl, function() { return function(success, files) { that.addFiles_(success, files); }; }(), options); }; TestHarness.reportType = { ADD_PAGE: 1, READY: 2, START_PAGE: 3, TEST_RESULT: 4, FINISH_PAGE: 5, FINISHED_ALL_TESTS: 6 }; TestHarness.prototype.addFiles_ = function(success, files) { if (!success) { this.reportFunc( TestHarness.reportType.FINISHED_ALL_TESTS, '', 'Unable to load tests. Are you running locally?\n' + 'You need to run from a server or configure your\n' + 'browser to allow access to local files (not recommended).\n\n' + 'Note: An easy way to run from a server:\n\n' + '\tcd path_to_tests\n' + '\tpython -m SimpleHTTPServer\n\n' + 'then point your browser to ' + '' + 'http://localhost:8000/webgl-conformance-tests.html', false) return; } log("total files: " + files.length); for (var ii = 0; ii < files.length; ++ii) { log("" + ii + ": " + files[ii]); this.files.push(new TestFile(files[ii])); this.reportFunc(TestHarness.reportType.ADD_PAGE, '', files[ii], undefined); } this.reportFunc(TestHarness.reportType.READY, '', undefined, undefined); } TestHarness.prototype.runTests = function(opt_options) { var options = opt_options || { }; options.start = options.start || 0; options.count = options.count || this.files.length; this.idleIFrames = this.iframes.slice(0); this.runningTests = {}; var testsToRun = []; for (var ii = 0; ii < options.count; ++ii) { testsToRun.push(ii + options.start); } this.numTestsRemaining = options.count; this.testsToRun = testsToRun; this.startNextTest(); }; TestHarness.prototype.setTimeout = function(test) { var that = this; test.timeoutId = this.window.setTimeout(function() { that.timeout(test); }, this.timeoutDelay); }; TestHarness.prototype.clearTimeout = function(test) { this.window.clearTimeout(test.timeoutId); }; TestHarness.prototype.startNextTest = function() { if (this.numTestsRemaining == 0) { log("done"); this.reportFunc(TestHarness.reportType.FINISHED_ALL_TESTS, '', '', true); } else { while (this.testsToRun.length > 0 && this.idleIFrames.length > 0) { var testId = this.testsToRun.shift(); var iframe = this.idleIFrames.shift(); this.startTest(iframe, this.files[testId], this.webglVersion); } } }; TestHarness.prototype.startTest = function(iframe, testFile, webglVersion) { var test = { iframe: iframe, testFile: testFile }; var url = testFile.url; this.runningTests[url] = test; log("loading: " + url); if (this.reportFunc(TestHarness.reportType.START_PAGE, url, url, undefined)) { iframe.src = getURLWithOptions(url, { "webglVersion": webglVersion, "dumpShaders": this.dumpShaders, "quiet": this.quiet }); this.setTimeout(test); } else { this.reportResults(url, !!this.allowSkip, "skipped", true); this.notifyFinished(url); } }; TestHarness.prototype.getTest = function(url) { var test = this.runningTests[FilterURL(url)]; if (!test) { throw("unknown test:" + url); } return test; }; TestHarness.prototype.reportResults = function(url, success, msg, skipped) { url = FilterURL(url); var test = this.getTest(url); this.clearTimeout(test); log((success ? "PASS" : "FAIL") + ": " + msg); this.reportFunc(TestHarness.reportType.TEST_RESULT, url, msg, success, skipped); // For each result we get, reset the timeout this.setTimeout(test); }; TestHarness.prototype.dequeTest = function(test) { this.clearTimeout(test); this.idleIFrames.push(test.iframe); delete this.runningTests[test.testFile.url]; --this.numTestsRemaining; } TestHarness.prototype.notifyFinished = function(url) { url = FilterURL(url); var test = this.getTest(url); log(url + ": finished"); this.dequeTest(test); this.reportFunc(TestHarness.reportType.FINISH_PAGE, url, url, true); this.startNextTest(); }; TestHarness.prototype.timeout = function(test) { this.dequeTest(test); var url = test.testFile.url; log(url + ": timeout"); this.reportFunc(TestHarness.reportType.FINISH_PAGE, url, url, undefined); this.startNextTest(); }; TestHarness.prototype.setTimeoutDelay = function(x) { this.timeoutDelay = x; }; return { 'TestHarness': TestHarness, 'getMajorVersion': getMajorVersion, 'getURLWithOptions': getURLWithOptions }; }();