diff --git a/.eslintrc.json b/.eslintrc.json index b3e28f7519..ee0eea269c 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -67,7 +67,10 @@ "node": true, "jest": true }, - "files": ["**/{__factories__,__mocks__,__tests__}/*.js{,x}"], + "files": [ + "**/{__factories__,__mocks__,__tests__}/**/*.js{,x}", + "jest-setup.js" + ], "rules": { "jest/no-alias-methods": "warn", "jest/no-disabled-tests": "warn", diff --git a/__mocks__/html-inspector/index.js b/__mocks__/html-inspector/index.js new file mode 100644 index 0000000000..776242e410 --- /dev/null +++ b/__mocks__/html-inspector/index.js @@ -0,0 +1,2 @@ +jest.requireActual('html-inspector/html-inspector'); +export default window.HTMLInspector; diff --git a/__mocks__/html-inspector/window.js b/__mocks__/html-inspector/window.js new file mode 100644 index 0000000000..ff8b4c5632 --- /dev/null +++ b/__mocks__/html-inspector/window.js @@ -0,0 +1 @@ +export default {}; diff --git a/__mocks__/i18next.js b/__mocks__/i18next.js new file mode 100644 index 0000000000..eb51007308 --- /dev/null +++ b/__mocks__/i18next.js @@ -0,0 +1,6 @@ +export function t(key) { + if (key === 'utility.or') { + return ' or '; + } + return ''; +} diff --git a/babel.config.js b/babel.config.js index 2480b24150..3cd9515ff9 100644 --- a/babel.config.js +++ b/babel.config.js @@ -39,5 +39,18 @@ module.exports = api => { ], plugins, compact: false, + overrides: isJest + ? [ + { + test: './node_modules/html-inspector/html-inspector.js', + plugins: [ + [ + 'transform-globals', + {import: {'html-inspector/window': {window: 'default'}}}, + ], + ], + }, + ] + : [], }; }; diff --git a/config/lintignore b/config/lintignore index 77cb2d3bf5..affdaf78f5 100644 --- a/config/lintignore +++ b/config/lintignore @@ -3,5 +3,6 @@ /dist /node_modules /nodeenv -/test/data +/src/validations/__tests__/acceptance.json +/src/validations/__tests__/acceptance /templates diff --git a/jest-setup.js b/jest-setup.js new file mode 100644 index 0000000000..dd0013c611 --- /dev/null +++ b/jest-setup.js @@ -0,0 +1,43 @@ +function createRange() { + // createRange is always called with a Document context + /* eslint-disable no-invalid-this, consistent-this */ + const context = this; + let end; + return { + setEndBefore(el) { + end = el; + }, + + // Finds all text nodes up to the target node, and returns them + // This is used to count newlines for the error reporting + toString() { + let hasFoundNode = false; + const newLines = []; + const iterator = context.createNodeIterator( + context.documentElement, + NodeFilter.SHOW_ALL, + { + acceptNode(node) { + if (hasFoundNode) { + return NodeFilter.FILTER_REJECT; + } + if (node.isEqualNode(end)) { + hasFoundNode = true; + } + if (node.nodeType === Node.TEXT_NODE) { + newLines.push(node.textContent); + } + return NodeFilter.FILTER_ACCEPT; + }, + }, + ); + const nodes = []; + let node; + while ((node = iterator.nextNode())) { + nodes.push(node); + } + return newLines.join(''); + }, + }; +} +global.Document.prototype.createRange = createRange; diff --git a/jest.config.js b/jest.config.js index 676d538981..a08ed2e728 100644 --- a/jest.config.js +++ b/jest.config.js @@ -9,8 +9,10 @@ module.exports = { moduleNameMapper: { '@factories/(.*)$': '/__factories__/$1', '\\.(html|svg)': '/__mocks__/fileMock.js', + i18next: '/__mocks__/i18next.js', }, + testMatch: ['**/__tests__/**/*.test.js?(x)'], testPathIgnorePatterns: ['/node_modules/', '/bower_components/', '/nodeenv/'], transformIgnorePatterns: ['node_modules/(?!(lodash-es)/)'], - setupFilesAfterEnv: ['jest-extended'], + setupFilesAfterEnv: ['jest-extended', './jest-setup.js'], }; diff --git a/script/update-validation-acceptance b/script/update-validation-acceptance index c942d15c05..3b67dac235 100755 --- a/script/update-validation-acceptance +++ b/script/update-validation-acceptance @@ -3,16 +3,17 @@ /* eslint-env node */ const fs = require('fs'); + const path = require('path'); const output = {}; -['html', 'css', 'javascript'].forEach((language) => { - const dir = `./test/data/acceptance/${language}`; +['html', 'css', 'javascript'].forEach(language => { + const dir = `./src/validations/__tests__/acceptance/${language}`; output[language] = []; - fs.readdirSync(dir).forEach((file) => { + fs.readdirSync(dir).forEach(file => { output[language].push( fs.readFileSync(path.join(dir, file), {encoding: 'utf8'}), ); @@ -20,6 +21,6 @@ const output = {}; }); fs.writeFile( - './test/data/acceptance.json', + './src/validations/__tests__/acceptance.json', JSON.stringify(output, null, 2), ); diff --git a/src/logic/__tests__/linkGithubIdentityTest.js b/src/logic/__tests__/linkGithubIdentity.test.js similarity index 100% rename from src/logic/__tests__/linkGithubIdentityTest.js rename to src/logic/__tests__/linkGithubIdentity.test.js diff --git a/test/data/acceptance.json b/src/validations/__tests__/acceptance.json similarity index 100% rename from test/data/acceptance.json rename to src/validations/__tests__/acceptance.json diff --git a/test/data/acceptance/css/u2p2.css b/src/validations/__tests__/acceptance/css/u2p2.css similarity index 100% rename from test/data/acceptance/css/u2p2.css rename to src/validations/__tests__/acceptance/css/u2p2.css diff --git a/test/data/acceptance/css/u4p1.css b/src/validations/__tests__/acceptance/css/u4p1.css similarity index 100% rename from test/data/acceptance/css/u4p1.css rename to src/validations/__tests__/acceptance/css/u4p1.css diff --git a/test/data/acceptance/css/u5p1.css b/src/validations/__tests__/acceptance/css/u5p1.css similarity index 100% rename from test/data/acceptance/css/u5p1.css rename to src/validations/__tests__/acceptance/css/u5p1.css diff --git a/test/data/acceptance/css/u5p2.css b/src/validations/__tests__/acceptance/css/u5p2.css similarity index 100% rename from test/data/acceptance/css/u5p2.css rename to src/validations/__tests__/acceptance/css/u5p2.css diff --git a/test/data/acceptance/html/u1p1.html b/src/validations/__tests__/acceptance/html/u1p1.html similarity index 100% rename from test/data/acceptance/html/u1p1.html rename to src/validations/__tests__/acceptance/html/u1p1.html diff --git a/test/data/acceptance/html/u2p1.html b/src/validations/__tests__/acceptance/html/u2p1.html similarity index 100% rename from test/data/acceptance/html/u2p1.html rename to src/validations/__tests__/acceptance/html/u2p1.html diff --git a/test/data/acceptance/html/u2p2.html b/src/validations/__tests__/acceptance/html/u2p2.html similarity index 100% rename from test/data/acceptance/html/u2p2.html rename to src/validations/__tests__/acceptance/html/u2p2.html diff --git a/test/data/acceptance/html/u4p1.html b/src/validations/__tests__/acceptance/html/u4p1.html similarity index 100% rename from test/data/acceptance/html/u4p1.html rename to src/validations/__tests__/acceptance/html/u4p1.html diff --git a/test/data/acceptance/html/u5p1.html b/src/validations/__tests__/acceptance/html/u5p1.html similarity index 100% rename from test/data/acceptance/html/u5p1.html rename to src/validations/__tests__/acceptance/html/u5p1.html diff --git a/test/data/acceptance/html/u5p2.html b/src/validations/__tests__/acceptance/html/u5p2.html similarity index 100% rename from test/data/acceptance/html/u5p2.html rename to src/validations/__tests__/acceptance/html/u5p2.html diff --git a/test/data/acceptance/html/u7p1.html b/src/validations/__tests__/acceptance/html/u7p1.html similarity index 100% rename from test/data/acceptance/html/u7p1.html rename to src/validations/__tests__/acceptance/html/u7p1.html diff --git a/test/data/acceptance/javascript/u5p2.js b/src/validations/__tests__/acceptance/javascript/u5p2.js similarity index 100% rename from test/data/acceptance/javascript/u5p2.js rename to src/validations/__tests__/acceptance/javascript/u5p2.js diff --git a/src/validations/__tests__/css.test.js b/src/validations/__tests__/css.test.js new file mode 100644 index 0000000000..464d89af94 --- /dev/null +++ b/src/validations/__tests__/css.test.js @@ -0,0 +1,160 @@ +import css from '../css'; + +import testValidatorAcceptance from './testValidatorAcceptance'; +import validationTest from './validationHelper'; + +describe('css validation', () => { + test('valid flexbox', () => + validationTest( + `.flex-container { + display: flex; + flex-flow: nowrap column; + align-content: flex-end; + justify-content: flex-start; + align-items: center; + } + .flex-item { + flex: 1 0 auto; + align-self: flex-end; + order: 2; + }`, + css, + )); + + test('valid filter', () => + validationTest( + `img { + filter: grayscale(100%); + }`, + css, + )); + + test('valid text-shadow declaration', () => + validationTest( + `p { + text-shadow: rgba(0,0,0,0.1) 0 -5px, rgba(0,0,0,0.1) 0 -1px, \ + rgba(255,255,255,0.1) 1px 0, rgba(255,255,255,0.1) 0 1px, \ + rgba(0,0,0,0.1) -1px -1px, rgba(255,255,255,0.1) 1px 1px; + }`, + css, + )); + + test('valid background-position declarations', () => + validationTest( + `img { + background-position-x: 10px; + background-position-y: 15%; + }`, + css, + )); + + test('bogus flex value', () => + validationTest('.flex-item { flex: bogus; }', css, { + reason: 'invalid-value', + row: 0, + payload: {error: 'bogus'}, + })); + + test('fails with bogus filter value', () => + validationTest('img { filter: whitescale(100%); }', css, { + reason: 'invalid-value', + row: 0, + payload: {error: 'whitescale('}, + })); + + test('no opening curly brace', () => + validationTest( + `p + display: block;`, + css, + {reason: 'block-expected', row: 0, payload: {error: 'p'}}, + )); + + test('no closing curly brace', () => + validationTest( + `p { + display: block;`, + css, + {reason: 'missing-closing-curly', row: 0}, + )); + + test('bogus character in selector', () => + validationTest('p; div { display: block; }', css, { + reason: 'invalid-token-in-selector', + row: 0, + payload: {token: ';'}, + })); + + test('invalid negative value', () => + validationTest('p { padding-left: -2px; }', css, { + reason: 'invalid-negative-value', + row: 0, + payload: {error: '-2px'}, + })); + + test('invalid fractional value', () => + validationTest('p { z-index: 2.4; }', css, { + reason: 'invalid-fractional-value', + row: 0, + payload: {error: '2.4'}, + })); + + test('missing semicolon at end of block', () => + validationTest( + `p { + display: block + }`, + css, + {reason: 'missing-semicolon', row: 1}, + )); + + test('missing semicolon within block', () => + validationTest( + ` + p { + margin: 10px + padding: 5px; + } + `, + css, + {reason: 'missing-semicolon', row: 2}, + )); + + test('extra tokens after value', () => + validationTest( + ` + p { + padding: 5px 5px 5px 5px 5px; + } + `, + css, + {reason: 'extra-tokens-after-value', row: 2, payload: {token: '5px'}}, + )); + + test('potentially missing semicolon before first line', () => + validationTest( + `button{border:20px solid b; + }`, + css, + {reason: 'extra-tokens-after-value', row: 0, payload: {token: 'b'}}, + )); + + test('extra token that is prefix of the beginning of the line', () => + validationTest( + ` + p { + border: 20px solid b; + } + `, + css, + {reason: 'extra-tokens-after-value', row: 2, payload: {token: 'b'}}, + )); + + test('thoroughly unparseable CSS', () => + validationTest('

'), html, { + reason: 'banned-attributes.align', + row: htmlWithBody.offset, + })); + + test('void tags without explicit close', () => + validationTest(htmlWithBody(''), html)); + + test('
tag with relative href property', () => + validationTest(htmlWithBody('Bad link'), html, { + reason: 'href-style', + row: htmlWithBody.offset, + })); + + test(' tag with fragment-only URL href', () => + validationTest(htmlWithBody('Fragment'), html)); + + test('missing doctype', () => + validationTest('', html, {reason: 'doctype', row: 0})); + + test('unclosed tag', () => + validationTest( + ` + + + Titlte + `, + html, + {reason: 'unclosed-tag', row: 1, payload: {tag: 'html'}}, + )); + + test('tag outside tag', () => + validationTest( + ` + + Title + + +

Extra text here

`, + html, + { + reason: 'invalid-tag-outside-body', + row: 5, + payload: {tagName: 'p'}, + }, + )); + + test('text outside tag', () => + validationTest( + ` + + (Extra text here) + Title + + `, + html, + { + reason: 'invalid-text-outside-body', + row: 2, + }, + )); + + test('text directly inside tag', () => + validationTest( + ` + + + Title + --- Shouldn't be here --- + + + `, + html, + { + reason: 'invalid-text-outside-body', + row: 4, + }, + )); + + test('missing internal closing tag div', () => + validationTest(htmlWithBody('
'), html, { + reason: 'unclosed-tag', + row: htmlWithBody.offset + 1, + payload: {tag: 'div'}, + })); + + test('missing internal closing tag p', () => + validationTest(htmlWithBody('

'), html, { + reason: 'unclosed-tag', + row: htmlWithBody.offset + 1, + payload: {tag: 'p'}, + })); + + test('unfinished closing tag', () => + validationTest(htmlWithBody('

+ validationTest(htmlWithBody('
'), html, { + reason: 'unexpected-close-tag', + row: htmlWithBody.offset, + payload: {tag: 'span'}, + })); + + test('misplaced closing tag', () => + validationTest( + htmlWithBody(`
+ `), + html, + { + reason: 'misplaced-close-tag', + row: htmlWithBody.offset + 1, + payload: { + open: 'span', + close: 'div', + // Display the mismatch as one-indexed, not zero-indexed. + mismatch: htmlWithBody.offset + 1, + }, + }, + )); + + test('space inside HTML angle bracket', () => + validationTest(htmlWithBody('< p>Content

'), html, { + reason: 'space-before-tag-name', + row: htmlWithBody.offset, + payload: {tag: 'p'}, + })); + + test('lowercase attributes', () => + validationTest(htmlWithBody('
Content
'), html)); + + test('lowercase data attributes', () => + validationTest(htmlWithBody('
Content
'), html)); + + test('uppercase attributes', () => + validationTest(htmlWithBody('
Content
'), html, { + reason: 'lower-case-attribute-name', + row: htmlWithBody.offset, + })); + + test('mixed uppercase attributes', () => + validationTest(htmlWithBody('
Content
'), html, { + reason: 'lower-case-attribute-name', + row: htmlWithBody.offset, + })); + + test('ul with child text outside
  • ', () => + validationTest( + htmlWithBody('
      Invalid to have non-empty text nodes
    '), + html, + { + reason: 'text-elements-as-list-children', + row: htmlWithBody.offset, + payload: { + tag: 'ul', + children: 'li', + textContent: 'Invalid to have non-empty text nodes', + }, + }, + )); + + test('ol with child text outside
  • ', () => + validationTest( + htmlWithBody('
      Invalid to have non-empty text nodes
    '), + html, + { + reason: 'text-elements-as-list-children', + row: htmlWithBody.offset, + payload: { + tag: 'ol', + children: 'li', + textContent: 'Invalid to have non-empty text nodes', + }, + }, + )); + + test('li not inside ul', () => + validationTest(htmlWithBody('
  • Orphaned List Item
  • '), html, { + reason: 'invalid-tag-parent', + row: htmlWithBody.offset, + payload: {tag: 'li', parent: '
      ,
        or tags'}, + })); + + test('li inside div', () => + validationTest(htmlWithBody('
      1. List within span
      2. '), html, { + reason: 'invalid-tag-parent', + row: htmlWithBody.offset, + payload: {tag: 'li', parent: '
          ,
            or tags'}, + })); + + test('li within ul', () => { + validationTest(htmlWithBody('
            • List item
            '), html); + }); + + test('li within ol', () => + validationTest(htmlWithBody('
            1. List item
            '), html)); + + test('div inside span', () => + validationTest( + htmlWithBody('
            Block inside inline
            '), + html, + { + reason: 'invalid-tag-location', + row: htmlWithBody.offset, + payload: {tag: 'div', parent: 'span'}, + }, + )); + + test('extra tag at end of doc', () => + validationTest( + ` + + Page Title + + +
    `, + html, + {reason: 'unexpected-close-tag', row: 5, payload: {tag: 'div'}}, + )); + + test('malformed DOCTYPE that doesn’t parse', () => + validationTest(' + validationTest(htmlWithHead(''), html, { + reason: 'missing-title', + row: htmlWithHead.offset - 1, + })); + + test('generates specific error when missing', () => + validationTest(htmlWithHead(''), html, { + reason: 'empty-title-element', + row: htmlWithHead.offset, + })); + + test('title with text', () => + validationTest(htmlWithHead('test'), html)); + + testValidatorAcceptance(html, 'html'); +}); diff --git a/src/validations/__tests__/javascript.test.js b/src/validations/__tests__/javascript.test.js new file mode 100644 index 0000000000..35cbc76a86 --- /dev/null +++ b/src/validations/__tests__/javascript.test.js @@ -0,0 +1,79 @@ +import partialRight from 'lodash-es/partialRight'; + +import javascript from '../javascript'; + +import validationTest from './validationHelper'; +import testValidatorAcceptance from './testValidatorAcceptance'; + +const analyzer = { + enabledLibraries: [], + containsExternalScript: false, +}; + +const analyzerWithjQuery = { + enabledLibraries: ['jquery'], + containsExternalScript: false, +}; + +const analyzerWithExternalScript = { + enabledLibraries: [], + containsExternalScript: true, +}; + +describe('javascript validation', () => { + test('invalid LHS error followed by comment', () => + validationTest( + `alert(--"str" + // comment`, + partialRight(javascript, analyzer), + {reason: 'missing-token', row: 0, payload: {token: ')'}}, + {reason: 'invalid-left-hand-string', row: 1, payload: {value: '"str"'}}, + )); + + test('for loop with only initializer', () => + validationTest( + 'for(var count=1){', + partialRight(javascript, analyzer), + { + reason: 'strict-operators.custom-case', + row: 0, + payload: {goodOperator: ';', badOperator: ')'}, + }, + { + reason: 'unmatched', + row: 0, + payload: {openingSymbol: '{', closingSymbol: '}'}, + }, + )); + + test('undeclared variable', () => + validationTest( + 'TinyTurtle.whatever();', + partialRight(javascript, analyzer), + { + reason: 'declare-variable', + row: 0, + payload: {variable: 'TinyTurtle'}, + }, + )); + + test('undeclared variable with external script', () => + validationTest( + 'TinyTurtle.whatever();', + partialRight(javascript, analyzerWithExternalScript), + )); + + test('function used before it is declared', () => + validationTest( + `myFunction(); + function myFunction() { + return true; + }`, + partialRight(javascript, analyzer), + )); + + testValidatorAcceptance( + partialRight(javascript, analyzerWithjQuery), + 'javascript', + ); +}); diff --git a/src/validations/__tests__/rules.test.js b/src/validations/__tests__/rules.test.js new file mode 100644 index 0000000000..e2000cbd37 --- /dev/null +++ b/src/validations/__tests__/rules.test.js @@ -0,0 +1,69 @@ +import Code from '../html/rules/Code'; +import MismatchedTag from '../html/rules/MismatchedTag'; + +describe('html rules', () => { + test('misplaced close tag', () => { + const rule = new MismatchedTag(); + rule.openTag({row: 0, column: 10}, {name: 'div'}); + rule.openTag({row: 1, column: 11}, {name: 'p'}); + rule.closeTag({row: 2, column: 12}, 'div'); + rule.closeTag({row: 3, column: 13}, 'p'); + expect(Array.from(rule.done())).toEqual([ + { + code: Code.MISPLACED_CLOSE_TAG, + openTag: { + location: {row: 1, column: 11}, + name: 'p', + }, + closeTag: { + location: {row: 2, column: 12}, + name: 'div', + }, + match: {row: 3, column: 13}, + }, + ]); + }); + + test('unclosed tag', () => { + const rule = new MismatchedTag(); + rule.openTag({row: 0, column: 10}, {name: 'div'}); + rule.openTag({row: 1, column: 11}, {name: 'p'}); + rule.closeTag({row: 2, column: 12}, 'div'); + expect(Array.from(rule.done())).toEqual([ + { + code: Code.UNCLOSED_TAG, + openTag: { + location: {row: 1, column: 11}, + name: 'p', + }, + closeTag: { + location: {row: 2, column: 12}, + name: 'div', + }, + }, + ]); + }); + + test('unopened tag', () => { + const rule = new MismatchedTag(); + rule.openTag({row: 0, column: 10}, {name: 'div'}); + rule.closeTag({row: 1, column: 11}, 'div'); + rule.closeTag({row: 2, column: 12}, 'p'); + expect(Array.from(rule.done())).toEqual([ + { + code: Code.UNOPENED_TAG, + closeTag: { + location: {row: 2, column: 12}, + name: 'p', + }, + }, + ]); + }); + + test('mismatched tag okay', () => { + const rule = new MismatchedTag(); + rule.openTag({row: 0, column: 10}, {name: 'div'}); + rule.closeTag({row: 1, column: 11}, 'div'); + expect(Array.from(rule.done())).toEqual([]); + }); +}); diff --git a/src/validations/__tests__/runRules.test.js b/src/validations/__tests__/runRules.test.js new file mode 100644 index 0000000000..d66f6ac7a8 --- /dev/null +++ b/src/validations/__tests__/runRules.test.js @@ -0,0 +1,176 @@ +import runRules from '../html/runRules'; + +describe('structural html validation', () => { + test('openTag row', async () => { + expect.assertions(1); + await runRules( + [ + { + openTag({row}) { + expect(row).toEqual(2); + }, + *done() {}, + }, + ], + '\n\n
    ', + ); + }); + + test('openTag column', async () => { + expect.assertions(1); + await runRules( + [ + { + openTag({column}) { + expect(column).toEqual(1); + }, + *done() {}, + }, + ], + '\n
    ', + ); + }); + test('openTag name', async () => { + expect.assertions(1); + await runRules( + [ + { + openTag(_, {name}) { + expect(name).toEqual('div'); + }, + *done() {}, + }, + ], + '
    ', + ); + }); + + test('openTag skips void tag', async () => { + expect.assertions(0); + await runRules( + [ + { + openTag(location, tag) { + throw new Error( + `location: ${JSON.stringify(location)} tag: ${JSON.stringify( + tag, + )}`, + ); + }, + *done() {}, + }, + ], + '', + ); + }); + + test('openTag skips self-closing non-void tag', async () => { + expect.assertions(0); + await runRules( + [ + { + openTag(location, tag) { + throw new Error( + `location: ${JSON.stringify(location)} tag: ${JSON.stringify( + tag, + )}`, + ); + }, + *done() {}, + }, + ], + '
    ', + ); + }); + + test('closeTag row', async () => { + expect.assertions(1); + await runRules( + [ + { + closeTag({row}) { + expect(row).toEqual(2); + }, + *done() {}, + }, + ], + '\n\n
    ', + ); + }); + + test('closeTag column', async () => { + expect.assertions(1); + await runRules( + [ + { + closeTag({column}) { + expect(column).toEqual(1); + }, + *done() {}, + }, + ], + '\n
    ', + ); + }); + test('closeTag name', async () => { + expect.assertions(1); + await runRules( + [ + { + closeTag(_, name) { + expect(name).toEqual('div'); + }, + *done() {}, + }, + ], + '
    ', + ); + }); + test('event sequence', async () => { + const events = []; + // Use Array.from to consume the errors. + Array.from( + await runRules( + [ + { + openTag(_, {name}) { + events.push(`<${name}>`); + }, + closeTag(_, name) { + events.push(``); + }, + done() { + events.push(null); + return []; + }, + }, + ], + '

    ', + ), + ); + expect(events).toEqual(['
    ', '

    ', '

    ', '
    ', null]); + }); + + test('chains errors', async () => { + const errors = Array.from( + await runRules( + [ + { + *done() { + yield 2; + }, + }, + { + *done() { + yield 1; + }, + }, + ], + '', + ), + ); + // To avoid dependence on iteration order. + errors.sort(); + expect(errors).toEqual([1, 2]); + }); +}); diff --git a/src/validations/__tests__/testValidatorAcceptance.js b/src/validations/__tests__/testValidatorAcceptance.js new file mode 100644 index 0000000000..098fd5bbcf --- /dev/null +++ b/src/validations/__tests__/testValidatorAcceptance.js @@ -0,0 +1,9 @@ +import acceptance from './acceptance.json'; + +import validationTest from './validationHelper'; + +export default function testValidatorAcceptance(validator, language) { + test.each(acceptance[language])('Acceptance - %i', source => + validationTest(source, validator), + ); +} diff --git a/src/validations/__tests__/validationHelper.js b/src/validations/__tests__/validationHelper.js new file mode 100644 index 0000000000..a1f7b2e7a7 --- /dev/null +++ b/src/validations/__tests__/validationHelper.js @@ -0,0 +1,16 @@ +import map from 'lodash-es/map'; +import orderBy from 'lodash-es/orderBy'; +import pick from 'lodash-es/pick'; + +export default async function validationTest( + input, + validate, + ...expectedErrors +) { + const errors = await validate(input); + expect( + map(orderBy(errors, ['reason', 'row']), error => + pick(error, ['reason', 'row', 'payload']), + ), + ).toEqual(orderBy(expectedErrors, ['reason', 'row'])); +} diff --git a/src/validations/html/htmlInspector.js b/src/validations/html/htmlInspector.js index e6a962f362..984879ae4e 100644 --- a/src/validations/html/htmlInspector.js +++ b/src/validations/html/htmlInspector.js @@ -88,6 +88,7 @@ HTMLInspector.rules.add( class HtmlInspectorValidator extends Validator { constructor(source) { super(source, 'html', errorMap); + this._doc = new DOMParser().parseFromString(this.source, 'text/html'); } diff --git a/test/helpers/testValidatorAcceptance.js b/test/helpers/testValidatorAcceptance.js deleted file mode 100644 index 94c7bbb9ed..0000000000 --- a/test/helpers/testValidatorAcceptance.js +++ /dev/null @@ -1,11 +0,0 @@ -import acceptance from '../data/acceptance.json'; - -import validationTest from './validationTest'; - -export default function testValidatorAcceptance(validator, language) { - return t => { - acceptance[language].forEach(source => - t.test(`Acceptance - ${language}`, validationTest(source, validator)), - ); - }; -} diff --git a/test/helpers/validationTest.js b/test/helpers/validationTest.js deleted file mode 100644 index c64f3c484a..0000000000 --- a/test/helpers/validationTest.js +++ /dev/null @@ -1,20 +0,0 @@ -import map from 'lodash-es/map'; -import orderBy from 'lodash-es/orderBy'; -import pick from 'lodash-es/pick'; - -export default function validationTest(input, validate, ...expectedErrors) { - return async assert => { - try { - const errors = await validate(input); - assert.deepEqual( - map(orderBy(errors, ['reason', 'row']), error => - pick(error, ['reason', 'row', 'payload']), - ), - orderBy(expectedErrors, ['reason', 'row']), - ); - } catch (e) { - assert.error(e); - } - assert.end(); - }; -} diff --git a/test/unit/validations/css.js b/test/unit/validations/css.js deleted file mode 100644 index af13341b26..0000000000 --- a/test/unit/validations/css.js +++ /dev/null @@ -1,193 +0,0 @@ -import test from 'tape-catch'; - -import css from '../../../src/validations/css'; -import validationTest from '../../helpers/validationTest'; -import testValidatorAcceptance from '../../helpers/testValidatorAcceptance'; - -test( - 'valid flexbox', - validationTest( - `.flex-container { - display: flex; - flex-flow: nowrap column; - align-content: flex-end; - justify-content: flex-start; - align-items: center; - } - .flex-item { - flex: 1 0 auto; - align-self: flex-end; - order: 2; - }`, - css, - ), -); - -test( - 'valid filter', - validationTest( - `img { - filter: grayscale(100%); - }`, - css, - ), -); - -test( - 'valid text-shadow declaration', - validationTest( - `p { - text-shadow: rgba(0,0,0,0.1) 0 -5px, rgba(0,0,0,0.1) 0 -1px, \ - rgba(255,255,255,0.1) 1px 0, rgba(255,255,255,0.1) 0 1px, \ - rgba(0,0,0,0.1) -1px -1px, rgba(255,255,255,0.1) 1px 1px; - }`, - css, - ), -); - -test( - 'valid background-position declarations', - validationTest( - `img { - background-position-x: 10px; - background-position-y: 15%; - }`, - css, - ), -); - -test( - 'bogus flex value', - validationTest('.flex-item { flex: bogus; }', css, { - reason: 'invalid-value', - row: 0, - payload: {error: 'bogus'}, - }), -); - -test( - 'fails with bogus filter value', - validationTest('img { filter: whitescale(100%); }', css, { - reason: 'invalid-value', - row: 0, - payload: {error: 'whitescale('}, - }), -); - -test( - 'no opening curly brace', - validationTest( - `p - display: block;`, - css, - {reason: 'block-expected', row: 0, payload: {error: 'p'}}, - ), -); - -test( - 'no closing curly brace', - validationTest( - `p { - display: block;`, - css, - {reason: 'missing-closing-curly', row: 0}, - ), -); - -test( - 'bogus character in selector', - validationTest('p; div { display: block; }', css, { - reason: 'invalid-token-in-selector', - row: 0, - payload: {token: ';'}, - }), -); - -test( - 'invalid negative value', - validationTest('p { padding-left: -2px; }', css, { - reason: 'invalid-negative-value', - row: 0, - payload: {error: '-2px'}, - }), -); - -test( - 'invalid fractional value', - validationTest('p { z-index: 2.4; }', css, { - reason: 'invalid-fractional-value', - row: 0, - payload: {error: '2.4'}, - }), -); - -test( - 'missing semicolon at end of block', - validationTest( - `p { - display: block - }`, - css, - {reason: 'missing-semicolon', row: 1}, - ), -); - -test( - 'missing semicolon within block', - validationTest( - ` - p { - margin: 10px - padding: 5px; - } - `, - css, - {reason: 'missing-semicolon', row: 2}, - ), -); - -test( - 'extra tokens after value', - validationTest( - ` - p { - padding: 5px 5px 5px 5px 5px; - } - `, - css, - {reason: 'extra-tokens-after-value', row: 2, payload: {token: '5px'}}, - ), -); - -test( - 'potentially missing semicolon before first line', - validationTest( - `button{border:20px solid b; - }`, - css, - {reason: 'extra-tokens-after-value', row: 0, payload: {token: 'b'}}, - ), -); - -test( - 'extra token that is prefix of the beginning of the line', - validationTest( - ` - p { - border: 20px solid b; - } - `, - css, - {reason: 'extra-tokens-after-value', row: 2, payload: {token: 'b'}}, - ), -); - -test( - 'thoroughly unparseable CSS', - validationTest('

    '), html, { - reason: 'banned-attributes.align', - row: htmlWithBody.offset, - }), -); - -test( - 'void tags without explicit close', - validationTest(htmlWithBody(''), html), -); - -test( - '
    tag with relative href property', - validationTest(htmlWithBody('Bad link'), html, { - reason: 'href-style', - row: htmlWithBody.offset, - }), -); - -test( - ' tag with fragment-only URL href', - validationTest(htmlWithBody('Fragment'), html), -); - -test( - 'missing doctype', - validationTest('', html, {reason: 'doctype', row: 0}), -); - -test( - 'unclosed tag', - validationTest( - ` - - Titlte - `, - html, - {reason: 'unclosed-tag', row: 1, payload: {tag: 'html'}}, - ), -); - -test( - 'tag outside tag', - validationTest( - ` - - Title - - -

    Extra text here

    `, - html, - { - reason: 'invalid-tag-outside-body', - row: 5, - payload: {tagName: 'p'}, - }, - ), -); - -test( - 'text outside tag', - validationTest( - ` - - (Extra text here) - Title - -`, - html, - { - reason: 'invalid-text-outside-body', - row: 2, - }, - ), -); - -test( - 'text directly inside tag', - validationTest( - ` - - - Title - --- Shouldn't be here --- - - -`, - html, - { - reason: 'invalid-text-outside-body', - row: 4, - }, - ), -); - -test( - 'missing internal closing tag', - validationTest(htmlWithBody('
    '), html, { - reason: 'unclosed-tag', - row: htmlWithBody.offset + 1, - payload: {tag: 'div'}, - }), -); - -test( - 'missing internal closing tag', - validationTest(htmlWithBody('

    '), html, { - reason: 'unclosed-tag', - row: htmlWithBody.offset + 1, - payload: {tag: 'p'}, - }), -); - -test( - 'unfinished closing tag', - validationTest(htmlWithBody('

    '), html, { - reason: 'unexpected-close-tag', - row: htmlWithBody.offset, - payload: {tag: 'span'}, - }), -); - -test( - 'misplaced closing tag', - validationTest( - htmlWithBody(`
    -`), - html, - { - reason: 'misplaced-close-tag', - row: htmlWithBody.offset + 1, - payload: { - open: 'span', - close: 'div', - // Display the mismatch as one-indexed, not zero-indexed. - mismatch: htmlWithBody.offset + 1, - }, - }, - ), -); - -test( - 'space inside HTML angle bracket', - validationTest(htmlWithBody('< p>Content

    '), html, { - reason: 'space-before-tag-name', - row: htmlWithBody.offset, - payload: {tag: 'p'}, - }), -); - -test( - 'lowercase attributes', - validationTest(htmlWithBody('
    Content
    '), html), -); - -test( - 'lowercase data attributes', - validationTest(htmlWithBody('
    Content
    '), html), -); - -test( - 'uppercase attributes', - validationTest(htmlWithBody('
    Content
    '), html, { - reason: 'lower-case-attribute-name', - row: htmlWithBody.offset, - }), -); - -test( - 'uppercase attributes', - validationTest(htmlWithBody('
    Content
    '), html, { - reason: 'lower-case-attribute-name', - row: htmlWithBody.offset, - }), -); - -test( - 'ul with child text outside
  • ', - validationTest( - htmlWithBody('
      Invalid to have non-empty text nodes
    '), - html, - { - reason: 'text-elements-as-list-children', - row: htmlWithBody.offset, - payload: { - tag: 'ul', - children: 'li', - textContent: 'Invalid to have non-empty text nodes', - }, - }, - ), -); - -test( - 'ol with child text outside
  • ', - validationTest( - htmlWithBody('
      Invalid to have non-empty text nodes
    '), - html, - { - reason: 'text-elements-as-list-children', - row: htmlWithBody.offset, - payload: { - tag: 'ol', - children: 'li', - textContent: 'Invalid to have non-empty text nodes', - }, - }, - ), -); - -test( - 'li not inside ul', - validationTest(htmlWithBody('
  • Orphaned List Item
  • '), html, { - reason: 'invalid-tag-parent', - row: htmlWithBody.offset, - payload: {tag: 'li', parent: '
      ,
        or tags'}, - }), -); - -test( - 'li inside div', - validationTest(htmlWithBody('
      1. List within span
      2. '), html, { - reason: 'invalid-tag-parent', - row: htmlWithBody.offset, - payload: {tag: 'li', parent: '
          ,
            or tags'}, - }), -); - -test( - 'li within ul', - validationTest(htmlWithBody('
            • List item
            '), html), -); - -test( - 'li within ol', - validationTest(htmlWithBody('
            1. List item
            '), html), -); - -test( - 'div inside span', - validationTest( - htmlWithBody('
            Block inside inline
            '), - html, - { - reason: 'invalid-tag-location', - row: htmlWithBody.offset, - payload: {tag: 'div', parent: 'span'}, - }, - ), -); - -test( - 'extra tag at end of doc', - validationTest( - ` - -Page Title - - -
    `, - html, - {reason: 'unexpected-close-tag', row: 5, payload: {tag: 'div'}}, - ), -); - -test( - 'malformed DOCTYPE that doesn’t parse', - validationTest(''), html, { - reason: 'empty-title-element', - row: htmlWithHead.offset, - }), -); - -test( - 'title with text', - validationTest(htmlWithHead('test'), html), -); - -test('acceptance', testValidatorAcceptance(html, 'html')); diff --git a/test/unit/validations/html/rules.js b/test/unit/validations/html/rules.js deleted file mode 100644 index fd7c3927a1..0000000000 --- a/test/unit/validations/html/rules.js +++ /dev/null @@ -1,73 +0,0 @@ -import test from 'tape-catch'; - -import Code from '../../../../src/validations/html/rules/Code'; -import MismatchedTag from '../../../../src/validations/html/rules/MismatchedTag'; - -test('misplaced close tag', t => { - const rule = new MismatchedTag(); - rule.openTag({row: 0, column: 10}, {name: 'div'}); - rule.openTag({row: 1, column: 11}, {name: 'p'}); - rule.closeTag({row: 2, column: 12}, 'div'); - rule.closeTag({row: 3, column: 13}, 'p'); - t.deepEqual(Array.from(rule.done()), [ - { - code: Code.MISPLACED_CLOSE_TAG, - openTag: { - location: {row: 1, column: 11}, - name: 'p', - }, - closeTag: { - location: {row: 2, column: 12}, - name: 'div', - }, - match: {row: 3, column: 13}, - }, - ]); - t.end(); -}); - -test('unclosed tag', t => { - const rule = new MismatchedTag(); - rule.openTag({row: 0, column: 10}, {name: 'div'}); - rule.openTag({row: 1, column: 11}, {name: 'p'}); - rule.closeTag({row: 2, column: 12}, 'div'); - t.deepEqual(Array.from(rule.done()), [ - { - code: Code.UNCLOSED_TAG, - openTag: { - location: {row: 1, column: 11}, - name: 'p', - }, - closeTag: { - location: {row: 2, column: 12}, - name: 'div', - }, - }, - ]); - t.end(); -}); - -test('unopened tag', t => { - const rule = new MismatchedTag(); - rule.openTag({row: 0, column: 10}, {name: 'div'}); - rule.closeTag({row: 1, column: 11}, 'div'); - rule.closeTag({row: 2, column: 12}, 'p'); - t.deepEqual(Array.from(rule.done()), [ - { - code: Code.UNOPENED_TAG, - closeTag: { - location: {row: 2, column: 12}, - name: 'p', - }, - }, - ]); - t.end(); -}); - -test('mismatched tag okay', t => { - const rule = new MismatchedTag(); - rule.openTag({row: 0, column: 10}, {name: 'div'}); - rule.closeTag({row: 1, column: 11}, 'div'); - t.deepEqual(Array.from(rule.done()), []); - t.end(); -}); diff --git a/test/unit/validations/html/runRules.js b/test/unit/validations/html/runRules.js deleted file mode 100644 index fb815ee41e..0000000000 --- a/test/unit/validations/html/runRules.js +++ /dev/null @@ -1,193 +0,0 @@ -import test from 'tape-catch'; - -import runRules from '../../../../src/validations/html/runRules'; - -test('openTag row', async t => { - t.plan(1); - await runRules( - [ - { - openTag({row}) { - t.equal(row, 2); - }, - - *done() {}, - }, - ], - '\n\n
    ', - ); - t.end(); -}); - -test('openTag column', async t => { - t.plan(1); - await runRules( - [ - { - openTag({column}) { - t.equal(column, 1); - }, - - *done() {}, - }, - ], - '\n
    ', - ); - t.end(); -}); - -test('openTag name', async t => { - t.plan(1); - await runRules( - [ - { - openTag(_, {name}) { - t.equal(name, 'div'); - }, - - *done() {}, - }, - ], - '
    ', - ); - t.end(); -}); - -test('openTag skips void tag', async t => { - await runRules( - [ - { - openTag(location, tag) { - t.fail( - `location: ${JSON.stringify(location)} tag: ${JSON.stringify(tag)}`, - ); - }, - - *done() {}, - }, - ], - '', - ); - t.end(); -}); - -test('openTag skips self-closing non-void tag', async t => { - await runRules( - [ - { - openTag(location, tag) { - t.fail( - `location: ${JSON.stringify(location)} tag: ${JSON.stringify(tag)}`, - ); - }, - - *done() {}, - }, - ], - '
    ', - ); - t.end(); -}); - -test('closeTag row', async t => { - t.plan(1); - await runRules( - [ - { - closeTag({row}) { - t.equal(row, 2); - }, - - *done() {}, - }, - ], - '\n\n
    ', - ); - t.end(); -}); - -test('closeTag column', async t => { - t.plan(1); - await runRules( - [ - { - closeTag({column}) { - t.equal(column, 1); - }, - - *done() {}, - }, - ], - '\n
    ', - ); - t.end(); -}); - -test('closeTag name', async t => { - t.plan(1); - await runRules( - [ - { - closeTag(_, name) { - t.equal(name, 'div'); - }, - - *done() {}, - }, - ], - '
    ', - ); - t.end(); -}); - -test('event sequence', async t => { - const events = []; - // Use Array.from to consume the errors. - Array.from( - await runRules( - [ - { - openTag(_, {name}) { - events.push(`<${name}>`); - }, - - closeTag(_, name) { - events.push(``); - }, - - done() { - events.push(null); - return []; - }, - }, - ], - '

    ', - ), - ); - t.deepEqual(events, ['
    ', '

    ', '

    ', '
    ', null]); - t.end(); -}); - -test('chains errors', async t => { - const errors = Array.from( - await runRules( - [ - { - *done() { - yield 2; - }, - }, - { - *done() { - yield 1; - }, - }, - ], - '', - ), - ); - // To avoid dependence on iteration order. - errors.sort(); - t.deepEqual(errors, [1, 2]); - t.end(); -}); diff --git a/test/unit/validations/javascript.js b/test/unit/validations/javascript.js deleted file mode 100644 index d526aee74e..0000000000 --- a/test/unit/validations/javascript.js +++ /dev/null @@ -1,86 +0,0 @@ -import test from 'tape-catch'; -import partialRight from 'lodash-es/partialRight'; - -import validationTest from '../../helpers/validationTest'; -import testValidatorAcceptance from '../../helpers/testValidatorAcceptance'; -import javascript from '../../../src/validations/javascript'; - -const analyzer = { - enabledLibraries: [], - containsExternalScript: false, -}; - -const analyzerWithjQuery = { - enabledLibraries: ['jquery'], - containsExternalScript: false, -}; - -const analyzerWithExternalScript = { - enabledLibraries: [], - containsExternalScript: true, -}; - -test( - 'invalid LHS error followed by comment', - validationTest( - `alert(--"str" -// comment`, - partialRight(javascript, analyzer), - {reason: 'missing-token', row: 0, payload: {token: ')'}}, - {reason: 'invalid-left-hand-string', row: 1, payload: {value: '"str"'}}, - ), -); - -test( - 'for loop with only initializer', - validationTest( - 'for(var count=1){', - partialRight(javascript, analyzer), - { - reason: 'strict-operators.custom-case', - row: 0, - payload: {goodOperator: ';', badOperator: ')'}, - }, - { - reason: 'unmatched', - row: 0, - payload: {openingSymbol: '{', closingSymbol: '}'}, - }, - ), -); - -test( - 'undeclared variable', - validationTest('TinyTurtle.whatever();', partialRight(javascript, analyzer), { - reason: 'declare-variable', - row: 0, - payload: {variable: 'TinyTurtle'}, - }), -); - -test( - 'undeclared variable with external script', - validationTest( - 'TinyTurtle.whatever();', - partialRight(javascript, analyzerWithExternalScript), - ), -); - -test( - 'function used before it is declared', - validationTest( - `myFunction(); - function myFunction() { - return true; - }`, - partialRight(javascript, analyzer), - ), -); - -test( - 'acceptance', - testValidatorAcceptance( - partialRight(javascript, analyzerWithjQuery), - 'javascript', - ), -);