diff --git a/packages/extension-vscode/src/quickfix-provider.ts b/packages/extension-vscode/src/quickfix-provider.ts index e500f25de18..187e9142d42 100644 --- a/packages/extension-vscode/src/quickfix-provider.ts +++ b/packages/extension-vscode/src/quickfix-provider.ts @@ -1,6 +1,9 @@ import { CodeAction, CodeActionKind, CodeActionParams, Command, Diagnostic, TextDocuments } from 'vscode-languageserver'; import { TextDocument } from 'vscode-languageserver-textdocument'; -import { getFeatureNameFromDiagnostic } from './utils/problems'; + +import type { Problem } from '@hint/utils-types'; + +import { getFeatureNameFromDiagnostic, WebhintDiagnosticData } from './utils/problems'; export class QuickFixActionProvider { private documents: TextDocuments; @@ -11,16 +14,116 @@ export class QuickFixActionProvider { this.sourceName = sourceName; } + private createCodeFixAction(hintName: string, diagnostic: Diagnostic, problem: Problem): CodeAction { + if (!problem.fixes) { + throw new Error('Unable to determine which fixes to apply'); + } + + const action = CodeAction.create( + `Fix '${hintName}' issue`, + { + arguments: [hintName, hintName], + command: 'vscode-webhint/apply-code-fix', + title: hintName + }, + CodeActionKind.QuickFix + ); + + action.diagnostics = [diagnostic]; + + action.edit = { + changes: { + [problem.resource]: problem.fixes.map((fix) => { + return { + newText: fix.text, + range: { + end: { + character: fix.location.endColumn ?? 0, + line: fix.location.endLine ?? 0 + }, + start: { + character: fix.location.column, + line: fix.location.line + } + } + }; + }) + } + }; + + return action; + } + + private createIgnoreAxeRuleAction(hintName: string, diagnostic: Diagnostic): CodeAction { + const command = 'vscode-webhint/ignore-axe-rule-project'; + const url = diagnostic.codeDescription?.href; + const ruleName = url && url.substring(url.lastIndexOf('/') + 1, url.indexOf('?')); + + /* istanbul ignore next */ + if (!ruleName) { + throw new Error('Unable to determine which axe-core rule to ignore'); + } + + const action = CodeAction.create( + `Ignore '${ruleName}' accessibility in this project`, + { + arguments: [ruleName, hintName], + command, + title: ruleName + }, + CodeActionKind.QuickFix + ); + + /* + * TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed + * action.diagnostics = [diagnostic]; + */ + + return action; + } + + private createIgnoreBrowsersAction(hintName: string, diagnostic: Diagnostic, problem: Problem): CodeAction { + const command = 'vscode-webhint/ignore-browsers-project'; + const browsers = problem.browsers; + + // TODO: Consider passing the friendly browser names in the problem to avoid i18n issues. + const [, firstBrowser, separator] = diagnostic.message.match(/ by (.+?)(,|\. |\.$)/) || []; + const hasMore = separator === ','; + + /* istanbul ignore next */ + if (!browsers || !firstBrowser) { + throw new Error('Unable to determine which browsers to ignore'); + } + + const action = CodeAction.create( + `Ignore '${firstBrowser}${hasMore ? ', \u2026' : ''}' compatibility in this project`, + { + arguments: ['browsers', hintName, problem], + command, + title: 'browsers' + }, + CodeActionKind.QuickFix + ); + + /* + * TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed + * action.diagnostics = [diagnostic]; + */ + + return action; + } + private createIgnoreFeatureAction(hintName: string, diagnostic: Diagnostic): CodeAction { const command = 'vscode-webhint/ignore-feature-project'; const featureName = getFeatureNameFromDiagnostic(diagnostic); + /* istanbul ignore next */ if (!featureName) { throw new Error('Unable to determine which HTML/CSS feature to ignore'); } const action = CodeAction.create( - `Ignore '${featureName}' in this project`, + `Ignore '${featureName}' compatibility in this project`, { arguments: [featureName, hintName], command, @@ -29,7 +132,10 @@ export class QuickFixActionProvider { CodeActionKind.QuickFix ); - action.diagnostics = [diagnostic]; + /* + * TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed + * action.diagnostics = [diagnostic]; + */ return action; } @@ -46,7 +152,10 @@ export class QuickFixActionProvider { CodeActionKind.QuickFix ); - action.diagnostics = [diagnostic]; + /* + * TODO: link to diagnostic once https://github.com/microsoft/vscode/issues/126393 is fixed + * action.diagnostics = [diagnostic]; + */ return action; } @@ -58,31 +167,46 @@ export class QuickFixActionProvider { return null; } + const webhintDiagnostics = params.context.diagnostics.filter((diagnostic) => { + return diagnostic.data && diagnostic.source && diagnostic.source === this.sourceName; + }); + + if (webhintDiagnostics.length === 0) { + return null; + } + const results: CodeAction[] = []; - params.context.diagnostics.forEach((currentDiagnostic) => { - // only respond to requests for specified source. - if (!currentDiagnostic.source || currentDiagnostic.source !== this.sourceName) { - return; - } + // First add options to ignore reported diagnostics (if available). + webhintDiagnostics.forEach((diagnostic) => { + const hintName = `${diagnostic.code}`; + const data = diagnostic.data as WebhintDiagnosticData; - const hintName = `${currentDiagnostic.code}`; + if (data.problem.fixes?.length) { + results.push(this.createCodeFixAction(hintName, diagnostic, data.problem)); + } + if (hintName.startsWith('axe/')) { + results.push(this.createIgnoreAxeRuleAction(hintName, diagnostic)); + } if (hintName.startsWith('compat-api/')) { - // Prefer ignoring specific HTML/CSS features when possible. - results.push(this.createIgnoreFeatureAction(hintName, currentDiagnostic)); + results.push(this.createIgnoreFeatureAction(hintName, diagnostic)); } + if (data.problem.browsers) { + results.push(this.createIgnoreBrowsersAction(hintName, diagnostic, data.problem)); + } + }); - // Offer to disable the entire hint. - results.push(this.createIgnoreHintAction(hintName, currentDiagnostic)); + // Then add options to disable the hints that reported the diagnostics. + webhintDiagnostics.forEach((diagnostic) => { + results.push(this.createIgnoreHintAction(`${diagnostic.code}`, diagnostic)); }); - if (results.length > 0) { - const editCurrentProjectConfigTitle = 'Edit .hintrc for current project'; - const editCurrentProjectConfig: Command = { command: 'vscode-webhint/edit-hintrc-project', title: editCurrentProjectConfigTitle }; + // Finally, add a shortcut to edit the .hintrc file. + const editCurrentProjectConfigTitle = 'Edit .hintrc for current project'; + const editCurrentProjectConfig: Command = { command: 'vscode-webhint/edit-hintrc-project', title: editCurrentProjectConfigTitle }; - results.push(CodeAction.create(editCurrentProjectConfigTitle, editCurrentProjectConfig, CodeActionKind.QuickFix)); - } + results.push(CodeAction.create(editCurrentProjectConfigTitle, editCurrentProjectConfig, CodeActionKind.QuickFix)); return results; } diff --git a/packages/extension-vscode/src/server.ts b/packages/extension-vscode/src/server.ts index e8516a73981..4e85e3e10c4 100644 --- a/packages/extension-vscode/src/server.ts +++ b/packages/extension-vscode/src/server.ts @@ -1,10 +1,12 @@ import { createConnection, ProposedFeatures, TextDocuments, TextDocumentSyncKind } from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; - import { pathToFileURL } from 'node:url'; + +import type { Problem } from '@hint/utils-types'; + import { Analyzer } from './utils/analyze'; import { QuickFixActionProvider } from './quickfix-provider'; -import {WebhintConfiguratorParser} from './utils/webhint-utils'; +import { WebhintConfiguratorParser } from './utils/webhint-utils'; import * as path from 'path'; @@ -28,7 +30,10 @@ connection.onInitialize((params) => { codeActionProvider: true, executeCommandProvider: { commands: [ + 'vscode-webhint/apply-code-fix', 'vscode-webhint/ignore-hint-project', + 'vscode-webhint/ignore-axe-rule-project', + 'vscode-webhint/ignore-browsers-project', 'vscode-webhint/ignore-feature-project', 'vscode-webhint/edit-hintrc-project' ] @@ -54,6 +59,7 @@ connection.onExecuteCommand(async (params) => { const args = params.arguments ?? []; const problemName = args[0] as string; const hintName = args[1] as string; + const problem = args[2] as Problem; const configurationParser = new WebhintConfiguratorParser(); const configFilePath = path.join(workspace, '.hintrc'); @@ -64,6 +70,14 @@ connection.onExecuteCommand(async (params) => { await configurationParser.ignoreHintPerProject(hintName); break; } + case 'vscode-webhint/ignore-axe-rule-project': { + await configurationParser.addAxeRuleToIgnoredHintsConfig(hintName, problemName); + break; + } + case 'vscode-webhint/ignore-browsers-project': { + await configurationParser.addBrowsersToIgnoredHintsConfig(hintName, problem); + break; + } case 'vscode-webhint/ignore-feature-project': { await configurationParser.addFeatureToIgnoredHintsConfig(hintName, problemName); break; diff --git a/packages/extension-vscode/src/utils/problems.ts b/packages/extension-vscode/src/utils/problems.ts index a8228774158..312a82d2374 100644 --- a/packages/extension-vscode/src/utils/problems.ts +++ b/packages/extension-vscode/src/utils/problems.ts @@ -3,6 +3,10 @@ import { TextDocument } from 'vscode-languageserver-textdocument'; import { Problem, Severity } from '@hint/utils-types'; +export type WebhintDiagnosticData = { + problem: Problem; +}; + // Translate a webhint severity into the VSCode DiagnosticSeverity format. const webhintToDiagnosticServerity = (severity: Severity): DiagnosticSeverity => { switch (severity) { @@ -52,6 +56,7 @@ export const problemToDiagnostic = (problem: Problem, textDocument: TextDocument return { code: problem.hintId, codeDescription: { href: docHref }, + data: { problem } as WebhintDiagnosticData, message: `${problem.message}`, range: { end: { character: endColumn, line: endLine }, diff --git a/packages/extension-vscode/src/utils/webhint-utils.ts b/packages/extension-vscode/src/utils/webhint-utils.ts index 82179609996..795475cd6fb 100644 --- a/packages/extension-vscode/src/utils/webhint-utils.ts +++ b/packages/extension-vscode/src/utils/webhint-utils.ts @@ -1,7 +1,9 @@ import * as fs from 'fs'; +import type { Problem } from '@hint/utils-types'; + import { hasFile } from './fs'; -import type { HintConfig, UserConfig as WebhintUserConfig } from '@hint/utils'; +import type { HintConfig, HintSeverity, UserConfig as WebhintUserConfig } from '@hint/utils'; export class WebhintConfiguratorParser { @@ -14,9 +16,9 @@ export class WebhintConfiguratorParser { if (!fileExists) { // .hintrc does not exists so create one with the default config - const defaultConfig = { extends: ['development'] }; + this.userConfig = { extends: ['development'] }; - await fs.promises.writeFile(this.configFilePath, JSON.stringify(defaultConfig), 'utf-8'); + await this.saveConfiguration(); } // user config file is guaranteed to exist at this point, now read it. @@ -27,6 +29,83 @@ export class WebhintConfiguratorParser { return this.userConfig; } + public async addAxeRuleToIgnoredHintsConfig(hintName: string, ruleName: string): Promise { + /* istanbul ignore next */ + if (!this.isInitialized() || (!hintName || !ruleName)) { + return; + } + + if (!this.userConfig.hints) { + this.userConfig.hints = {}; + } + + // TODO: support array syntax + /* istanbul ignore next */ + if (Array.isArray(this.userConfig.hints)) { + throw new Error('Cannot alter hints collection written as an array'); + } + + const hint = this.userConfig.hints[hintName]; + const config: [HintSeverity, any] = ['default', {}]; + + if (typeof hint === 'string' || typeof hint === 'number') { + config[0] = hint; + } else if (Array.isArray(hint)) { + config[0] = hint[0]; + config[1] = hint[1] || {}; + } + + const rulesConfig = config[1]; + + /* istanbul ignore next */ + if (Array.isArray(rulesConfig)) { + throw new Error('Cannot alter axe-core rules collection written as an array'); + } + + rulesConfig[ruleName as keyof typeof rulesConfig] = 'off'; + + this.userConfig.hints[hintName] = config; + + await this.saveConfiguration(); + } + + public async addBrowsersToIgnoredHintsConfig(hintName: string, problem: Problem): Promise { + /* istanbul ignore next */ + if (!this.isInitialized() || (!hintName || !problem || !problem.browsers)) { + return; + } + + if (!this.userConfig.browserslist) { + this.userConfig.browserslist = ['defaults', 'not ie 11']; + } + + if (typeof this.userConfig.browserslist === 'string') { + this.userConfig.browserslist = [this.userConfig.browserslist]; + } + + const browsers = new Map(); + + // Keep only the highest version number to ignore per browser + for (const browser of problem.browsers) { + const [name, versions] = browser.split(' '); + const maxVersion = parseFloat(versions.split('-').pop() || '1'); + + if (maxVersion > (browsers.get(name) ?? 1)) { + browsers.set(name, maxVersion); + } + } + + // Ignore everything below the highest target version number per browser + const ignoredBrowsers = Array.from(browsers.entries()).map(([name, version]) => { + return `not ${name} <= ${version}`; + }); + + // TODO: remove unnecessary entries (e.g. if both 'ie <= 9' and 'ie <= 11' are present). + this.userConfig.browserslist = [...this.userConfig.browserslist, ...ignoredBrowsers]; + + await this.saveConfiguration(); + } + public async addFeatureToIgnoredHintsConfig(hintName: string, featureName: string): Promise { if (!this.isInitialized() || (!hintName || !featureName)) { return; diff --git a/packages/extension-vscode/tests/fixtures/browsers/.hintrc b/packages/extension-vscode/tests/fixtures/browsers/.hintrc new file mode 100644 index 00000000000..0d06fa33949 --- /dev/null +++ b/packages/extension-vscode/tests/fixtures/browsers/.hintrc @@ -0,0 +1,4 @@ +{ + "extends": ["development"], + "browserslist": ["defaults"] +} diff --git a/packages/extension-vscode/tests/fixtures/no-browsers/.hintrc b/packages/extension-vscode/tests/fixtures/no-browsers/.hintrc new file mode 100644 index 00000000000..b222dc4b6c6 --- /dev/null +++ b/packages/extension-vscode/tests/fixtures/no-browsers/.hintrc @@ -0,0 +1,3 @@ +{ + "extends": ["development"] +} diff --git a/packages/extension-vscode/tests/fixtures/string-browsers/.hintrc b/packages/extension-vscode/tests/fixtures/string-browsers/.hintrc new file mode 100644 index 00000000000..d994f22f780 --- /dev/null +++ b/packages/extension-vscode/tests/fixtures/string-browsers/.hintrc @@ -0,0 +1,4 @@ +{ + "extends": ["development"], + "browserslist": "defaults" +} diff --git a/packages/extension-vscode/tests/quickfix-provider.ts b/packages/extension-vscode/tests/quickfix-provider.ts index 57cd62c3194..0194033698d 100644 --- a/packages/extension-vscode/tests/quickfix-provider.ts +++ b/packages/extension-vscode/tests/quickfix-provider.ts @@ -1,5 +1,4 @@ -import anyTest, { TestFn } from 'ava'; -import * as sinon from 'sinon'; +import test from 'ava'; import { TextDocuments, CodeActionParams} from 'vscode-languageserver/node'; import { TextDocument } from 'vscode-languageserver-textdocument'; @@ -7,12 +6,7 @@ import { QuickFixActionProvider } from '../src/quickfix-provider'; import { Problem, Severity } from '@hint/utils-types'; import { problemToDiagnostic } from '../src/utils/problems'; -type SandboxContext = { - sandbox: sinon.SinonSandbox; -}; - -const test = anyTest as TestFn; -const mockContext = (context: SandboxContext) => { +const mockContext = () => { const fakeCodeActions = { context: {diagnostics: []}, textDocument: {uri: {}} @@ -29,18 +23,9 @@ const mockContext = (context: SandboxContext) => { }; }; -test.beforeEach((t) => { - t.context.sandbox = sinon.createSandbox(); -}); - -test.afterEach.always((t) => { - t.context.sandbox.restore(); -}); - - test('It handles an empty document', (t) => { - const {fakeCodeActions} = mockContext(t.context); + const {fakeCodeActions} = mockContext(); const quickFixActionProvider = new QuickFixActionProvider(new TextDocuments(TextDocument), 'test environment'); const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); @@ -48,16 +33,15 @@ test('It handles an empty document', (t) => { t.is(results, null); }); -test('It returns zero elements for empty diagnostics', (t) => { - const {documents, fakeCodeActions} = mockContext(t.context); +test('It returns null for empty diagnostics', (t) => { + const {documents, fakeCodeActions} = mockContext(); fakeCodeActions.context.diagnostics = []; const quickFixActionProvider = new QuickFixActionProvider(documents, 'test environment'); const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); - t.not(results, null); - t.is(results?.length, 0); + t.is(results, null); }); test('It correctly returns expected quick fixes for a single diagnostic with the right source set', (t) => { @@ -75,7 +59,7 @@ test('It correctly returns expected quick fixes for a single diagnostic with the severity: Severity.error } as Problem; - const {documents, fakeCodeActions} = mockContext(t.context); + const {documents, fakeCodeActions} = mockContext(); const currentDocument = documents.get('any'); @@ -127,7 +111,7 @@ test('It correctly returns expected quick fixes for a several diagnostic with th } as Problem; - const {documents, fakeCodeActions} = mockContext(t.context); + const {documents, fakeCodeActions} = mockContext(); const currentDocument = documents.get('any'); @@ -151,8 +135,8 @@ test('It correctly returns expected quick fixes for a several diagnostic with th if (results){ t.is(results[0].title.includes('fake-problem'), true); - t.is(results[1].title.includes('hint-test-1'), true); - t.is(results[2].title.includes('second-fake-problem'), true); + t.is(results[1].title.includes('second-fake-problem'), true); + t.is(results[2].title.includes('hint-test-1'), true); t.is(results[3].title.includes('hint-test-2'), true); t.is(results[4].title, 'Edit .hintrc for current project'); } else { @@ -175,7 +159,7 @@ test('It correctly filters messages with the wrong source set', (t) => { severity: Severity.error } as Problem; - const {documents, fakeCodeActions} = mockContext(t.context); + const {documents, fakeCodeActions} = mockContext(); const currentDocument = documents.get('any'); @@ -192,6 +176,164 @@ test('It correctly filters messages with the wrong source set', (t) => { const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); + t.is(results, null); +}); + +test('It correctly returns expected quick fixes for ignoring axe-core rules', (t) => { + const location = { + column: 5, + endColumn: 10, + endLine: 8, + line: 7 + }; + + const problem = { + documentation: [ + { + link: 'https://dequeuniversity.com/rules/axe/4.4/html-has-lang?application=axeAPI', + text: 'learn more' + } + ], + hintId: 'axe/language', + location, + message: `'fake-problem' is reported in here`, + severity: Severity.error + } as Problem; + + const {documents, fakeCodeActions} = mockContext(); + + const currentDocument = documents.get('any'); + + if (currentDocument) { + const diagnostic = problemToDiagnostic(problem, currentDocument); + + diagnostic.source = 'test_environment'; + fakeCodeActions.context.diagnostics = [diagnostic]; + } else { + t.fail('Expected code actions but none received'); + } + + const quickFixActionProvider = new QuickFixActionProvider(documents, 'test_environment'); + + const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); + + t.not(results, null); + t.is(results?.length, 3); + + if (results){ + t.is(results[0].title.includes('html-has-lang'), true); + t.is(results[1].title.includes('axe/language'), true); + t.is(results[2].title, 'Edit .hintrc for current project'); + } else { + t.fail('Expected code actions but none received'); + } +}); + +test('It correctly returns expected quick fixes for ignoring browsers', (t) => { + const location = { + column: 5, + endColumn: 10, + endLine: 8, + line: 7 + }; + + const problem = { + browsers: ['ie 9', 'ie 10'], + hintId: 'test-hint', + location, + message: `'fake-problem' is not supported by Internet Explorer < 11.`, + severity: Severity.error + } as Problem; + + const {documents, fakeCodeActions} = mockContext(); + + const currentDocument = documents.get('any'); + + if (currentDocument) { + const diagnostic = problemToDiagnostic(problem, currentDocument); + + diagnostic.source = 'test_environment'; + fakeCodeActions.context.diagnostics = [diagnostic]; + } else { + t.fail('Expected code actions but none received'); + } + + const quickFixActionProvider = new QuickFixActionProvider(documents, 'test_environment'); + + const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); + t.not(results, null); - t.is(results?.length, 0); + + if (results) { + t.is(results[0].title.includes('Internet Explorer < 11'), true); + } else { + t.fail('Expected code actions but none received'); + } +}); + +test('It correctly returns an edit for issue with a fix', (t) => { + const location = { + column: 5, + endColumn: 10, + endLine: 8, + line: 7 + }; + + const problem = { + fixes: [ + { + location: { + column: 0, + endColumn: 0, + endLine: 0, + line: 0 + }, + text: 'fixed!' + } + ], + hintId: 'test-hint', + location, + message: `'fake-problem' is reported in here`, + resource: 'test.html', + severity: Severity.error + } as Problem; + + const {documents, fakeCodeActions} = mockContext(); + + const currentDocument = documents.get('any'); + + if (currentDocument) { + const diagnostic = problemToDiagnostic(problem, currentDocument); + + diagnostic.source = 'test_environment'; + fakeCodeActions.context.diagnostics = [diagnostic]; + } else { + t.fail('Expected code actions but none received'); + } + + const quickFixActionProvider = new QuickFixActionProvider(documents, 'test_environment'); + + const results = quickFixActionProvider.provideCodeActions(fakeCodeActions); + + t.not(results, null); + + t.is(results?.[0].command?.command, 'vscode-webhint/apply-code-fix'); + + t.deepEqual(results?.[0].edit, { + changes: { + 'test.html': [{ + newText: 'fixed!', + range: { + end: { + character: 0, + line: 0 + }, + start: { + character: 0, + line: 0 + } + } + }] + } + }); }); diff --git a/packages/extension-vscode/tests/utils/webhint-utils.ts b/packages/extension-vscode/tests/utils/webhint-utils.ts index e1b4719fd9b..ade481e8d4e 100644 --- a/packages/extension-vscode/tests/utils/webhint-utils.ts +++ b/packages/extension-vscode/tests/utils/webhint-utils.ts @@ -5,6 +5,8 @@ import * as path from 'path'; import * as sinon from 'sinon'; import * as proxyquire from 'proxyquire'; +import type { Problem } from '@hint/utils-types'; + type SandboxContext = { sandbox: sinon.SinonSandbox; }; @@ -124,6 +126,90 @@ test.serial('It correctly ignores a problem in an existing configuration file wi t.deepEqual(result, expectedResults); }); +test.serial('It correctly ignores an axe-core rule in an existing configuration file without a previous hint section', async (t) => { + const expectedPath = path.join(__dirname, '../fixtures/no-hint-property/.hintrc'); + const sampleFileContents = await fs.promises.readFile(expectedPath); + const expectedResults = JSON.parse(sampleFileContents.toString()); + + expectedResults.hints = {}; + expectedResults.hints['axe/language'] = JSON.parse('["default", {"html-has-lang":"off"}]'); + + const { MockWebhintConfiguratorParser, readFileStub, writeFileStub } = mockContext(t.context); + + const configParser = new MockWebhintConfiguratorParser(); + + readFileStub.resolves(sampleFileContents); + await configParser.initialize(expectedPath); + configParser.addAxeRuleToIgnoredHintsConfig('axe/language', 'html-has-lang'); + + t.is(writeFileStub.callCount, 1); + t.is(readFileStub.callCount, 1); + t.is((writeFileStub.getCall(0).args as unknown as Array)[0], expectedPath); + const result = JSON.parse((writeFileStub.getCall(0).args as unknown as Array)[1]); + + t.deepEqual(result, expectedResults); +}); + +test.serial('It correctly ignores browsers in an existing configuration file', async (t) => { + const expectedPath = path.join(__dirname, '../fixtures/browsers/.hintrc'); + const sampleFileContents = await fs.promises.readFile(expectedPath); + const expectedResults = JSON.parse(sampleFileContents.toString()); + + expectedResults.browserslist.push('not ie <= 10'); + + const { MockWebhintConfiguratorParser, readFileStub, writeFileStub } = mockContext(t.context); + + const configParser = new MockWebhintConfiguratorParser(); + + readFileStub.resolves(sampleFileContents); + await configParser.initialize(expectedPath); + configParser.addBrowsersToIgnoredHintsConfig('test-hint', { browsers: ['ie 9', 'ie 10'] } as Problem); + + const result = JSON.parse((writeFileStub.getCall(0).args as unknown as Array)[1]); + + t.deepEqual(result, expectedResults); +}); + +test.serial('It correctly ignores browsers in an existing configuration file with no browserslist', async (t) => { + const expectedPath = path.join(__dirname, '../fixtures/no-browsers/.hintrc'); + const sampleFileContents = await fs.promises.readFile(expectedPath); + const expectedResults = JSON.parse(sampleFileContents.toString()); + + expectedResults.browserslist = ['defaults', 'not ie 11', 'not firefox <= 100']; + + const { MockWebhintConfiguratorParser, readFileStub, writeFileStub } = mockContext(t.context); + + const configParser = new MockWebhintConfiguratorParser(); + + readFileStub.resolves(sampleFileContents); + await configParser.initialize(expectedPath); + configParser.addBrowsersToIgnoredHintsConfig('test-hint', { browsers: ['firefox 100'] } as Problem); + + const result = JSON.parse((writeFileStub.getCall(0).args as unknown as Array)[1]); + + t.deepEqual(result, expectedResults); +}); + +test.serial('It correctly ignores browsers in an existing configuration file with a string browserslist', async (t) => { + const expectedPath = path.join(__dirname, '../fixtures/string-browsers/.hintrc'); + const sampleFileContents = await fs.promises.readFile(expectedPath); + const expectedResults = JSON.parse(sampleFileContents.toString()); + + expectedResults.browserslist = ['defaults', 'not safari <= 13']; + + const { MockWebhintConfiguratorParser, readFileStub, writeFileStub } = mockContext(t.context); + + const configParser = new MockWebhintConfiguratorParser(); + + readFileStub.resolves(sampleFileContents); + await configParser.initialize(expectedPath); + configParser.addBrowsersToIgnoredHintsConfig('test-hint', { browsers: ['safari 13'] } as Problem); + + const result = JSON.parse((writeFileStub.getCall(0).args as unknown as Array)[1]); + + t.deepEqual(result, expectedResults); +}); + test.serial('It correctly ignores a problem in an existing configuration file with a previous ignored problem', async (t) => { const expectedPath = path.join(__dirname, '../fixtures/with-problem/.hintrc'); const sampleFileContents = await fs.promises.readFile(expectedPath); diff --git a/packages/hint-compat-api/src/css.ts b/packages/hint-compat-api/src/css.ts index c02b74cad2b..c57cacb326a 100644 --- a/packages/hint-compat-api/src/css.ts +++ b/packages/hint-compat-api/src/css.ts @@ -286,6 +286,7 @@ export default class CSSCompatHint implements IHint { resource, message, { + browsers: unsupported.browsers, codeLanguage: 'css', codeSnippet, documentation, diff --git a/packages/hint-compat-api/src/html.ts b/packages/hint-compat-api/src/html.ts index 6d2ec772e83..526d2d97c07 100644 --- a/packages/hint-compat-api/src/html.ts +++ b/packages/hint-compat-api/src/html.ts @@ -98,6 +98,7 @@ export default class HTMLCompatHint implements IHint { resource, message, { + browsers: unsupported.browsers, documentation, element, severity: Severity.warning diff --git a/packages/hint-compat-api/src/utils/browsers.ts b/packages/hint-compat-api/src/utils/browsers.ts index 4e93bfb2c6a..0e0bdbe5f30 100644 --- a/packages/hint-compat-api/src/utils/browsers.ts +++ b/packages/hint-compat-api/src/utils/browsers.ts @@ -67,7 +67,7 @@ export const formatUnsupported = (browser: string, versionAdded?: string, versio * Serialize summarized support ranges for provided browsers. * * ```js - * joinBrowsers({ browsers: ['edge 15'], browserDetails: new Map([['edge 15', { versionAdded: '18' }]])); + * joinBrowsers({ browsers: ['edge 15'], details: new Map([['edge 15', { versionAdded: '18' }]]) }); * // returns 'Edge < 18'; * ``` */ @@ -76,7 +76,7 @@ export const joinBrowsers = (unsupported: UnsupportedBrowsers): string => { const details = unsupported.details.get(browser); if (!details) { - throw new Error(`No details provided for browser: ${name}`); + throw new Error(`No details provided for browser: ${browser}`); } return formatUnsupported(browser, details.versionAdded, details.versionRemoved); diff --git a/packages/hint-css-prefix-order/src/hint.ts b/packages/hint-css-prefix-order/src/hint.ts index 3d1ec51da0f..e1a5a388d29 100644 --- a/packages/hint-css-prefix-order/src/hint.ts +++ b/packages/hint-css-prefix-order/src/hint.ts @@ -10,7 +10,7 @@ import { IHint } from 'hint/dist/src/lib/types'; import { debug as d } from '@hint/utils-debug'; import { getFullCSSCodeSnippet, getCSSLocationFromNode, getUnprefixed } from '@hint/utils-css'; import { StyleEvents, StyleParse } from '@hint/parser-css'; -import { Severity } from '@hint/utils-types'; +import { CodeFix, Severity } from '@hint/utils-types'; import meta from './meta'; import { getMessage } from './i18n.import'; @@ -22,6 +22,39 @@ type DeclarationPair = { unprefixed: Declaration; }; +/** Return edits to fix a report by swapping the name and value of the last prefixed property with the unprefixed property. */ +const generateFixes = (pair: DeclarationPair): CodeFix[] | undefined => { + const lastPrefixedStart = pair.lastPrefixed.source?.start; + const lastPrefixedEnd = pair.lastPrefixed.source?.end; + const unprefixedStart = pair.unprefixed.source?.start; + const unprefixedEnd = pair.unprefixed.source?.end; + + if (!lastPrefixedStart || !lastPrefixedEnd || !unprefixedStart || !unprefixedEnd) { + return undefined; + } + + return [ + { + location: { + column: lastPrefixedStart.column - 1, + endColumn: lastPrefixedEnd.column - 1, + endLine: lastPrefixedEnd.line - 1, + line: lastPrefixedStart.line - 1 + }, + text: pair.unprefixed.toString() + }, + { + location: { + column: unprefixedStart.column - 1, + endColumn: unprefixedEnd.column - 1, + endLine: unprefixedEnd.line - 1, + line: unprefixedStart.line - 1 + }, + text: pair.lastPrefixed.toString() + } + ]; +}; + /** Determine if the order of a prefixed/unprefixed pair is valid. */ const validatePair = (pair: Partial): boolean => { // Valid if only prefixed or only unprefixed versions exist. @@ -117,7 +150,14 @@ export default class CssPrefixOrderHint implements IHint { context.report( resource, message, - { codeLanguage: 'css', codeSnippet, element, location, severity }); + { + codeLanguage: 'css', + codeSnippet, + element, + fixes: generateFixes(invalidPair), + location, + severity + }); } }); }); diff --git a/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.css b/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.css new file mode 100644 index 00000000000..cfe3255e819 --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.css @@ -0,0 +1,5 @@ +.example { + appearance: none; /* Report */ + -webkit-appearance: none; + background-size: cover; +} diff --git a/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.fixed.css b/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.fixed.css new file mode 100644 index 00000000000..dcabd9c6f5e --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/different-property-at-end.fixed.css @@ -0,0 +1,5 @@ +.example { + -webkit-appearance: none; /* Report */ + appearance: none; + background-size: cover; +} diff --git a/packages/hint-css-prefix-order/tests/fixtures/interleaved-prefixes.fixed.css b/packages/hint-css-prefix-order/tests/fixtures/interleaved-prefixes.fixed.css new file mode 100644 index 00000000000..164c1164121 --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/interleaved-prefixes.fixed.css @@ -0,0 +1,5 @@ +.example { + -moz-appearance: none; + -webkit-appearance: none; /* Report */ + appearance: none; +} diff --git a/packages/hint-css-prefix-order/tests/fixtures/mixed-with-prefixes-last.fixed.css b/packages/hint-css-prefix-order/tests/fixtures/mixed-with-prefixes-last.fixed.css new file mode 100644 index 00000000000..baa8d5f8244 --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/mixed-with-prefixes-last.fixed.css @@ -0,0 +1,6 @@ +.example { + -webkit-appearance: none; /* Report */ + background-color: #fff; + -moz-appearance: none; + appearance: none; +} diff --git a/packages/hint-css-prefix-order/tests/fixtures/prefixed-values-last.fixed.css b/packages/hint-css-prefix-order/tests/fixtures/prefixed-values-last.fixed.css new file mode 100644 index 00000000000..ea15eb2e34f --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/prefixed-values-last.fixed.css @@ -0,0 +1,4 @@ +.example { + display: -ms-grid; /* Report */ + display: grid; +} diff --git a/packages/hint-css-prefix-order/tests/fixtures/prefixes-last-same-line.fixed.css b/packages/hint-css-prefix-order/tests/fixtures/prefixes-last-same-line.fixed.css new file mode 100644 index 00000000000..0dbdb6718df --- /dev/null +++ b/packages/hint-css-prefix-order/tests/fixtures/prefixes-last-same-line.fixed.css @@ -0,0 +1 @@ +.example { -webkit-appearance: none; /* Report */ -moz-appearance: none; appearance: none; } diff --git a/packages/hint-css-prefix-order/tests/tests.ts b/packages/hint-css-prefix-order/tests/tests.ts index 8cae862ecc1..7bf27032948 100644 --- a/packages/hint-css-prefix-order/tests/tests.ts +++ b/packages/hint-css-prefix-order/tests/tests.ts @@ -33,6 +33,10 @@ const generateConfig = (fileName: string) => { }; }; +const getFixedContent = (fileName: string): string => { + return readFile(`${__dirname}/fixtures/${fileName}.fixed.css`); +}; + const tests: HintTest[] = [ { name: 'Prefixed properties in isolation across blocks pass', @@ -41,19 +45,31 @@ const tests: HintTest[] = [ { name: `Some prefixed properties listed first, but others last fail`, reports: [{ + fixes: { match: getFixedContent('interleaved-prefixes') }, message: `'appearance' should be listed after '-webkit-appearance'.`, position: { match: 'appearance: none; /* Report */', range: 'appearance' }, severity: Severity.warning }], serverConfig: generateConfig('interleaved-prefixes') }, + { + name: `Prefixes listed last with unrelated unprefixed properties after fail`, + reports: [{ + fixes: { match: getFixedContent('different-property-at-end') }, + message: `'appearance' should be listed after '-webkit-appearance'.`, + position: { match: 'appearance: none; /* Report */', range: 'appearance' }, + severity: Severity.warning + }], + serverConfig: generateConfig('different-property-at-end') + }, { name: `Prefixed properties listed first with other properties mixed in pass`, serverConfig: generateConfig('mixed-with-prefixes-first') }, { - name: 'Prefixed properties listed last with other properties mixed in pass', + name: 'Prefixed properties listed last with other properties mixed in fail', reports: [{ + fixes: { match: getFixedContent('mixed-with-prefixes-last') }, message: `'appearance' should be listed after '-webkit-appearance'.`, position: { match: 'appearance: none; /* Report */', range: 'appearance' }, severity: Severity.warning @@ -103,6 +119,7 @@ const tests: HintTest[] = [ { name: 'Prefixed values listed last fail', reports: [{ + fixes: { match: getFixedContent('prefixed-values-last') }, message: `'display: grid' should be listed after 'display: -ms-grid'.`, position: { match: 'grid; /* Report */', range: 'grid' }, severity: Severity.warning @@ -129,6 +146,7 @@ const tests: HintTest[] = [ { name: 'Prefixed properties listed last on same line fail', reports: [{ + fixes: { match: getFixedContent('prefixes-last-same-line') }, message: `'appearance' should be listed after '-webkit-appearance'.`, position: { match: 'appearance: none; /* Report */', range: 'appearance' }, severity: Severity.warning diff --git a/packages/hint-no-protocol-relative-urls/src/hint.ts b/packages/hint-no-protocol-relative-urls/src/hint.ts index 5dcc7fdf53c..debcc486175 100644 --- a/packages/hint-no-protocol-relative-urls/src/hint.ts +++ b/packages/hint-no-protocol-relative-urls/src/hint.ts @@ -54,6 +54,17 @@ export default class NoProtocolRelativeUrlsHint implements IHint { debug('Protocol relative URL found'); const message = getMessage('noProtocolRelativeUrl', context.language); + const attribute = src ? 'src' : 'href'; + const attributeLocation = element.getAttributeLocation(attribute); + const fixedUrl = url.replace('//', 'https://'); + const replacementText = `${attribute}="${fixedUrl}"`; + + const fixes = [ + { + location: attributeLocation, + text: replacementText + } + ]; const severity = isHTTPS(resource) ? Severity.hint : @@ -63,9 +74,10 @@ export default class NoProtocolRelativeUrlsHint implements IHint { resource, message, { - attribute: src ? 'src' : 'href', + attribute, content: url, element, + fixes, severity }); } diff --git a/packages/hint-no-protocol-relative-urls/tests/_generate-tests.ts b/packages/hint-no-protocol-relative-urls/tests/_generate-tests.ts index 243316cb1e3..8ac192eb644 100644 --- a/packages/hint-no-protocol-relative-urls/tests/_generate-tests.ts +++ b/packages/hint-no-protocol-relative-urls/tests/_generate-tests.ts @@ -22,6 +22,7 @@ const getTests = (severity: Severity) => { { name: `'link' with initial // fails the hint`, reports: [{ + fixes: { match: generateHTMLPage('')}, message: errorMessage, position: { match: 'href="//site.webmanifest"' }, severity @@ -47,6 +48,7 @@ const getTests = (severity: Severity) => { { name: `'script' with initial // fails the hint`, reports: [{ + fixes: { match: generateHTMLPage(undefined, '')}, message: errorMessage, position: { match: 'src="//script.js"' }, severity @@ -68,6 +70,7 @@ const getTests = (severity: Severity) => { { name: `'a' with initial // fails the hint`, reports: [{ + fixes: { match: generateHTMLPage(undefined, 'home')}, message: errorMessage, position: { match: 'href="//home"' }, severity diff --git a/packages/hint/src/lib/hint-context.ts b/packages/hint/src/lib/hint-context.ts index 6e490e5cbeb..09bc4a438d6 100644 --- a/packages/hint/src/lib/hint-context.ts +++ b/packages/hint/src/lib/hint-context.ts @@ -26,6 +26,11 @@ export type ReportOptions = { * Used with `element` to get a more targeted `ProblemLocation`. */ attribute?: string; + /** + * The target browsers that caused this problem to be reported (if compatibility related). + * Browser identifiers are in the `browserslist` format (e.g. `['ie 11', 'chrome 100']`). + */ + browsers?: string[]; /** The source code to display (defaults to the `outerHTML` of `element`). */ codeSnippet?: string; /** The text within `element` where the issue was found (used to refine a `ProblemLocation`). */ @@ -48,6 +53,7 @@ export type ReportOptions = { forceSeverity?: boolean; /** Indicate the language of the codeSnippet. */ codeLanguage?: CodeLanguage; + /** A collection of edits that resolve the reported problem. */ fixes?: CodeFix[]; }; @@ -176,6 +182,7 @@ export class HintContext { */ this.engine.report({ + browsers: options.browsers, category: (this.meta && this.meta.docs && this.meta.docs.category) ? this.meta.docs.category : Category.other, codeLanguage: options.codeLanguage, documentation: options.documentation, diff --git a/packages/parser-jsx/src/parser.ts b/packages/parser-jsx/src/parser.ts index b48d050af4d..741f5b67425 100644 --- a/packages/parser-jsx/src/parser.ts +++ b/packages/parser-jsx/src/parser.ts @@ -92,13 +92,13 @@ const isAttributeOrNativeElement = (node: Node) => { /** * Translate JS AST locations to HTML AST locations. */ -const mapLocation = (node: Node, { startColumnOffset = 0 } = {}): parse5.Location => { +const mapLocation = (node: Node): parse5.Location => { // TODO: Remove `columnOffset` once `Problem` supports a range. return { - endCol: node.loc && (node.loc.end.column) || -1, + endCol: node.loc && (node.loc.end.column + 1) || -1, endLine: node.loc && node.loc.end.line || -1, endOffset: node.range && node.range[1] || -1, - startCol: node.loc && (node.loc.start.column + startColumnOffset) || -1, + startCol: node.loc && (node.loc.start.column + 1) || -1, startLine: node.loc && node.loc.start.line || -1, startOffset: node.range && node.range[0] || -1 }; @@ -193,9 +193,9 @@ const mapElement = (node: JSXElement, childMap: ChildMap): ElementData => { endTag: node.closingElement ? mapLocation(node.closingElement) : undefined as any, // TODO: Fix types to allow undefined (matches parse5 behavior) startTag: { attrs, - ...mapLocation(node.openingElement, { startColumnOffset: 1 }) + ...mapLocation(node.openingElement) }, - ...mapLocation(node, { startColumnOffset: 1 }) + ...mapLocation(node) }, type: 'tag' }; diff --git a/packages/utils-tests-helpers/src/hint-runner.ts b/packages/utils-tests-helpers/src/hint-runner.ts index 1ebed5b7e03..b6b93f120e1 100644 --- a/packages/utils-tests-helpers/src/hint-runner.ts +++ b/packages/utils-tests-helpers/src/hint-runner.ts @@ -79,6 +79,55 @@ const findPosition = (source: string, position: MatchProblemLocation): ProblemLo return result; }; +/** + * Compares two location to see if they have the same start/end values + */ +export const comparePositions = (position1: ProblemLocation, position2: ProblemLocation): boolean => { + if (position1.line !== position2.line) { + return false; + } + if (position1.column !== position2.column) { + return false; + } + if (position1.endLine !== position2.endLine) { + return false; + } + if (position1.endColumn !== position2.endColumn) { + return false; + } + + return true; +}; + +/** + * Translates line and column start and end values to offset values. + */ +export const positionToOffset = (position: ProblemLocation, document: string): number[] => { + const {column, endColumn, endLine, line} = position; + + if (typeof endLine !== 'number' || typeof endColumn !== 'number') { + return [-1, -1]; + } + let startOffset = column; + let endOffset = endColumn; + + const regex = /(\r\n|\r|\n)/gm; + + for (let i = 0; i < endLine && regex.exec(document); i++) { + const curLineOffset = regex.lastIndex || -1; + + if (i === line - 1) { + startOffset = curLineOffset + column; + } + + if (i === endLine - 1) { + endOffset = curLineOffset + endColumn; + } + } + + return [startOffset, endOffset]; +}; + /** * Get the source code for the provided resource. * Returns the empty string if resource was invalid. @@ -192,7 +241,7 @@ const validateResults = (t: ExecutionContext, sources: Map { if (typeof report.message === 'string') { @@ -211,7 +260,7 @@ const validateResults = (t: ExecutionContext, sources: Map { /* * If the report from the test doesn't ask for documentation, - * we don't need to macth it. + * we don't need to match it. */ if (!report.documentation) { return true; @@ -330,7 +379,93 @@ const validateResults = (t: ExecutionContext, sources: Map { + const filteredByFixes = filteredByPosition.filter((report) => { + /* + * If the report from the test doesn't ask for fixes, + * we don't need to match it. + */ + if (!report.fixes) { + return true; + } + + /* + * If the report from the test does ask for fixes + * but the result doesn't provide it, then it isn't a match. + */ + if (!fixes) { + return false; + } + + if (Array.isArray(report.fixes)) { + if (report.fixes.length !== fixes.length) { + return false; + } + + for (let i = 0; i < fixes.length; i++) { + const curLocation = fixes[i].location; + const curText = fixes[i].text; + const targetLocation = report.fixes[i].location; + const targetText = report.fixes[i].text; + + if (curText !== targetText || !comparePositions(curLocation, targetLocation)) { + return false; + } + } + + return true; + } else if (report.fixes.match) { + let sourceCopy = sources.get(resource); + + if (!sourceCopy) { + return false; + } + + /** + * We want to sort fixes starting from the end of the document moving upwards to avoid one fix changing the offset of a subsequent fix. + */ + fixes.sort((a, b) => { + const lineDiff = b.location.line - a.location.line; + + if (lineDiff === 0) { + return b.location.column - a.location.column; + } + + return lineDiff; + }); + + for (const fix of fixes) { + let startOffset = fix.location.startOffset; + let endOffset = fix.location.endOffset; + + + if (!startOffset || !endOffset) { + const document = sources.get(resource); + + if (document) { + [startOffset, endOffset] = positionToOffset(fix.location, document); + if (startOffset < 0 || endOffset < 0) { + return false; + } + } else { + return false; + } + } + + sourceCopy = sourceCopy.substring(0, startOffset) + fix.text + sourceCopy.substring(endOffset); + } + t.is(sourceCopy, report.fixes.match); + + return sourceCopy === report.fixes.match; + } + + return false; + }); + + if (filteredByFixes.length === 0) { + t.fail(`The fix ${JSON.stringify(fixes)} does not match any report for "${message}"`); + } + + const filteredBySeverity = filteredByFixes.filter((report) => { // Not all reports in the test have a severity if (typeof report.severity === 'undefined') { return true; diff --git a/packages/utils-tests-helpers/src/hint-test-type.ts b/packages/utils-tests-helpers/src/hint-test-type.ts index e04be191b58..f364b9dcba0 100644 --- a/packages/utils-tests-helpers/src/hint-test-type.ts +++ b/packages/utils-tests-helpers/src/hint-test-type.ts @@ -1,6 +1,6 @@ import { ExecutionContext } from 'ava'; -import { ProblemLocation, Severity, ProblemDocumentation } from '@hint/utils-types'; +import { CodeFix, ProblemLocation, Severity, ProblemDocumentation } from '@hint/utils-types'; export type MatchProblemLocation = { /** A substring matching the location of the problem. */ @@ -14,6 +14,7 @@ export type Report = { position?: ProblemLocation | MatchProblemLocation; severity?: Severity; documentation?: ProblemDocumentation[]; + fixes?: CodeFix[] | { match: string }; }; export type HintTest = { diff --git a/packages/utils-tests-helpers/tests/hint-runner-helpers.ts b/packages/utils-tests-helpers/tests/hint-runner-helpers.ts new file mode 100644 index 00000000000..02c8f8c0708 --- /dev/null +++ b/packages/utils-tests-helpers/tests/hint-runner-helpers.ts @@ -0,0 +1,78 @@ +import test from 'ava'; + +import { comparePositions, positionToOffset } from '../src/hint-runner'; + +/* + * ------------------------------------------------------------------------------ + * positionToOffset tests + * ------------------------------------------------------------------------------ + */ + +test('positionToOffset - properly translates offset of file with LF line endings', (t) => { + const document = 'one\ntwo\nthree\nfour'; + + const actual = positionToOffset({column: 1, endColumn: 3, endLine: 2, line: 2}, document); + const expected = [9, 11]; + + t.deepEqual(actual, expected); +}); + +test('positionToOffset - properly translates offset of file with CRLF line endings', (t) => { + const document = 'one\r\ntwo\r\nthree\r\nfour'; + + const actual = positionToOffset({column: 1, endColumn: 3, endLine: 2, line: 2}, document); + const expected = [11, 13]; + + t.deepEqual(actual, expected); +}); + +test('positionToOffset - properly translates offset of file with no line endings', (t) => { + const document = 'onetwothreefour'; + + const actual = positionToOffset({column: 3, endColumn: 5, endLine: 0, line: 0}, document); + const expected = [3, 5]; + + t.deepEqual(actual, expected); +}); + + +/* + * ------------------------------------------------------------------------------ + * comparePositions tests + * ------------------------------------------------------------------------------ + */ + +test('comparePositions - properly compares two positions with line difference', (t) => { + const position1 = {column: 1, endColumn: 4, endLine: 2, line: 1}; + const position2 = {column: 1, endColumn: 4, endLine: 2, line: 2}; + + t.deepEqual(comparePositions(position1, position2), false); +}); + +test('comparePositions - properly compares two positions with endLine difference', (t) => { + const position1 = {column: 1, endColumn: 4, endLine: 3, line: 2}; + const position2 = {column: 1, endColumn: 4, endLine: 2, line: 2}; + + t.deepEqual(comparePositions(position1, position2), false); +}); + +test('comparePositions - properly compares two positions with column difference', (t) => { + const position1 = {column: 2, endColumn: 4, endLine: 2, line: 2}; + const position2 = {column: 1, endColumn: 4, endLine: 2, line: 2}; + + t.deepEqual(comparePositions(position1, position2), false); +}); + +test('comparePositions - properly compares two positions with endColumn difference', (t) => { + const position1 = {column: 2, endColumn: 4, endLine: 2, line: 2}; + const position2 = {column: 2, endColumn: 5, endLine: 2, line: 2}; + + t.deepEqual(comparePositions(position1, position2), false); +}); + +test('comparePositions - properly compares two identical positions', (t) => { + const position1 = {column: 1, endColumn: 4, endLine: 2, line: 2}; + const position2 = {column: 1, endColumn: 4, endLine: 2, line: 2}; + + t.deepEqual(comparePositions(position1, position2), true); +}); diff --git a/packages/utils-types/src/problems.ts b/packages/utils-types/src/problems.ts index 352e01338a5..9e7615888cb 100644 --- a/packages/utils-types/src/problems.ts +++ b/packages/utils-types/src/problems.ts @@ -22,6 +22,11 @@ export type Problem = { category: Category; /** The severity of the hint based on the actual configuration */ severity: Severity; + /** + * The target browsers that caused this problem to be reported (if compatibility related). + * Browser identifiers are in the `browserslist` format (e.g. `['ie 11', 'chrome 100']`). + */ + browsers?: string[]; /** Indicate the language of the sourceCode */ codeLanguage?: string; /** The link to the documentation in the 3rd party package */