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 , or '), html);
+ });
+
+ test('li within ol', () =>
+ validationTest(htmlWithBody('- 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(`${name}>`);
+ },
+ 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 , or '), html),
-);
-
-test(
- 'li within ol',
- validationTest(htmlWithBody('- 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(`${name}>`);
- },
-
- 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',
- ),
-);