mirror of
https://github.com/nature-of-code/noc-book-2
synced 2024-11-16 07:47:48 +01:00
linting and tests for snippets and external examples
This commit is contained in:
parent
6eb0ea516c
commit
c40039fa87
5 changed files with 4905 additions and 299 deletions
4927
package-lock.json
generated
4927
package-lock.json
generated
File diff suppressed because it is too large
Load diff
13
package.json
13
package.json
|
@ -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
92
tests/examples.test.js
Normal 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
76
tests/linter.js
Normal 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
96
tests/snippets.test.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
Loading…
Reference in a new issue