diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000000..68fbf6d87c --- /dev/null +++ b/.travis.yml @@ -0,0 +1,11 @@ +language: node_js + +node_js: + - "0.10" + - "0.12" + +before_script: + - npm install -g gulp + - gulp + +script: npm test diff --git a/package.json b/package.json index 06152b4fa8..2f8af95a5f 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "description": "Lightweight, robust, elegant syntax highlighting. A spin-off project from Dabblet.", "main": "prism.js", "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "test": "mocha tests/testrunner-tests.js && mocha tests/run.js" }, "repository": { "type": "git", @@ -18,10 +18,12 @@ "license": "MIT", "readmeFilename": "README.md", "devDependencies": { + "chai": "^2.3.0", "gulp": "^3.8.6", "gulp-concat": "^2.3.4", "gulp-header": "^1.0.5", "gulp-rename": "^1.2.0", - "gulp-uglify": "^0.3.1" + "gulp-uglify": "^0.3.1", + "mocha": "^2.2.5" } } diff --git a/test-suite.html b/test-suite.html new file mode 100644 index 0000000000..accd149d92 --- /dev/null +++ b/test-suite.html @@ -0,0 +1,147 @@ + + + + + + +Running the test suite ▲ Prism + + + + + + + + + +
+
+ +

Running the test suite

+

Prism has a test suite, that ensures that the correct tokens are matched.

+
+ +
+

Running the test suite

+ +

Running the test suite is simple: just call npm test.

+

All test files are run in isolation. A new prism instance is created for each test case. This will slow the test runner a bit down, but we can be sure that nothing leaks into the next test case.

+
+ +
+

Writing tests

+ +

Thank you for writing tests! Tests are awesome! They ensure, that we can improve the codebase without breaking anything. Also, this way, we can ensure that upgrading Prism is as painless as possible for you.

+

You can add new tests by creating a new test case file (with the .test file extension) in the tests directory which is located at /tests/languages/${language}.

+ +
+

Language directories

+

All tests are sorted into directories in the tests/languages directory. Each directory name encodes, which language you are currently testing.

+

All language names must match the names from the definition in components.js.

+ +

Example 1: testing a language in isolation (default use case)

+

Just put your test file into the directory of the language you want to test.

+

So, if you want to test CSS, put your test file in /tests/languages/css to test CSS only. If you create a test case in this directory, the test runner will ensure that the css language definition including all required language definitions are correctly loaded.

+ +

Example 2: testing language injection

+

If you want to test language injection, you typically need to load two or more languages where one language is the “main” language that is being tested, with all other languages being injected into it.

+

You need to define multiple languages by separating them using a + sign: markup+php.

+

The languages are loaded in order, so first markup (+ dependencies) is loaded, then php (+ dependencies). The test loader ensures that no language is loaded more than once (for example if two languages have the same dependencies).

+

By default the first language is the main language: markup+php will have markup as main language. This is equal to putting your code in the following code block:

+
...
+<pre><code class="language-markup">
+	<!-- your code here -->
+</code><pre>
+...
+ +

If you need to load the languages in a given order, but you don't want to use the first language as main language, you can mark the main language with an exclamation mark: markup+php!. This will use php as main language. (You can only define one main language. The test runner will fail all tests in directories with more than one main language.)

+ +

