linting and tests for snippets and external examples

This commit is contained in:
Francis Turmel 2023-10-02 09:36:26 -04:00
parent 6eb0ea516c
commit c40039fa87
5 changed files with 4905 additions and 299 deletions

4927
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,10 @@
"serve": "gatsby serve",
"clean": "gatsby clean",
"build": "gatsby build",
"build:pdf": "magicbook build"
"build:pdf": "magicbook build",
"test": "jest",
"test:examples": "jest tests/examples.test.js",
"test:snippets": "jest tests/snippets.test.js"
},
"repository": {
"type": "git",
@ -22,7 +25,12 @@
"homepage": "https://github.com/nature-of-code/noc-notion#readme",
"dependencies": {
"@tailwindcss/typography": "^0.5.4",
"@typescript-eslint/eslint-plugin": "^6.7.3",
"@typescript-eslint/parser": "^6.7.3",
"autoprefixer": "^10.4.7",
"cheerio": "^1.0.0-rc.12",
"eslint": "^8.50.0",
"eslint-plugin-prefer-let": "^3.0.1",
"fs-extra": "^10.1.0",
"gatsby": "^4.19.1",
"gatsby-plugin-image": "^2.19.0",
@ -32,6 +40,8 @@
"gatsby-source-filesystem": "^4.19.0",
"gatsby-transformer-json": "^4.19.0",
"gatsby-transformer-sharp": "^4.19.0",
"glob": "^10.3.9",
"jest": "^29.7.0",
"katex": "^0.16.0",
"lodash-es": "^4.17.21",
"magicbook": "^0.1.31",
@ -49,6 +59,7 @@
"rehype-react": "^7.1.1",
"rehype-slug": "^5.0.1",
"tailwindcss": "^3.1.6",
"typescript": "^5.2.2",
"unified": "^10.1.2",
"unist-util-remove": "^3.1.0",
"unist-util-visit": "^4.1.0"

92
tests/examples.test.js Normal file
View file

@ -0,0 +1,92 @@
const cheerio = require('cheerio');
const fs = require('node:fs');
const glob = require('glob');
const path = require('node:path');
const { getLinter } = require('./linter');
const externalAllowList = [
'https://cdnjs.cloudflare.com/ajax/libs/p5.js/1.7.0/p5.min.js',
'https://unpkg.com/ml5@latest/dist/ml5.min.js', // TODO pin version when ml5-next-gen is stable
'https://cdn.jsdelivr.net/gh/hapticdata/toxiclibsjs@0.3.2/build/toxiclibs.min.js',
'https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js',
];
const editorUrls = new Map();
glob.sync('content/*.html').forEach((htmlFilePath) => {
const source = fs.readFileSync(htmlFilePath);
const $ = cheerio.load(source.toString());
$('[data-example-path]').each((_, el) => {
const $el = $(el);
const examplePath = $el.attr('data-example-path');
const editorUrl = $el.attr('data-p5-editor');
editorUrls.set(path.join('content/', examplePath), editorUrl);
});
});
// ---
describe('Validate examples <script> tags', () => {
const protocolRegex = /^https?:\/\//;
glob.sync('content/examples/*/*/index.html').forEach((indexPath) => {
const dir = path.parse(indexPath).dir;
describe(`${indexPath} | ${editorUrls.get(dir)}`, () => {
const source = fs.readFileSync(indexPath).toString();
const $ = cheerio.load(source);
$('script').each((_, el) => {
const src = $(el).attr('src');
if (protocolRegex.test(src)) {
test('External library is not in allow list', () => {
expect(externalAllowList).toContain(src);
});
} else {
test(`File "${src}" is referenced by a <script> tag but missing`, () => {
const localFilePath = path.join(dir, src);
expect(fs.existsSync(localFilePath)).toBe(true);
});
}
});
});
});
});
describe('Validate examples preview images`', () => {
glob.sync('content/examples/*/*/').forEach((exampleDir) => {
describe(`${exampleDir} | ${editorUrls.get(exampleDir)}`, () => {
test('Example should have a `screenshot.png', () => {
const screenshotPath = path.join(exampleDir, 'screenshot.png');
expect(fs.existsSync(screenshotPath)).toBe(true);
});
});
});
});
describe('Lint examples`', () => {
glob.sync('content/examples/*/*/').forEach((exampleDir) => {
const chapter = Number(exampleDir.split('/').at(-2).split('_')[0]);
const linter = getLinter('examples', chapter);
test(editorUrls.get(exampleDir), async () => {
const res = await linter.lintFiles(path.join(exampleDir, '/**/*.js'));
const errors = res
.filter((r) => r.errorCount > 0)
.flatMap((r) => {
const filepath = path.join(
exampleDir,
path.relative(exampleDir, r.filePath),
);
return r.messages.map(
(m) => `${filepath}:${m.line} | ${m.ruleId} | ${m.message}`,
);
});
expect(errors).toEqual([]);
});
});
});

76
tests/linter.js Normal file
View file

@ -0,0 +1,76 @@
const eslint = require('eslint');
function getLinter(type, chapter) {
const rules = {
// ENABLED RULES:
eqeqeq: 'error',
'prefer-let/prefer-let': 'error',
'no-tabs': 'error',
'space-infix-ops': 'error',
'space-before-blocks': 'error',
'func-call-spacing': ['error', 'never'],
'space-before-function-paren': ['error', 'never'],
'@typescript-eslint/no-for-in-array': 'error',
'no-prototype-builtins': 'error',
'object-curly-spacing': [
'error',
'always',
{ arraysInObjects: true, objectsInObjects: true },
],
'keyword-spacing': ['error', { before: true }],
'key-spacing': ['error', { beforeColon: false }],
'array-element-newline': ['error', 'consistent'],
'array-bracket-spacing': ['error', 'never'],
'array-bracket-newline': ['error', 'consistent'],
'no-useless-return': 'error',
'no-unneeded-ternary': 'error',
// Nitpicky, but could be useful
// 'one-var': ['error', 'never'],
// 'comma-dangle': ['error', 'always-multiline'],
// 'operator-assignment': ['error', 'always'],
// 'no-negated-condition': 'error',
// 'function-paren-newline': ['error', 'multiline'],
// semi: ['error', 'always'],
// quotes: ['error', 'single', { avoidEscape: true }],
// 'no-shadow': 'error',
// DISABLED RULES:
// Snippets can be fragments, code can be in diffent files without imports, etc.
'no-undef': 'off',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': 'off',
// One snippet has an empty function to discuss arguments
'no-empty': 'off',
// Some snippets redeclare variables with strikethrough styles
'no-redeclare': 'off',
// Allow `while (true) {}`
'no-constant-condition': ['error', { checkLoops: false }],
// Overlaps with `no-tabs` rule
'no-mixed-spaces-and-tabs': 'off',
};
if (chapter > 4) {
rules['@typescript-eslint/prefer-for-of'] = 'error';
}
return new eslint.ESLint({
overrideConfig: {
extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
plugins: ['prefer-let', '@typescript-eslint'],
parser: '@typescript-eslint/parser',
parserOptions: {
ecmaVersion: 6,
},
rules,
},
});
}
module.exports = { getLinter };

96
tests/snippets.test.js Normal file
View file

@ -0,0 +1,96 @@
const cheerio = require('cheerio');
const fs = require('node:fs');
const glob = require('glob');
const path = require('node:path');
const { getLinter } = require('./linter');
const transforms = [
// isolated method
(code) => `class TestWrapper { ${code} }`,
(code) => `class TestWrapper { ${code} } }`,
(code) => `class TestWrapper { ${code}`,
// isolated function body with return statement
(code) => `function testFunction() { ${code} }`,
(code) => `function testFunction() { ${code}`,
// missing closing brackets
(code) => `${code} }`,
(code) => `${code} } }`,
// extra closing brackets
(code) => code.replace(/(\})\s*$/g, ''),
(code) => code.replace(/(\}\s*\})\s*$/g, ''),
];
async function lintSnippet(linter, code) {
// 1) try as-is
let res = await linter.lintText(code);
if (res[0].fatalErrorCount === 0) return res;
// 2) remove common problems and mutate the code string
// strip start/end ellipsis that indicate a fragment
code = code.replace(/^\s*(\.\.\.)/g, '');
code = code.replace(/(\.\.\.)\s*$/g, '');
// replace known descriptive text inside snippets
code = code.replaceAll(/(\?\?\?+)/gm, 'undefined'); // 3+ consecutive question marks
code = code.replaceAll('some fancy calculations', 'undefined');
code = code.replaceAll('some value that increments slowly', '0');
code = code.replaceAll('_______________?', 'undefined');
res = await linter.lintText(code);
if (res[0].fatalErrorCount === 0) return res;
// 3) try isolated transforms
for (const transform of transforms) {
const transformedRes = await linter.lintText(transform(code));
if (transformedRes[0].fatalErrorCount === 0) return transformedRes;
}
// we did not manage to heal the snippet, return step 2) results
return res;
}
// ---
describe('Lint embedded code snippets`', () => {
glob.sync('content/*.html').forEach((htmlFilePath) => {
const chapter = Number(path.parse(htmlFilePath).name.split('_')[0]);
const linter = getLinter('snippets', chapter);
const source = fs.readFileSync(htmlFilePath).toString();
const $ = cheerio.load(source, {
withStartIndices: true,
xmlMode: true,
});
$('[data-code-language="javascript"]').each((_, el) => {
const $el = $(el);
const lineNumber = source
.substring(0, $el.get(0).startIndex)
.split('\n').length;
const code = $el.text();
test(`${htmlFilePath}:${lineNumber}`, async () => {
let res = await lintSnippet(linter, code);
const errors = res
.filter((r) => r.errorCount > 0)
.flatMap((r) =>
r.messages.map(
(m) =>
`${htmlFilePath}:${lineNumber + m.line - 1} - Line ${
m.line
} | ${m.ruleId} | ${m.message}`,
),
);
expect(errors).toEqual([]);
});
});
});
});