fix: Use ESLint Linter API to more directly control parser and rules module loading (#4078)

* fix: Use ESLint Linter API to more directly control parser and rules module loading
* chore: Add smoke test to verify conflicting eslint version do not break addons-linter
This commit is contained in:
Luca Greco 2021-12-13 18:09:43 +01:00 коммит произвёл GitHub
Родитель 8690b32a57
Коммит eeccf2547a
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
21 изменённых файлов: 259 добавлений и 176 удалений

Просмотреть файл

@ -123,6 +123,7 @@ jobs:
# This should run after we persist the workspace because this command # This should run after we persist the workspace because this command
# invokes `npm install`. # invokes `npm install`.
- run: npm run webext-test-functional - run: npm run webext-test-functional
- run: npm run smoke-test-eslint-version-conflicts
- store_artifacts: - store_artifacts:
path: coverage path: coverage
@ -140,6 +141,7 @@ jobs:
# production-like environment # production-like environment
- run: npm run test-integration:production - run: npm run test-integration:production
- run: npm run webext-test-functional - run: npm run webext-test-functional
- run: npm run smoke-test-eslint-version-conflicts
test-alternate: test-alternate:
<<: *defaults-alternate <<: *defaults-alternate
@ -155,6 +157,7 @@ jobs:
# production-like environment # production-like environment
- run: npm run test-integration:production - run: npm run test-integration:production
- run: npm run webext-test-functional - run: npm run webext-test-functional
- run: npm run smoke-test-eslint-version-conflicts
release-tag: release-tag:
<<: *defaults <<: *defaults

Просмотреть файл

@ -2,6 +2,7 @@ scripts/download-import-tag
scripts/list-firefox-tags scripts/list-firefox-tags
scripts/run-l10n-extraction scripts/run-l10n-extraction
scripts/webext-test-functional scripts/webext-test-functional
scripts/smoke-test-eslint-version-conflicts
dist dist
coverage coverage
tests/fixtures/** tests/fixtures/**

Просмотреть файл

@ -8,9 +8,3 @@
!bin/addons-linter !bin/addons-linter
!dist/addons-linter.* !dist/addons-linter.*
!dist/locale/** !dist/locale/**
!dist/rules/**
# Overridden .eslintignore file to prevent addons-linter
# from loading an arbirary .eslintignore from the cwd
# (See https://github.com/mozilla/addons-linter/issues/3390)
!addons-linter.eslintignore

Просмотреть файл

@ -6,6 +6,7 @@ scripts/download-import-tag
scripts/list-firefox-tags scripts/list-firefox-tags
scripts/run-l10n-extraction scripts/run-l10n-extraction
scripts/webext-test-functional scripts/webext-test-functional
scripts/smoke-test-eslint-version-conflicts
package-lock.json package-lock.json
LICENSE LICENSE

Просмотреть файл

@ -1,3 +0,0 @@
# Overridden .eslintignore file to prevent addons-linter
# from loading an arbirary .eslintignore from the cwd
# (See https://github.com/mozilla/addons-linter/issues/3390)

Просмотреть файл

@ -3,7 +3,6 @@
const path = require('path'); const path = require('path');
const absoluteAppRoot = path.resolve(path.join(__dirname, '..')); const absoluteAppRoot = path.resolve(path.join(__dirname, '..'));
global.appRoot = path.relative(process.cwd(), absoluteAppRoot);
global.localesRoot = path.join(absoluteAppRoot, 'dist', 'locale'); global.localesRoot = path.join(absoluteAppRoot, 'dist', 'locale');
global.nodeRequire = require; global.nodeRequire = require;

Просмотреть файл

@ -31,6 +31,7 @@
"build-rules": "scripts/build-rules && cp node_modules/github-markdown-css/github-markdown.css docs/github-markdown.css", "build-rules": "scripts/build-rules && cp node_modules/github-markdown-css/github-markdown.css docs/github-markdown.css",
"gen-contributing-toc": "doctoc CONTRIBUTING.md", "gen-contributing-toc": "doctoc CONTRIBUTING.md",
"webext-test-functional": "scripts/webext-test-functional", "webext-test-functional": "scripts/webext-test-functional",
"smoke-test-eslint-version-conflicts": "scripts/smoke-test-eslint-version-conflicts",
"update-hashes": "scripts/dispensary > src/dispensary/hashes.txt" "update-hashes": "scripts/dispensary > src/dispensary/hashes.txt"
}, },
"repository": { "repository": {

Просмотреть файл

@ -0,0 +1,36 @@
#!/usr/bin/env bash
set -e
LINTER_PATH=$(pwd)
TARGET_TEST_PATH="$LINTER_PATH/tmp/test-project"
FIXTURE_TEST_PATH="$LINTER_PATH/tests/fixtures/eslint-versions-conflicts-package"
LINTER_VERSION=$(node -p 'require("./package.json").version')
# Fail if the target path already exists.
[[ -f "$TARGET_TEST_PATH" ]] && (
echo "ERROR: Temporary test dir '$TARGET_TEST_PATH' already exists."
exit 1
)
echo -e "INFO: Initializing test environment\n"
# Build addons-linter
npm ci
npm run build
# Create target test path directory and cleanup on exit.
cp -rf "$FIXTURE_TEST_PATH" "$TARGET_TEST_PATH"
trap 'rm -rf "$TARGET_TEST_PATH"' EXIT
pushd "$TARGET_TEST_PATH"
npm pack "$LINTER_PATH"
npm install
npm install --save-dev "./addons-linter-$LINTER_VERSION.tgz"
echo -e "INFO: Run addons-linter on the test fixture project\n"
npx addons-linter ./src || (
echo "ERROR: addons-linter validation failed".
exit 2
)

Просмотреть файл

@ -0,0 +1,25 @@
import content_scripts_file_absent from './content-scripts-file-absent';
import deprecated_entities from './deprecated-entities';
import event_listener_fourth from './event-listener-fourth';
import global_require_arg from './global-require-arg';
import opendialog_nonlit_uri from './opendialog-nonlit-uri';
import opendialog_remote_uri from './opendialog-remote-uri';
import webextension_api from './webextension-api';
import webextension_api_compat from './webextension-api-compat';
import webextension_api_compat_android from './webextension-api-compat-android';
import webextension_deprecated_api from './webextension-deprecated-api';
import webextension_unsupported_api from './webextension-unsupported-api';
export default {
'content-scripts-file-absent': content_scripts_file_absent,
'deprecated-entities': deprecated_entities,
'event-listener-fourth': event_listener_fourth,
'global-require-arg': global_require_arg,
'opendialog-nonlit-uri': opendialog_nonlit_uri,
'opendialog-remote-uri': opendialog_remote_uri,
'webextension-api': webextension_api,
'webextension-api-compat': webextension_api_compat,
'webextension-api-compat-android': webextension_api_compat_android,
'webextension-deprecated-api': webextension_deprecated_api,
'webextension-unsupported-api': webextension_unsupported_api,
};

Просмотреть файл

@ -1,7 +1,5 @@
/* global appRoot */
import path from 'path';
import ESLint from 'eslint'; import ESLint from 'eslint';
import noUnsanitized from 'eslint-plugin-no-unsanitized';
import { oneLine } from 'common-tags'; import { oneLine } from 'common-tags';
import * as espree from 'espree'; import * as espree from 'espree';
import * as vk from 'eslint-visitor-keys'; import * as vk from 'eslint-visitor-keys';
@ -11,7 +9,7 @@ import { ESLINT_RULE_MAPPING, ESLINT_TYPES } from 'const';
import * as messages from 'messages'; import * as messages from 'messages';
import { ensureFilenameExists } from 'utils'; import { ensureFilenameExists } from 'utils';
const IGNORE_FILE = 'addons-linter.eslintignore'; import customEslintRules from '../rules/javascript';
export default class JavaScriptScanner { export default class JavaScriptScanner {
disabledRules = []; disabledRules = [];
@ -45,10 +43,9 @@ export default class JavaScriptScanner {
_ESLint = ESLint, _ESLint = ESLint,
_messages = messages, _messages = messages,
_ruleMapping = ESLINT_RULE_MAPPING, _ruleMapping = ESLINT_RULE_MAPPING,
// This tells ESLint where to expect the ESLint rules for addons-linter. // This property is used to inject additional custom eslint rules
// Its default value is defined below. This property is mainly used for // as part of tests.
// testing purposes. _rules = undefined,
_rulePaths = undefined,
} = {}) { } = {}) {
const detectedSourceType = this.detectSourceType(this.filename); const detectedSourceType = this.detectSourceType(this.filename);
this.sourceType = detectedSourceType.sourceType; this.sourceType = detectedSourceType.sourceType;
@ -61,34 +58,38 @@ export default class JavaScriptScanner {
} }
}); });
const root = const linter = new _ESLint.Linter();
typeof appRoot !== 'undefined' ? appRoot : path.join(__dirname, '..');
// Load additional rules injected by unit tests.
if (_rules) {
for (const ruleName of Object.keys(_rules)) {
linter.defineRule(ruleName, _rules[ruleName]);
}
}
// Load custom eslint rules embedded into addons-linter bundle.
for (const key of Object.keys(customEslintRules)) {
linter.defineRule(key, customEslintRules[key]);
}
// Load plugins rules.
const pluginRules = noUnsanitized.rules;
for (const key of Object.keys(pluginRules)) {
linter.defineRule(`no-unsanitized/${key}`, pluginRules[key]);
}
linter.defineParser('addons-linter-espree', espree);
const eslintConfig = { const eslintConfig = {
resolvePluginsRelativeTo: path.resolve(root),
// The default value for `rulePaths` is configured so that it finds the
// files exported by webpack when this project is built.
rulePaths: _rulePaths || [path.join(root, 'dist', 'rules', 'javascript')],
allowInlineConfig: false,
// Avoid loading the addons-linter .eslintrc file
useEslintrc: false,
// Avoid loading the .eslintignore file from the cwd
// by explicitly configuring eslint with a custom ignore file
// packaged with the addons-linter npm package.
ignorePath: path.join(path.resolve(root), IGNORE_FILE),
baseConfig: {
env: { env: {
browser: true, browser: true,
es6: true, es6: true,
webextensions: true, webextensions: true,
}, },
// It's the default but also shouldn't change since we're using // Ensure we use the same parser and parserOptions used to detect
// espree to parse javascript files below manually to figure out // the sourceType.
// if they're modules or not parser: 'addons-linter-espree',
parser: 'espree',
parserOptions: { parserOptions: {
ecmaVersion: ECMA_VERSION, ecmaVersion: ECMA_VERSION,
sourceType: this.sourceType, sourceType: this.sourceType,
@ -106,21 +107,18 @@ export default class JavaScriptScanner {
addonMetadata: this.options.addonMetadata, addonMetadata: this.options.addonMetadata,
existingFiles: this.options.existingFiles, existingFiles: this.options.existingFiles,
}, },
},
}; };
const cli = new _ESLint.ESLint(eslintConfig); const results = linter.verify(this.code, eslintConfig, {
const results = await cli.lintText(this.code, { allowInlineConfig: false,
filePath: this.filename, filename: this.filename,
warnIgnored: true,
}); });
// eslint prepends the filename with the current working directory, // eslint prepends the filename with the current working directory,
// strip that out. // strip that out.
this.scannedFiles.push(this.filename); this.scannedFiles.push(this.filename);
results.forEach((result) => { results.forEach((message) => {
result.messages.forEach((message) => {
let extraShortDescription = ''; let extraShortDescription = '';
// Fatal error messages (like SyntaxErrors) are a bit different, we // Fatal error messages (like SyntaxErrors) are a bit different, we
@ -201,7 +199,6 @@ export default class JavaScriptScanner {
type: ESLINT_TYPES[message.severity], type: ESLINT_TYPES[message.severity],
}); });
}); });
});
return { return {
linterMessages: this.linterMessages, linterMessages: this.linterMessages,

11
tests/fixtures/eslint-versions-conflicts-package/package.json поставляемый Normal file
Просмотреть файл

@ -0,0 +1,11 @@
{
"name": "eslint-versions-conflicts-package",
"version": "1.0.0",
"description": "test issues with conflicting eslint versions under the same package",
"keywords": [],
"author": "",
"license": "MPL-v2",
"devDependencies": {
"eslint": "^7.32.0"
}
}

Просмотреть файл

@ -0,0 +1 @@
console.log("background page loaded");

Просмотреть файл

@ -0,0 +1,11 @@
{
"manifest_version": 2,
"name": "test-extension",
"version": "0.1",
"permissions": [],
"background": {
"scripts": [
"background.js"
]
}
}

Просмотреть файл

@ -25,9 +25,7 @@ jest.mock('cli', () => {
}; };
}); });
global.appRoot = path.join(__dirname, '..'); if (!fs.existsSync(path.join(__dirname, '..', 'dist'))) {
if (!fs.existsSync(path.join(global.appRoot, 'dist'))) {
throw new Error('Please run `npm run build` before running the test suite.'); throw new Error('Please run `npm run build` before running the test suite.');
} }