Note: by loading multiple languages you can do integration tests (ensure that loading two or more languages together won't break anything).

+
+ +
+

Creating your test case file

+

At first you need to create a new file in the language directory, you want to test.

+

Use a proper name for your test case. Please use one case of the following conventions:

+ +

You can use all conventions as a prefix, so string_interpolation_feature_inline.test is possible. But please take a minute or two to think of a proper name of your test case file. You are writing code not only for the computers, but also for your fellow developers.

+
+ +
+

Writing your test

+

The structure of a test case file is as follows:

+

+... language snippet...
+----
+... the simplified token stream you expect ...
+ +

Your file is built up of two or three sections, separated by three or more dashes -, starting at the begin of the line:

+
    +
  1. Your language snippet. The code you want to compile using Prism. (required)
  2. +
  3. The simplified token stream you expect. Needs to be valid JSON. (required)
  4. +
  5. A comment explaining the test case. (optional)
  6. +
+

The easiest way would be to look at an existing test file:

+
var a = 5;
+
+----------------------------------------------------
+
+[
+	["keyword", "var"],
+	" a ",
+	["operator", "="],
+	["number", "5"],
+	["punctuation", ";"]
+]
+
+----------------------------------------------------
+
+This is a comment explaining this test case.
+
+ +
+

Explaining the simplified token stream

+

While compiling, Prism transforms your source code into a token stream. This is basically a tree of nested tokens (or arrays, or strings).

+

As these trees are hard to write by hand, the test runner uses a simplified version of it.

+

It uses the following rules:

+ +

For further information: reading the tests of the test runner (tests/testrunner-tests.js) will help you understand the transformation.

+
+
+ + +
+

Test runner tests

+

The test runner itself is tested in a separate test case. You can find all “test core” related tests in tests/testrunner-tests.js.

+

You shouldn't need to touch this file ever, except you modify the test runner code.

+
+ +
+

Internal structure

+

The global test flow is at follows:

+
    +
  1. Run all internal tests (test the test runner).
  2. +
  3. Find all language tests.
  4. +
  5. Run all language tests individually.
  6. +
  7. Report the results.
  8. +
+
+ + + + + + + + + + + diff --git a/tests/helper/components.js b/tests/helper/components.js new file mode 100644 index 0000000000..68cb7abe3a --- /dev/null +++ b/tests/helper/components.js @@ -0,0 +1,10 @@ +"use strict"; + +var fs = require("fs"); +var vm = require("vm"); + +var fileContent = fs.readFileSync(__dirname + "/../../components.js", "utf8"); +var context = {}; +vm.runInNewContext(fileContent, context); + +module.exports = context.components; diff --git a/tests/helper/prism-loader.js b/tests/helper/prism-loader.js new file mode 100644 index 0000000000..31f3689d63 --- /dev/null +++ b/tests/helper/prism-loader.js @@ -0,0 +1,113 @@ +"use strict"; + +var fs = require("fs"); +var vm = require("vm"); +var components = require("./components"); +var languagesCatalog = components.languages; + + +module.exports = { + + /** + * Creates a new Prism instance with the given language loaded + * + * @param {string|string[]} languages + * @returns {Prism} + */ + createInstance: function (languages) { + var context = { + loadedLanguages: [], + Prism: this.createEmptyPrism() + }; + languages = Array.isArray(languages) ? languages : [languages]; + + for (var i = 0, l = languages.length; i < l; i++) { + context = this.loadLanguage(languages[i], context); + } + + return context.Prism; + }, + + + /** + * Loads the given language (including recursively loading the dependencies) and + * appends the config to the given Prism object + * + * @private + * @param {string} language + * @param {{loadedLanguages: string[], Prism: Prism}} context + * @returns {{loadedLanguages: string[], Prism: Prism}} + */ + loadLanguage: function (language, context) { + if (!languagesCatalog[language]) { + throw new Error("Language '" + language + "' not found."); + } + + // the given language was already loaded + if (-1 < context.loadedLanguages.indexOf(language)) { + return context; + } + + // if the language has a dependency -> load it first + if (languagesCatalog[language].require) { + context = this.loadLanguage(languagesCatalog[language].require, context); + } + + // load the language itself + var languageSource = this.loadFileSource(language); + context.Prism = this.runFileWithContext(languageSource, {Prism: context.Prism}).Prism; + context.loadedLanguages.push(language); + + return context; + }, + + + /** + * Creates a new empty prism instance + * + * @private + * @returns {Prism} + */ + createEmptyPrism: function () { + var coreSource = this.loadFileSource("core"); + var context = this.runFileWithContext(coreSource); + return context.Prism; + }, + + + /** + * Cached file sources, to prevent massive HDD work + * + * @private + * @type {Object.} + */ + fileSourceCache: {}, + + + /** + * Loads the given file source as string + * + * @private + * @param {string} name + * @returns {string} + */ + loadFileSource: function (name) { + return this.fileSourceCache[name] = this.fileSourceCache[name] || fs.readFileSync(__dirname + "/../../components/prism-" + name + ".js", "utf8"); + }, + + + /** + * Runs a VM for a given file source with the given context + * + * @private + * @param {string} fileSource + * @param {*} [context] + * + * @returns {*} + */ + runFileWithContext: function (fileSource, context) { + context = context || {}; + vm.runInNewContext(fileSource, context); + return context; + } +}; diff --git a/tests/helper/test-case.js b/tests/helper/test-case.js new file mode 100644 index 0000000000..34835a3326 --- /dev/null +++ b/tests/helper/test-case.js @@ -0,0 +1,145 @@ +"use strict"; + +var fs = require("fs"); +var assert = require("chai").assert; +var PrismLoader = require("./prism-loader"); +var TokenStreamTransformer = require("./token-stream-transformer"); + +/** + * Handles parsing of a test case file. + * + * + * A test case file consists of at least two parts, separated by a line of dashes. + * This separation line must start at the beginning of the line and consist of at least three dashes. + * + * The test case file can either consist of two parts: + * + * {source code} + * ---- + * {expected token stream} + * + * + * or of three parts: + * + * {source code} + * ---- + * {expected token stream} + * ---- + * {text comment explaining the test case} + * + * If the file contains more than three parts, the remaining parts are just ignored. + * If the file however does not contain at least two parts (so no expected token stream), + * the test case will later be marked as failed. + * + * + * @type {{runTestCase: Function, transformCompiledTokenStream: Function, parseTestCaseFile: Function}} + */ +module.exports = { + + /** + * Runs the given test case file and asserts the result + * + * The passed language identifier can either be a language like "css" or a composed language + * identifier like "css+markup". Composed identifiers can be used for testing language inclusion. + * + * When testing language inclusion, the first given language is the main language which will be passed + * to Prism for highlighting ("css+markup" will result in a call to Prism to highlight with the "css" grammar). + * But it will be ensured, that the additional passed languages will be loaded too. + * + * The languages will be loaded in the order they were provided. + * + * @param {string} languageIdentifier + * @param {string} filePath + */ + runTestCase: function (languageIdentifier, filePath) { + var testCase = this.parseTestCaseFile(filePath); + var usedLanguages = this.parseLanguageNames(languageIdentifier); + + if (null === testCase) { + throw new Error("Test case file has invalid format (or the provided token stream is invalid JSON), please read the docs."); + } + + var Prism = PrismLoader.createInstance(usedLanguages.languages); + // the first language is the main language to highlight + var mainLanguageGrammar = Prism.languages[usedLanguages.mainLanguage]; + var compiledTokenStream = Prism.tokenize(testCase.testSource, mainLanguageGrammar); + var simplifiedTokenStream = TokenStreamTransformer.simplify(compiledTokenStream); + + assert.deepEqual(simplifiedTokenStream, testCase.expectedTokenStream, testCase.comment); + }, + + + /** + * Parses the language names and finds the main language. + * + * It is either the first language or the language followed by a exclamation mark “!”. + * There should only be one language with an exclamation mark. + * + * @param {string} languageIdentifier + * + * @returns {{languages: string[], mainLanguage: string}} + */ + parseLanguageNames: function (languageIdentifier) { + var languages = languageIdentifier.split("+"); + var mainLanguage = null; + + languages = languages.map( + function (language) { + var pos = language.indexOf("!"); + + if (-1 < pos) { + if (mainLanguage) { + throw "There are multiple main languages defined."; + } + + mainLanguage = language.replace("!", ""); + return mainLanguage; + } + + return language; + } + ); + + if (!mainLanguage) { + mainLanguage = languages[0]; + } + + return { + languages: languages, + mainLanguage: mainLanguage + }; + }, + + + /** + * Parses the test case from the given test case file + * + * @private + * @param {string} filePath + * @returns {{testSource: string, expectedTokenStream: Array.>, comment:string?}|null} + */ + parseTestCaseFile: function (filePath) { + var testCaseSource = fs.readFileSync(filePath, "utf8"); + var testCaseParts = testCaseSource.split(/^----*\w*$/m); + + try { + var testCase = { + testSource: testCaseParts[0].trim(), + expectedTokenStream: JSON.parse(testCaseParts[1]), + comment: null + }; + + // if there are three parts, the third one is the comment + // explaining the test case + if (testCaseParts[2]) { + testCase.comment = testCaseParts[2].trim(); + } + + return testCase; + } + catch (e) { + // the JSON can't be parsed (e.g. it could be empty) + return null; + } + } +}; diff --git a/tests/helper/test-discovery.js b/tests/helper/test-discovery.js new file mode 100644 index 0000000000..741dc7591b --- /dev/null +++ b/tests/helper/test-discovery.js @@ -0,0 +1,65 @@ +"use strict"; + +var fs = require("fs"); +var path = require("path"); + + +module.exports = { + + /** + * Loads the list of all available tests + * + * @param {string} rootDir + * @returns {Object.} + */ + loadAllTests: function (rootDir) { + var testSuite = {}; + var self = this; + + this.getAllDirectories(rootDir).forEach( + function (language) { + testSuite[language] = self.getAllFiles(path.join(rootDir, language)); + } + ); + + return testSuite; + }, + + + /** + * Returns a list of all (sub)directories (just the directory names, not full paths) + * in the given src directory + * + * @param {string} src + * @returns {Array.} + */ + getAllDirectories: function (src) { + return fs.readdirSync(src).filter( + function (file) { + return fs.statSync(path.join(src, file)).isDirectory(); + } + ); + }, + + + /** + * Returns a list of all full file paths to all files in the given src directory + * + * @private + * @param {string} src + * @returns {Array.} + */ + getAllFiles: function (src) { + return fs.readdirSync(src).filter( + function (fileName) { + // only find files that have the ".test" extension + return ".test" === path.extname(fileName) && + fs.statSync(path.join(src, fileName)).isFile(); + } + ).map( + function (fileName) { + return path.join(src, fileName); + } + ); + } +}; diff --git a/tests/helper/token-stream-transformer.js b/tests/helper/token-stream-transformer.js new file mode 100644 index 0000000000..deb831c45b --- /dev/null +++ b/tests/helper/token-stream-transformer.js @@ -0,0 +1,32 @@ +"use strict"; + + +module.exports = { + /** + * Simplifies the token stream to ease the matching with the expected token stream. + * + * * Strings are kept as-is + * * In arrays each value is transformed individually + * * Values that are empty (empty arrays or strings only containing whitespace) + * + * + * @param {Array} tokenStream + * @returns {Array.} + */ + simplify: function (tokenStream) { + if (Array.isArray(tokenStream)) { + return tokenStream + .map(this.simplify.bind(this)) + .filter(function (value) { + return !(Array.isArray(value) && !value.length) && !(typeof value === "string" && !value.trim().length); + } + ); + } + else if (typeof tokenStream === "object") { + return [tokenStream.type, this.simplify(tokenStream.content)]; + } + else { + return tokenStream; + } + } +}; diff --git a/tests/languages/apacheconf/test2.test b/tests/languages/apacheconf/test2.test new file mode 100644 index 0000000000..a136917c03 --- /dev/null +++ b/tests/languages/apacheconf/test2.test @@ -0,0 +1,16 @@ +"foo bar" +'foo bar' +"%{REMOTE_HOST}" + +----- +[ + ["string", ["\"foo bar\""]], + ["string", ["'foo bar'"]], + [ + "string", [ + "\"", + ["variable", "%{REMOTE_HOST}"], + "\"" + ] + ] +] diff --git a/tests/languages/javascript/testcase1.test b/tests/languages/javascript/testcase1.test new file mode 100644 index 0000000000..78a197175a --- /dev/null +++ b/tests/languages/javascript/testcase1.test @@ -0,0 +1,15 @@ +var a = 5; + +---------------------------------------------------- + +[ + ["keyword", "var"], + " a ", + ["operator", "="], + ["number", "5"], + ["punctuation", ";"] +] + +---------------------------------------------------- + +This is a comment explaining this test case. diff --git a/tests/run.js b/tests/run.js new file mode 100644 index 0000000000..5e0050487f --- /dev/null +++ b/tests/run.js @@ -0,0 +1,31 @@ +"use strict"; + +var TestDiscovery = require("./helper/test-discovery"); +var TestCase = require("./helper/test-case"); +var path = require("path"); + +// load complete test suite +var testSuite = TestDiscovery.loadAllTests(__dirname + "/languages"); + +// define tests for all tests in all languages in the test suite +for (var language in testSuite) { + if (!testSuite.hasOwnProperty(language)) { + continue; + } + + (function (language, testFiles) { + describe("Testing language '" + language + "'", function () { + testFiles.forEach( + function (filePath) { + var fileName = path.basename(filePath, path.extname(filePath)); + + it("– should pass test case '" + fileName + "'", + function () { + TestCase.runTestCase(language, filePath); + } + ); + } + ); + }); + })(language, testSuite[language]); +} diff --git a/tests/testrunner-tests.js b/tests/testrunner-tests.js new file mode 100644 index 0000000000..c4e0fd51f0 --- /dev/null +++ b/tests/testrunner-tests.js @@ -0,0 +1,166 @@ +"use strict"; + +var assert = require("chai").assert; +var TokenStreamTransformer = require("./helper/token-stream-transformer"); +var TestCase = require("./helper/test-case"); + + +//region Token Stream Transformer +describe("The token stream transformer", + function () { + it("should handle all kinds of simple transformations", + function () { + var tokens = [ + {type: "type", content: "content"}, + "string" + ]; + + var expected = [ + ["type", "content"], + "string" + ]; + + assert.deepEqual(TokenStreamTransformer.simplify(tokens), expected); + } + ); + + + it("should handle nested structures", + function () { + var tokens = [ + { + type: "type", + content: [ + { + type: "insideType", content: + [ + {type: "insideInsideType", content: "content"} + ] + } + ] + } + ]; + + var expected = [ + ["type", [ + ["insideType", [ + ["insideInsideType", "content"] + ]] + ]] + ]; + + assert.deepEqual(TokenStreamTransformer.simplify(tokens), expected); + } + ); + + + it("should strip empty tokens", + function () { + var tokenStream = [ + "", + "\r\n", + "\t", + " " + ]; + + var expectedSimplified = []; + + assert.deepEqual(TokenStreamTransformer.simplify(tokenStream), expectedSimplified); + } + ); + + + it("should strip empty token tree branches", + function () { + var tokenStream = [ + { + type: "type", + content: [ + ["", ""], + "", + {type: "nested", content: [""]} + ] + }, + [[[[[[[""]]]]]]] + ]; + + var expectedSimplified = [ + ["type", [ + ["nested", []] + ]] + ]; + + assert.deepEqual(TokenStreamTransformer.simplify(tokenStream), expectedSimplified); + } + ); + + + it("should ignore all properties in tokens except value and content", + function () { + + var tokenStream = [ + {type: "type", content: "content", alias: "alias"} + ]; + + var expectedSimplified = [ + ["type", "content"] + ]; + + assert.deepEqual(TokenStreamTransformer.simplify(tokenStream), expectedSimplified); + } + ); + } +); +//endregion + + +//region Language name parsing +describe("The language name parsing", + function () { + it("should use the first language as the main language if no language is specified", + function () { + assert.deepEqual( + TestCase.parseLanguageNames("a"), + { + languages: ["a"], + mainLanguage: "a" + } + ); + + assert.deepEqual( + TestCase.parseLanguageNames("a+b+c"), + { + languages: ["a", "b", "c"], + mainLanguage: "a" + } + ); + } + ); + + + it("should use the specified language as main language", + function () { + assert.deepEqual( + TestCase.parseLanguageNames("a+b!+c"), + { + languages: ["a", "b", "c"], + mainLanguage: "b" + } + ); + } + ); + + + it("should throw an error if there are multiple main languages", + function () { + assert.throw( + function () { + TestCase.parseLanguageNames("a+b!+c!"); + }, + "There are multiple main languages defined." + ); + } + ); + } +); +//endregion