textlint-tester
Version:
testing tool for textlint rule.
441 lines (414 loc) • 14.5 kB
text/typescript
// LICENSE : MIT
;
import * as assert from "assert";
import { testInvalid, testValid } from "./test-util";
import {
TextlintFixResult,
TextlintKernel,
TextlintKernelDescriptor,
TextlintKernelPlugin,
TextlintPluginCreator,
TextlintRuleModule
} from "@textlint/kernel";
import { coreFlags } from "@textlint/feature-flag";
import textPlugin from "@textlint/textlint-plugin-text";
import markdownPlugin from "@textlint/textlint-plugin-markdown";
import fs from "fs/promises";
import path from "path";
import { TextlintPluginOptions, TextlintRuleOptions } from "@textlint/types";
const hasOwnProperty = Object.prototype.hasOwnProperty;
const globalObject = globalThis;
// Type guard helper
function isObjectWithProperty(obj: unknown, property: string): obj is Record<string, unknown> {
return typeof obj === "object" && obj !== null && property in obj;
}
const describe =
typeof globalObject.describe === "function"
? globalObject.describe
: function (this: unknown, _text: string, method: () => unknown) {
return method.apply(this);
};
const it =
typeof globalObject.it === "function"
? globalObject.it
: function (this: unknown, _text: string, method: () => unknown) {
return method.apply(this);
};
/**
* get fixer function from ruleCreator
* if not found, throw error
* @param {((...args: any[]) => any)|Object} ruleCreator
* @param {string} ruleName
*/
function assertHasFixer(ruleCreator: unknown, ruleName: string): void {
if (isObjectWithProperty(ruleCreator, "fixer") && typeof ruleCreator.fixer === "function") {
return;
}
if (typeof ruleCreator === "function") {
return;
}
throw new Error(`Not found \`fixer\` function in the ruleCreator: ${ruleName}`);
}
function assertTestConfig(testConfig: TestConfig): void {
assert.notEqual(testConfig, null, "TestConfig is null");
assert.notEqual(
Object.keys(testConfig).length === 0 && testConfig.constructor === Object,
true,
"TestConfig is empty"
);
assert.ok(Array.isArray(testConfig.rules), "TestConfig.rules should be an array");
assert.ok(testConfig.rules.length > 0, "TestConfig.rules should have at least one rule");
testConfig.rules.forEach((rule) => {
assert.ok(hasOwnProperty.call(rule, "ruleId"), "ruleId property not found");
assert.ok(hasOwnProperty.call(rule, "rule"), "rule property not found");
});
if (typeof testConfig.plugins !== "undefined") {
assert.ok(Array.isArray(testConfig.plugins), "TestConfig.plugins should be an array");
testConfig.plugins.forEach((plugin) => {
assert.ok(hasOwnProperty.call(plugin, "pluginId"), "pluginId property not found");
assert.ok(hasOwnProperty.call(plugin, "plugin"), "plugin property not found");
});
}
}
export type TestConfigPlugin = {
pluginId: string;
plugin: TextlintPluginCreator;
options?: TextlintPluginOptions | boolean;
};
export type TestConfigRule = {
ruleId: string;
rule: TextlintRuleModule;
options?: TextlintRuleOptions | boolean;
};
export type TestConfig = {
plugins?: TestConfigPlugin[];
rules: TestConfigRule[];
};
function isTestConfig(arg: unknown): arg is TestConfig {
if (hasOwnProperty.call(arg, "rules")) {
return true;
}
if ((isObjectWithProperty(arg, "fixer") && typeof arg.fixer === "function") || typeof arg === "function") {
return false;
}
return true;
}
export type TesterValid =
| string
| {
text?: string;
ext?: string;
inputPath?: string;
options?: TextlintRuleOptions;
description?: string;
};
export type TesterErrorDefinition = {
ruleId?: string;
range?: readonly [startIndex: number, endIndex: number];
loc?: {
start: {
line: number;
column: number;
};
end: {
line: number;
column: number;
};
};
/**
* @deprecated use `range` option
*/
index?: number;
/**
* @deprecated use `loc` option
*/
line?: number;
/**
* @deprecated use `loc` option
*/
column?: number;
message?: string;
/**
* array of suggestions for the error
*/
suggestions?: {
id: string;
message?: string;
range?: readonly [startIndex: number, endIndex: number];
output?: string;
}[];
};
export type TesterInvalid = {
text?: string;
output?: string;
ext?: string;
inputPath?: string;
options?: TextlintRuleOptions;
description?: string;
errors: TesterErrorDefinition[];
};
export type TestRuleSet = {
rules: { [index: string]: TextlintRuleModule };
rulesOptions: Record<string, TextlintRuleOptions>;
};
export type TestPluginSet = {
plugins: { [index: string]: TextlintPluginCreator };
pluginOptions: Record<string, TextlintPluginOptions | boolean>;
};
function createTestPluginSet(testConfigPlugins: TestConfigPlugin[]): TestPluginSet {
const testPluginSet: TestPluginSet = {
plugins: {},
pluginOptions: {}
};
testConfigPlugins.forEach((plugin) => {
const pluginName = plugin.pluginId;
const pluginOptions = plugin.options ?? true;
testPluginSet.plugins[pluginName] = plugin.plugin;
testPluginSet.pluginOptions[pluginName] = pluginOptions;
});
return testPluginSet;
}
const builtInPlugins: TextlintKernelPlugin[] = [
{
pluginId: "@textlint/textlint-plugin-text",
plugin: textPlugin,
options: true
},
{
pluginId: "@textlint/textlint-plugin-markdown",
plugin: markdownPlugin,
options: true
}
];
interface CreateTextlintKernelDescriptorArgs {
testName: string;
// base rule definition
testRuleDefinition: TextlintRuleModule | TestConfig;
// each test case options
testCaseOptions?: TestConfigRule["options"];
}
export const createTextlintKernelDescriptor = ({
testName,
testRuleDefinition,
testCaseOptions
}: CreateTextlintKernelDescriptorArgs): TextlintKernelDescriptor => {
if (isTestConfig(testRuleDefinition)) {
const testConfig = testRuleDefinition;
assertTestConfig(testConfig);
// Note: testCaseOptions is not supported and it will be just ignored.
// Assertion check it
// > Could not specify options property in valid object when TestConfig was passed. Use TestConfig.rules.options.
const testPluginSet = createTestPluginSet(testConfig.plugins || []);
const plugins = [
...builtInPlugins,
...Object.keys(testPluginSet.plugins).map((pluginId) => {
return {
pluginId,
plugin: testPluginSet.plugins[pluginId],
options: testPluginSet.pluginOptions[pluginId]
};
})
];
return new TextlintKernelDescriptor({
rules: testConfig.rules,
filterRules: [],
plugins
});
} else {
return new TextlintKernelDescriptor({
rules: [
{
ruleId: testName,
rule: testRuleDefinition,
options: testCaseOptions
}
],
filterRules: [],
plugins: builtInPlugins
});
}
};
export const createTestLinter = (textlintKernelDescriptor: TextlintKernelDescriptor) => {
const kernel = new TextlintKernel();
return {
async lintText(text: string, ext: string) {
return kernel.lintText(text, {
ext,
...textlintKernelDescriptor.toKernelOptions()
});
},
async lintFile(filePath: string) {
const text = await fs.readFile(filePath, "utf-8");
const ext = path.extname(filePath);
return kernel.lintText(text, {
ext,
filePath,
...textlintKernelDescriptor.toKernelOptions()
});
},
async fixText(text: string, ext: string) {
return kernel.fixText(text, {
ext,
...textlintKernelDescriptor.toKernelOptions()
});
},
async fixFile(filePath: string) {
const text = await fs.readFile(filePath, "utf-8");
const ext = path.extname(filePath);
return kernel.fixText(text, {
ext,
filePath,
...textlintKernelDescriptor.toKernelOptions()
});
}
};
};
export class TextLintTester {
constructor() {
if (typeof coreFlags === "object") {
coreFlags.runningTester = true;
}
}
testValidPattern(testName: string, param: TextlintRuleModule | TestConfig, valid: TesterValid) {
const text = typeof valid === "object" ? valid.text : valid;
const inputPath = typeof valid === "object" ? valid.inputPath : undefined;
const ext = typeof valid === "object" && valid.ext !== undefined ? valid.ext : ".md";
const options = typeof valid === "object" && valid.options !== undefined ? valid.options : undefined;
const description =
typeof valid === "object" && valid.description !== undefined ? valid.description : undefined;
const textlint = createTestLinter(
createTextlintKernelDescriptor({
testName,
testRuleDefinition: param,
testCaseOptions: options
})
);
const textCaseName = `${inputPath || text}`;
it(textCaseName, () => {
if (inputPath) {
return testValid({
textlint,
inputPath,
description
});
} else if (text !== undefined && ext) {
return testValid({
textlint,
text,
ext,
description
});
}
throw new Error(`valid should have text or inputPath property.
valid: [ "text", { text: "text" }, { inputPath: "path/to/file" } ]
`);
});
}
testInvalidPattern(testName: string, param: TextlintRuleModule | TestConfig, invalid: TesterInvalid) {
const errors = invalid.errors;
const inputPath = invalid.inputPath;
const text = invalid.text;
const ext = invalid.ext !== undefined ? invalid.ext : ".md";
const options = invalid.options;
const description = invalid.description;
const textlint = createTestLinter(
createTextlintKernelDescriptor({
testName,
testRuleDefinition: param,
testCaseOptions: options
})
);
const testCaseName = `${inputPath || text}`;
it(testCaseName, () => {
if (inputPath) {
return testInvalid({
textlint,
inputPath,
errors,
description
});
} else if (text !== undefined && ext) {
return testInvalid({
textlint,
text,
ext,
errors,
description
});
}
throw new Error(`invalid should have { text } or { inputPath } property.
invalid: [ { text: "text", errors: [...] }, { inputPath: "path/to/file", errors: [...] } ]
`);
});
// --fix
if (hasOwnProperty.call(invalid, "output")) {
it(`Fixer: ${testCaseName}`, () => {
if (isTestConfig(param)) {
param.rules.forEach((rule) => {
assertHasFixer(rule.rule, rule.ruleId);
});
} else {
assertHasFixer(param, testName);
}
let promise: Promise<TextlintFixResult>;
if (inputPath !== undefined) {
promise = textlint.fixFile(inputPath);
} else if (text !== undefined) {
promise = textlint.fixText(text, ext);
} else {
throw new Error("Should set `text` or `inputPath`");
}
return promise.then((result) => {
const output = invalid.output;
assert.strictEqual(result.output, output);
});
});
}
}
/**
* run test for textlint rule.
* @param {string} name name is name of the test or rule
* @param {TextlintRuleModule|TestConfig} testRuleDefinition param is TextlintRuleCreator or TestConfig
* @param {string[]|object[]} [valid]
* @param {object[]} [invalid]
*/
run(
name: string,
testRuleDefinition: TextlintRuleModule | TestConfig,
{
valid = [],
invalid = []
}: {
valid?: TesterValid[];
invalid?: TesterInvalid[];
}
) {
if (isTestConfig(testRuleDefinition)) {
assertTestConfig(testRuleDefinition);
if (valid) {
valid.forEach((validCase) => {
assert.ok(
!hasOwnProperty.call(validCase, "options"),
"Could not specify options property in valid object when TestConfig was passed. Use TestConfig.rules.options."
);
});
}
if (invalid) {
invalid.forEach((invalidCase) => {
assert.ok(
!hasOwnProperty.call(invalidCase, "options"),
"Could not specify options property in invalid object when TestConfig was passed. Use TestConfig.rules.options."
);
});
}
}
describe(name, () => {
invalid.forEach((state) => {
this.testInvalidPattern(name, testRuleDefinition, state);
});
valid.forEach((state) => {
this.testValidPattern(name, testRuleDefinition, state);
});
});
}
}