GitLab Commit is coming up on August 3-4. Learn how to innovate together using GitLab, the DevOps platform. Register for free: gitlabcommitvirtual2021.com

Commits (25)
# html-validate changelog
# [3.3.0](https://gitlab.com/html-validate/html-validate/compare/v3.2.0...v3.3.0) (2020-09-08)
### Bug Fixes
- **jest:** add missing `filename` to typescript declaration ([4be48fa](https://gitlab.com/html-validate/html-validate/commit/4be48fa1323f28719bf3909643eec91c9ed455eb))
- **meta:** default to pass when testing excluded category from unknown element ([07afa1a](https://gitlab.com/html-validate/html-validate/commit/07afa1aa7cb5f302b9caca74b923a5342c4a330c))
- **rules:** handle unknown elements better in `element-permitted-content` ([58ba1aa](https://gitlab.com/html-validate/html-validate/commit/58ba1aa4a7fcbee7743db10c27b6429420c07f8e)), closes [#95](https://gitlab.com/html-validate/html-validate/issues/95)
### Features
- **jest:** `toHTMLValidate()` supports passing expected errors ([7b3c30e](https://gitlab.com/html-validate/html-validate/commit/7b3c30e622130e93c4bc03e6455f94d85e746b84))
# [3.2.0](https://gitlab.com/html-validate/html-validate/compare/v3.1.0...v3.2.0) (2020-08-26)
### Features
......
......@@ -17,7 +17,7 @@ This makes all the custom matchers available.
## API
### `toHTMLValidate(config?: ConfigData, filename?: string)`
### `toHTMLValidate([error?: Message], [config?: ConfigData], filename?: string)`
Validates a string of HTML and passes the assertion if the markup is valid.
......@@ -55,7 +55,7 @@ This means you can apply transformations using patterns such as `^.*\\.(spec|tes
If you need to override the filename (perhaps because the test-case isn't in the same folder) you can pass in a custom filename as the third argument:
```js
expect("<p></i>").toHTMLValidate(null, "path/to/my-file.html");
expect("<p></i>").toHTMLValidate("path/to/my-file.html");
```
Additionally, the `root` configuration property can be used to skip loading from `.htmlvalidate.json` but remember to actually include the rules you need:
......@@ -67,6 +67,23 @@ expect("<p></i>").toHTMLValidate({
});
```
To test for presence of an error always use the negative form `expect(..).not.toHTMLValidate()`.
If you pass in an expected error as the first argument it will be matched using `objectContaining` when an error is present.
```js
/* OK - error matches */
expect("<p></i>").not.toHTMLValidate({
ruleId: "close-order",
message: expect.stringContaining("Mismatched close-tag"),
});
/* Fail - wrong error */
expect("<p></i>").not.toHTMLValidate({
ruleId: "void-style",
message: expect.stringContaining("Expected omitted end tag"),
});
```
### `toBeValid()`
Assert that a HTML-Validate report is valid.
......
This diff is collapsed.
{
"name": "html-validate",
"version": "3.2.0",
"version": "3.3.0",
"description": "html linter",
"keywords": [
"html",
......@@ -92,22 +92,22 @@
"minimist": "^1.2.0"
},
"devDependencies": {
"@babel/core": "7.11.4",
"@babel/preset-env": "7.11.0",
"@babel/core": "7.11.6",
"@babel/preset-env": "7.11.5",
"@commitlint/cli": "9.1.2",
"@html-validate/commitlint-config": "1.0.3",
"@html-validate/eslint-config": "1.5.15",
"@html-validate/jest-config": "1.0.22",
"@html-validate/eslint-config": "1.5.17",
"@html-validate/jest-config": "1.0.24",
"@html-validate/prettier-config": "1.0.1",
"@html-validate/semantic-release-config": "1.0.34",
"@html-validate/semantic-release-config": "1.0.36",
"@types/babel__code-frame": "7.0.1",
"@types/estree": "0.0.45",
"@types/glob": "7.1.3",
"@types/inquirer": "7.3.1",
"@types/jest": "26.0.10",
"@types/jest": "26.0.13",
"@types/json-merge-patch": "0.0.5",
"@types/minimist": "1.2.0",
"@types/node": "11.15.20",
"@types/node": "11.15.21",
"autoprefixer": "9.8.6",
"babelify": "10.0.0",
"bootstrap-sass": "3.4.1",
......@@ -130,23 +130,23 @@
"grunt-postcss": "0.9.0",
"grunt-sass": "3.1.0",
"highlight.js": "10.1.2",
"husky": "4.2.5",
"husky": "4.3.0",
"jest": "26.4.2",
"jest-diff": "26.4.2",
"jquery": "3.5.1",
"lint-staged": "10.2.13",
"lint-staged": "10.3.0",
"load-grunt-tasks": "5.1.0",
"marked": "1.1.1",
"minimatch": "3.0.4",
"prettier": "2.1.0",
"prettier": "2.1.1",
"pretty-format": "26.4.2",
"sass": "1.26.10",
"semantic-release": "17.1.1",
"serve-static": "1.14.1",
"stringmap": "0.2.2",
"strip-ansi": "6.0.0",
"ts-jest": "26.2.0",
"typescript": "3.9.7"
"ts-jest": "26.3.0",
"typescript": "4.0.2"
},
"jest": {
"preset": "@html-validate/jest-config",
......
......@@ -266,4 +266,53 @@ describe("toHTMLValidate()", () => {
const button = doc.createElement("button");
expect(button).not.toHTMLValidate();
});
it("should pass if markup has correct error", () => {
expect.assertions(1);
expect("<u></i>").not.toHTMLValidate({
ruleId: "close-order",
message: expect.stringContaining("Mismatched close-tag"),
});
});
it("should fail if markup has wrong error", async () => {
expect.assertions(3);
let error: Error;
try {
await expect("<u></i>").not.toHTMLValidate({
ruleId: "wrong-error",
message: expect.stringContaining("Some other error"),
});
} catch (e) {
error = e;
}
expect(error).toBeDefined();
expect(stripAnsi(error.message)).toMatchInlineSnapshot(`
"expect(received).not.toHTMLValidate(expected) // expected error
Expected error to be present:
{\\"message\\": StringContaining \\"Some other error\\", \\"ruleId\\": \\"wrong-error\\"}
- Expected error
+ Actual error
- ArrayContaining [
- ObjectContaining {
- \\"message\\": StringContaining \\"Some other error\\",
- \\"ruleId\\": \\"wrong-error\\",
+ Array [
+ Object {
+ \\"column\\": 5,
+ \\"context\\": undefined,
+ \\"line\\": 1,
+ \\"message\\": \\"Mismatched close-tag, expected '</u>' but found '</i>'.\\",
+ \\"offset\\": 4,
+ \\"ruleId\\": \\"close-order\\",
+ \\"selector\\": null,
+ \\"severity\\": 2,
+ \\"size\\": 2,
},
]"
`);
});
});
......@@ -31,8 +31,21 @@ declare global {
* Test passes if result is valid.
*
* @param config - Optional HTML-Validate configuration object.
* @param filename - Optional filename used when matching transformer and
* loading configuration.
*/
toHTMLValidate(config?: ConfigData): R;
toHTMLValidate(): R;
toHTMLValidate(filename: string): R;
toHTMLValidate(config: ConfigData): R;
toHTMLValidate(config: ConfigData, filename: string): R;
toHTMLValidate(error: Partial<Message>): R;
toHTMLValidate(error: Partial<Message>, filename: string): R;
toHTMLValidate(error: Partial<Message>, config: ConfigData): R;
toHTMLValidate(
error: Partial<Message>,
config: ConfigData,
filename: string
): R;
}
}
}
......@@ -144,18 +157,64 @@ function toHaveErrors(
return { pass, message: resultMessage };
}
function isMessage(arg: any): arg is Partial<Message> {
return (
arg &&
(arg.ruleId ||
arg.severity ||
arg.message ||
arg.offset ||
arg.line ||
arg.column ||
arg.size ||
arg.selector ||
arg.context)
);
}
function isConfig(arg: any): arg is ConfigData {
return (
arg &&
(arg.root ||
arg.extends ||
arg.elements ||
arg.plugin ||
arg.transform ||
arg.rules)
);
}
function isString(arg: any): arg is string {
return typeof arg === "string";
}
function toHTMLValidate(
this: jest.MatcherUtils,
// @ts-ignore DOM library not available
actual: string | HTMLElement,
userConfig?: ConfigData,
filename?: string
arg0?: Partial<Message> | ConfigData | string,
arg1?: ConfigData | string,
arg2?: string
): jest.CustomMatcherResult {
// @ts-ignore DOM library not available
if (actual instanceof HTMLElement) {
actual = actual.outerHTML;
}
const message = isMessage(arg0) ? arg0 : undefined;
const config = isConfig(arg0) ? arg0 : isConfig(arg1) ? arg1 : undefined;
const filename = isString(arg0) ? arg0 : isString(arg1) ? arg1 : arg2;
return toHTMLValidateImpl.call(this, actual, message, config, filename);
}
function toHTMLValidateImpl(
this: jest.MatcherUtils,
actual: string,
expectedError?: Partial<Message>,
userConfig?: ConfigData,
filename?: string
): jest.CustomMatcherResult {
const defaultConfig = {
rules: {
/* jsdom normalizes style so disabling rule when using this matcher or it
......@@ -172,6 +231,33 @@ function toHTMLValidate(
if (pass) {
return { pass, message: () => "HTML is valid when an error was expected" };
} else {
if (expectedError) {
const matcher = expect.arrayContaining([
expect.objectContaining(expectedError),
]);
const errorPass = this.equals(report.results[0].messages, matcher);
const diffString = diff(matcher, report.results[0].messages, {
expand: this.expand,
aAnnotation: "Expected error",
bAnnotation: "Actual error",
});
const hint = this.utils.matcherHint(
".not.toHTMLValidate",
undefined,
undefined,
{ comment: "expected error" }
);
const expectedErrorMessage = (): string =>
[
hint,
"",
"Expected error to be present:",
this.utils.printExpected(expectedError),
/* istanbul ignore next */ diffString ? `\n${diffString}` : "",
].join("\n");
return { pass: !errorPass, message: expectedErrorMessage };
}
const errors = report.results[0].messages.map(
(message) => ` ${message.message} [${message.ruleId}]`
);
......
......@@ -323,6 +323,22 @@ describe("Meta validator", () => {
expect(Validator.validatePermitted(bar, rules)).toBeFalsy();
});
it("should default to pass when excluding category from element without meta", () => {
expect.assertions(1);
const table = new MetaTable();
table.loadFromObject({});
const parser = new Parser(new ConfigMock(table));
const [foo] = parser.parseHtml("<foo/>").root.childElements;
const rules = [
[
{
exclude: "@interactive",
},
],
];
expect(Validator.validatePermitted(foo, rules)).toBeTruthy();
});
it("should handle empty object", () => {
expect.assertions(1);
const table = new MetaTable();
......
......@@ -92,7 +92,10 @@ export class Validator {
let prev = null;
for (const node of children) {
const old = i;
while (rules[i] && !Validator.validatePermittedCategory(node, rules[i])) {
while (
rules[i] &&
!Validator.validatePermittedCategory(node, rules[i], true)
) {
i++;
}
......@@ -103,7 +106,7 @@ export class Validator {
* - elements where the order doesn't matter
* In both of these cases no error should be reported. */
const orderSpecified = rules.find((cur: string) =>
Validator.validatePermittedCategory(node, cur)
Validator.validatePermittedCategory(node, cur, true)
);
if (orderSpecified) {
cb(node, prev);
......@@ -211,23 +214,24 @@ export class Validator {
private static validatePermittedRule(
node: HtmlElement,
rule: PermittedEntry
rule: PermittedEntry,
isExclude: boolean = false
): boolean {
if (typeof rule === "string") {
return Validator.validatePermittedCategory(node, rule);
return Validator.validatePermittedCategory(node, rule, !isExclude);
} else if (Array.isArray(rule)) {
return rule.every((inner: PermittedEntry) => {
return Validator.validatePermittedRule(node, inner);
return Validator.validatePermittedRule(node, inner, isExclude);
});
} else {
validateKeys(rule);
if (rule.exclude) {
if (Array.isArray(rule.exclude)) {
return !rule.exclude.some((inner: PermittedEntry) => {
return Validator.validatePermittedRule(node, inner);
return Validator.validatePermittedRule(node, inner, true);
});
} else {
return !Validator.validatePermittedRule(node, rule.exclude);
return !Validator.validatePermittedRule(node, rule.exclude, true);
}
} else {
return true;
......@@ -243,13 +247,15 @@ export class Validator {
* parent it should also allow @flow parent since @phrasing is a subset of
* @flow.
*
* @param {HtmlElement} node - The node to test against
* @param {string} category - Name of category with '@' prefix or tag name.
* @param node - The node to test against
* @param category - Name of category with '@' prefix or tag name.
* @param defaultMatch - The default return value when node categories is not known.
*/
// eslint-disable-next-line complexity
private static validatePermittedCategory(
node: HtmlElement,
category: string
category: string,
defaultMatch: boolean
): boolean {
/* match tagName when an explicit name is given */
if (category[0] !== "@") {
......@@ -259,7 +265,7 @@ export class Validator {
/* if the meta entry is missing assume any content model would match */
if (!node.meta) {
return true;
return defaultMatch;
}
switch (category) {
......
......@@ -20,6 +20,29 @@ describe("rule element-permitted-content", () => {
);
});
it("should report error when child is disallowed (referenced by tagname without meta)", () => {
expect.assertions(2);
const htmlvalidate = new HtmlValidate({
elements: [
"html5",
{
"custom-link": {
permittedContent: [{ exclude: "custom-element" }],
},
},
],
rules: { "element-permitted-content": "error" },
});
const report = htmlvalidate.validateString(
"<custom-link><custom-element></custom-element></custom-link>"
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"element-permitted-content",
"Element <custom-element> is not permitted as content in <custom-link>"
);
});
it("should report error when descendant is disallowed", () => {
expect.assertions(2);
const report = htmlvalidate.validateString(
......@@ -32,6 +55,41 @@ describe("rule element-permitted-content", () => {
);
});
it("should report error when descendant is disallowed (referenced by tagname without meta)", () => {
expect.assertions(2);
const htmlvalidate = new HtmlValidate({
elements: [
"html5",
{
"custom-link": {
permittedDescendants: [{ exclude: "custom-element" }],
},
},
],
rules: { "element-permitted-content": "error" },
});
const report = htmlvalidate.validateString(
"<custom-link><span><custom-element></custom-element></span></custom-link>"
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"element-permitted-content",
"Element <custom-element> is not permitted as descendant of <custom-link>"
);
});
it("should report error when descendant is disallowed (intermediate element without meta)", () => {
expect.assertions(2);
const report = htmlvalidate.validateString(
"<a><custom-element><button></button></custom-element></a>"
);
expect(report).toBeInvalid();
expect(report).toHaveError(
"element-permitted-content",
"Element <button> is not permitted as descendant of <a>"
);
});
it("should not report error when phrasing a-element is child of @phrasing", () => {
expect.assertions(1);
const report = htmlvalidate.validateString(
......
......@@ -17,26 +17,19 @@ export default class ElementPermittedContent extends Rule {
this.on("dom:ready", (event: DOMReadyEvent) => {
const doc = event.document;
doc.visitDepthFirst((node: HtmlElement) => {
/* dont verify root element, assume any element is allowed */
if (node.parent.isRootElement()) {
return;
}
const parent = node.parent;
/* if parent doesn't have metadata (unknown element) skip checking permitted
* content */
if (!node.parent.meta) {
/* dont verify root element, assume any element is allowed */
if (parent.isRootElement()) {
return;
}
const parent = node.parent;
const rules = parent.meta.permittedContent;
/* Run each validation step, stop as soon as any errors are
* reported. This is to prevent multiple similar errors on the same
* element, such as "<dd> is not permitted content under <span>" and
* "<dd> has no permitted ancestors". */
[
() => this.validatePermittedContent(node, parent, rules),
() => this.validatePermittedContent(node, parent),
() => this.validatePermittedDescendant(node, parent),
() => this.validatePermittedAncestors(node),
].some((fn) => fn());
......@@ -45,9 +38,23 @@ export default class ElementPermittedContent extends Rule {
}
private validatePermittedContent(
cur: HtmlElement,
parent: HtmlElement
): boolean {
/* if parent doesn't have metadata (unknown element) skip checking permitted
* content */
if (!parent.meta) {
return false;
}
const rules = parent.meta.permittedContent ?? null;
return this.validatePermittedContentImpl(cur, parent, rules);
}
private validatePermittedContentImpl(
cur: HtmlElement,
parent: HtmlElement,
rules: Permitted
rules: Permitted | null
): boolean {
if (!Validator.validatePermitted(cur, rules)) {
this.report(
......@@ -63,7 +70,7 @@ export default class ElementPermittedContent extends Rule {
if (cur.meta && cur.meta.transparent) {
return cur.childElements
.map((child: HtmlElement) => {
return this.validatePermittedContent(child, parent, rules);
return this.validatePermittedContentImpl(child, parent, rules);
})
.some(Boolean);
}
......@@ -75,19 +82,23 @@ export default class ElementPermittedContent extends Rule {
node: HtmlElement,
parent: HtmlElement
): boolean {
while (!parent.isRootElement()) {
if (
parent.meta &&
node.meta &&
!Validator.validatePermitted(node, parent.meta.permittedDescendants)
) {
this.report(
node,
`Element <${node.tagName}> is not permitted as descendant of ${parent.annotatedName}`
);
return true;
for (let cur = parent; !cur.isRootElement(); cur = cur.parent) {
const meta = cur.meta;
/* ignore checking parent without meta */
if (!meta) {
continue;
}
if (Validator.validatePermitted(node, meta.permittedDescendants)) {
continue;
}
parent = parent.parent;
this.report(
node,
`Element <${node.tagName}> is not permitted as descendant of ${cur.annotatedName}`
);
return true;
}
return false;
}
......