Просмотреть файл

@ -368,14 +368,17 @@ export const getJsRulePathForRule = (ruleName) => {
return path.join(FIXTURES_DIR, 'rules', 'javascript', ruleName); return path.join(FIXTURES_DIR, 'rules', 'javascript', ruleName);
}; };
export function runJsScanner( export async function runJsScanner(
jsScanner, jsScanner,
{ fixtureRules = [], scanOptions } = {} { fixtureRules = [], scanOptions } = {}
) { ) {
const ruleSource = path.join(global.appRoot, 'src/rules/javascript'); const _rules = {};
const fixturePaths = fixtureRules.map(getJsRulePathForRule); for (const ruleName of fixtureRules) {
const mod = await import(getJsRulePathForRule(ruleName));
_rules[ruleName] = mod.default ? mod.default : mod;
}
return jsScanner.scan({ return jsScanner.scan({
...scanOptions, ...scanOptions,
_rulePaths: [ruleSource].concat(fixturePaths), _rules,
}); });
} }

Просмотреть файл

@ -4,15 +4,30 @@ import { readdirSync } from 'fs';
import { ESLINT_RULE_MAPPING } from 'const'; import { ESLINT_RULE_MAPPING } from 'const';
describe('Eslint rules object', () => { describe('Eslint rules object', () => {
it('should have files that match the keys', () => { const files = readdirSync('src/rules/javascript').filter(
const files = readdirSync('src/rules/javascript'); (fileName) => fileName !== 'index.js'
files.forEach((fileName) => { );
it.each(files)(
'%s rule module should have a matching rule mapping',
(fileName) => {
expect( expect(
Object.prototype.hasOwnProperty.call( Object.prototype.hasOwnProperty.call(
ESLINT_RULE_MAPPING, ESLINT_RULE_MAPPING,
path.parse(fileName).name path.parse(fileName).name
) )
).toBe(true); ).toBe(true);
}); }
}); );
it.each(files)(
'%s rule module should be exported by the index.js module',
async (fileName) => {
const modName = path.parse(fileName).name;
const indexMod = (await import('rules/javascript')).default;
const ruleMod = (await import(`rules/javascript/${fileName}`)).default;
expect(typeof indexMod[modName]?.create).toBe('function');
expect(indexMod[modName]).toBe(ruleMod);
}
);
}); });

Просмотреть файл

@ -4,16 +4,17 @@ import * as messages from 'messages';
import { runJsScanner } from '../../helpers'; import { runJsScanner } from '../../helpers';
describe('unsupported manifest v2 APIs tested with mock', () => { jest.mock('schema/browser-apis.js', () => {
beforeAll(() => jest.resetModules());
it('returns expected message for APIs unsupported in manifest_version >= 3', async () => {
jest.doMock('schema/browser-apis.js', () => {
return { return {
hasBrowserApi: () => false, hasBrowserApi: () => false,
isMV2RemovedApi: () => true, isMV2RemovedApi: () => true,
}; };
}); });
describe('unsupported manifest v2 APIs tested with mock', () => {
beforeAll(() => jest.resetModules());
it('returns expected message for APIs unsupported in manifest_version >= 3', async () => {
const jsScanner = new JavaScriptScanner( const jsScanner = new JavaScriptScanner(
'browser.pageAction.show();', 'browser.pageAction.show();',
'code.js', 'code.js',

Просмотреть файл

@ -241,23 +241,18 @@ describe('JavaScript Scanner', () => {
}); });
it('should reject on missing message code', async () => { it('should reject on missing message code', async () => {
class FakeESLintClass { class FakeLinterClass {
async lintText() { defineRule() {}
return Promise.resolve([
{ defineParser() {}
filePath: 'badcode.js',
messages: [ verify() {
{ return [{ fatal: false }];
fatal: false,
},
],
},
]);
} }
} }
const FakeESLint = { const FakeESLint = {
ESLint: FakeESLintClass, Linter: FakeLinterClass,
}; };
const jsScanner = new JavaScriptScanner('whatever', 'badcode.js'); const jsScanner = new JavaScriptScanner('whatever', 'badcode.js');
@ -317,7 +312,6 @@ describe('JavaScript Scanner', () => {
const fakeMetadata = { const fakeMetadata = {
addonMetadata: validMetadata({ guid: 'snowflake' }), addonMetadata: validMetadata({ guid: 'snowflake' }),
}; };
const fakeESLintMapping = { 'metadata-not-passed': ESLINT_ERROR };
const jsScanner = new JavaScriptScanner( const jsScanner = new JavaScriptScanner(
'var hello = "something";', 'var hello = "something";',
@ -328,7 +322,7 @@ describe('JavaScript Scanner', () => {
const { linterMessages } = await runJsScanner(jsScanner, { const { linterMessages } = await runJsScanner(jsScanner, {
scanOptions: { scanOptions: {
_messages: fakeMessages, _messages: fakeMessages,
_ruleMapping: fakeESLintMapping, _ruleMapping: { 'metadata-not-passed': ESLINT_ERROR },
}, },
fixtureRules: ['metadata-not-passed'], fixtureRules: ['metadata-not-passed'],
}); });

Просмотреть файл

@ -1,5 +1,4 @@
/* eslint import/order: 0 */ /* eslint import/order: 0 */
const glob = require('glob');
const path = require('path'); const path = require('path');
// eslint-disable-next-line import/no-extraneous-dependencies // eslint-disable-next-line import/no-extraneous-dependencies
@ -12,10 +11,6 @@ module.exports = {
// webpack3 bundling step. // webpack3 bundling step.
mode: 'none', mode: 'none',
entry: { entry: {
...glob.sync('./src/rules/javascript/*.js').reduce((acc, file) => {
acc[file.replace(/^\.\/src\//, '').replace('.js', '')] = file;
return acc;
}, {}),
'addons-linter': './src/main.js', 'addons-linter': './src/main.js',
}, },
target: 'node', target: 'node',