diff --git a/packages/connector-jsdom/src/connector.ts b/packages/connector-jsdom/src/connector.ts index b6741ea24d8..5e2278f8d56 100644 --- a/packages/connector-jsdom/src/connector.ts +++ b/packages/connector-jsdom/src/connector.ts @@ -41,8 +41,8 @@ import { NetworkData } from 'hint/dist/src/lib/types'; import { Engine } from 'hint/dist/src/lib/engine'; -import createHTMLDocument from 'hint/dist/src/lib/utils/dom/create-html-document'; -import traverse from 'hint/dist/src/lib/utils/dom/traverse'; +import { createHTMLDocument } from 'hint/dist/src/lib/utils/dom/create-html-document'; +import { traverse } from 'hint/dist/src/lib/utils/dom/traverse'; import { Requester } from '@hint/utils-connector-tools/dist/src/requester'; diff --git a/packages/connector-jsdom/src/resource-loader.ts b/packages/connector-jsdom/src/resource-loader.ts index 0f901849622..f14aa0597b9 100644 --- a/packages/connector-jsdom/src/resource-loader.ts +++ b/packages/connector-jsdom/src/resource-loader.ts @@ -5,10 +5,10 @@ import { ResourceLoader } from 'jsdom'; import JSDOMConnector from './connector'; import { HTMLDocument } from 'hint/dist/src/lib/types'; -import createHTMLDocument from 'hint/dist/src/lib/utils/dom/create-html-document'; +import { createHTMLDocument } from 'hint/dist/src/lib/utils/dom/create-html-document'; import { NetworkData, FetchEnd, FetchError } from 'hint/dist/src/lib/types'; import { getContentTypeData, getType } from 'hint/dist/src/lib/utils/content-type'; -import getElementByUrl from 'hint/dist/src/lib/utils/dom/get-element-by-url'; +import { getElementByUrl } from 'hint/dist/src/lib/utils/dom/get-element-by-url'; const debug: debug.IDebugger = d(__filename); diff --git a/packages/connector-local/src/connector.ts b/packages/connector-local/src/connector.ts index 605920c3386..cca81b9e9e3 100644 --- a/packages/connector-local/src/connector.ts +++ b/packages/connector-local/src/connector.ts @@ -5,6 +5,30 @@ * It currently only sends `fetch::end::*` events. */ +/** + * `jsdom` always tries to load `canvas` even though it is not needed for + * the HTML parser. If there is a mismatch between the user's node version + * and where the HTML parser is being executed (e.g.: VS Code extension) + * `canvas` will fail to load and crash the excution. To avoid + * that we hijack `require`'s cache and set an empty `Module` for `canvas` + * and `canvas-prebuilt` so `jsdom` doesn't use it and continues executing + * normally. + * + */ + +try { + const canvasPath = require.resolve('canvas'); + const Module = require('module'); + const fakeCanvas = new Module('', null); + + /* istanbul ignore next */ + fakeCanvas.exports = function () { }; + + require.cache[canvasPath] = fakeCanvas; +} catch (e) { + // `canvas` is not installed, nothing to do +} + /* * ------------------------------------------------------------------------------ * Requirements @@ -23,7 +47,7 @@ import globby from 'globby'; import { fs, logger, network } from '@hint/utils'; import { getContentTypeData, isTextMediaType, getType } from 'hint/dist/src/lib/utils/content-type'; -import traverse from 'hint/dist/src/lib/utils/dom/traverse'; +import { traverse } from 'hint/dist/src/lib/utils/dom/traverse'; const { cwd, isFile, readFileAsync } = fs; const { asPathString, getAsUri } = network; @@ -154,7 +178,7 @@ export default class LocalConnector implements IConnector { const scanEndEvent: ScanEnd = { resource: href }; await this.engine.emitAsync('scan::end', scanEndEvent); - await this.engine.notify(); + await this.engine.notify(href); logger.log('Watching for file changes.'); } diff --git a/packages/create-hint/src/handlebars-utils.ts b/packages/create-hint/src/handlebars-utils.ts index b290f61d52d..330654dc75a 100644 --- a/packages/create-hint/src/handlebars-utils.ts +++ b/packages/create-hint/src/handlebars-utils.ts @@ -1,7 +1,7 @@ import * as Handlebars from 'handlebars'; import { fs } from '@hint/utils'; -import loadHintPackage from 'hint/dist/src/lib/utils/packages/load-hint-package'; +import { loadHintPackage } from 'hint/dist/src/lib/utils/packages/load-hint-package'; const { readFileAsync } = fs; diff --git a/packages/create-parser/src/handlebars-utils.ts b/packages/create-parser/src/handlebars-utils.ts index b290f61d52d..330654dc75a 100644 --- a/packages/create-parser/src/handlebars-utils.ts +++ b/packages/create-parser/src/handlebars-utils.ts @@ -1,7 +1,7 @@ import * as Handlebars from 'handlebars'; import { fs } from '@hint/utils'; -import loadHintPackage from 'hint/dist/src/lib/utils/packages/load-hint-package'; +import { loadHintPackage } from 'hint/dist/src/lib/utils/packages/load-hint-package'; const { readFileAsync } = fs; diff --git a/packages/extension-browser/src/content-script/connector.ts b/packages/extension-browser/src/content-script/connector.ts index 460391bbb69..b5bbc7f7398 100644 --- a/packages/extension-browser/src/content-script/connector.ts +++ b/packages/extension-browser/src/content-script/connector.ts @@ -11,13 +11,13 @@ import { HTMLDocument, HTMLElement } from 'hint/dist/src/lib/types'; -import getElementByUrl from 'hint/dist/src/lib/utils/dom/get-element-by-url'; +import { getElementByUrl } from 'hint/dist/src/lib/utils/dom/get-element-by-url'; import { Events } from '../shared/types'; import { eval } from '../shared/globals'; import { browser, document, location, window } from '../shared/globals'; -import createHTMLDocument from 'hint/dist/src/lib/utils/dom/create-html-document'; -import traverse from 'hint/dist/src/lib/utils/dom/traverse'; +import { createHTMLDocument } from 'hint/dist/src/lib/utils/dom/create-html-document'; +import { traverse } from 'hint/dist/src/lib/utils/dom/traverse'; export default class WebExtensionConnector implements IConnector { private _document: HTMLDocument | undefined; diff --git a/packages/extension-browser/src/content-script/webhint.ts b/packages/extension-browser/src/content-script/webhint.ts index 39ea73f1f7c..ba943ff1af7 100644 --- a/packages/extension-browser/src/content-script/webhint.ts +++ b/packages/extension-browser/src/content-script/webhint.ts @@ -3,7 +3,7 @@ require('util.promisify/shim')(); // Needed for `promisify` to work when bundled import browserslist = require('browserslist'); // `require` used because `browserslist` exports a function. import { URL } from 'url'; -import { Engine } from 'hint'; +import { Engine } from 'hint/dist/src/lib/engine'; import { Configuration } from 'hint/dist/src/lib/config'; import { HintResources, HintsConfigObject, IHintConstructor } from 'hint/dist/src/lib/types'; diff --git a/packages/extension-vscode/CONTRIBUTING.md b/packages/extension-vscode/CONTRIBUTING.md index a05b82bdb81..c44d35972e8 100644 --- a/packages/extension-vscode/CONTRIBUTING.md +++ b/packages/extension-vscode/CONTRIBUTING.md @@ -12,6 +12,10 @@ for Visual Studio Code. * Select `Client + Server` from the drop down. * Run the launch config. +NOTE: Make sure you open a file in the launched vscode instance +that webhint is registered for (like html or tsconfig.json), otherwise, +the server won't start. + ## Running Tests * Run `yarn test` from this directory. diff --git a/packages/extension-vscode/src/server.ts b/packages/extension-vscode/src/server.ts index 2e3d60be934..44cff9fc644 100644 --- a/packages/extension-vscode/src/server.ts +++ b/packages/extension-vscode/src/server.ts @@ -15,11 +15,8 @@ import { import * as notifications from './notifications'; -// TODO: Enhance `hint` exports so everything can be imported directly. import * as hint from 'hint'; -import * as config from 'hint/dist/src/lib/config'; -import * as loader from 'hint/dist/src/lib/utils/resource-loader'; -import { HintsConfigObject, Problem, Severity, UserConfig } from 'hint/dist/src/lib/types'; +import { HintsConfigObject, Problem, Severity, UserConfig } from 'hint'; let workspace = ''; @@ -109,7 +106,7 @@ const loadModule = async (context: string, name: string): Promise = }; // Load a user configuration, falling back to 'development' if none exists. -const loadUserConfig = (directory: string, Configuration: typeof config.Configuration): UserConfig => { +const loadUserConfig = (directory: string, Configuration: typeof hint.Configuration): UserConfig => { const defaultConfig: UserConfig = { extends: ['development'] }; try { @@ -122,37 +119,23 @@ const loadUserConfig = (directory: string, Configuration: typeof config.Configur } }; -// Load a copy of webhint with the provided configuration. -const loadEngine = async (directory: string, configuration: config.Configuration): Promise => { - const localLoader = await loadModule(directory, 'hint/dist/src/lib/utils/resource-loader'); - const localHint = await loadModule(directory, 'hint'); - - if (!localLoader || !localHint) { - return null; - } - - const resources = localLoader.loadResources(configuration); - - return new localHint.Engine(configuration, resources); -}; - // Load both webhint and a configuration, adjusting it as needed for this extension. -const loadWebHint = async (directory: string): Promise => { - const configModule = await loadModule(directory, 'hint/dist/src/lib/config'); +const loadWebHint = async (directory: string): Promise => { + const hintModule = await loadModule(directory, 'hint'); // If no module was returned, the user cancelled installing webhint. - if (!configModule) { + if (!hintModule) { return null; } - const { Configuration } = configModule; + const { Configuration } = hintModule; const userConfig = loadUserConfig(directory, Configuration); // The vscode extension only works with the local connector. userConfig.connector = { name: 'local' }; if (!userConfig.hints) { - userConfig.hints = { }; + userConfig.hints = {}; } /* @@ -162,6 +145,11 @@ const loadWebHint = async (directory: string): Promise => { */ (userConfig.hints as HintsConfigObject)['http-compression'] = 'off'; + /* + * Remove formatters because the extension doesn't use them. + */ + userConfig.formatters = []; + if (!userConfig.parsers) { userConfig.parsers = []; } @@ -171,12 +159,12 @@ const loadWebHint = async (directory: string): Promise => { userConfig.parsers.push('html'); } - let engine: hint.Engine | null = null; + let webhint: hint.Analyzer | null = null; try { - engine = await loadEngine(directory, Configuration.fromConfig(userConfig)); + webhint = hintModule.Analyzer.create(userConfig); - return engine; + return webhint; } catch (e) { // Instantiating webhint failed, log the error to the webhint output panel to aid debugging. console.error(e); @@ -198,7 +186,7 @@ const loadWebHint = async (directory: string): Promise => { } }; -let engine: hint.Engine | null = null; +let webhint: hint.Analyzer | null = null; let loaded = false; let validating = false; let validationQueue: TextDocument[] = []; @@ -280,11 +268,11 @@ const validateTextDocument = async (textDocument: TextDocument): Promise = // Try to load webhint if this is the first validation. if (!loaded) { loaded = true; - engine = await loadWebHint(workspace); + webhint = await loadWebHint(workspace); } // Gracefully exit if all attempts to get an engine failed. - if (!engine) { + if (!webhint) { return; } @@ -293,14 +281,14 @@ const validateTextDocument = async (textDocument: TextDocument): Promise = // Pass content directly to validate unsaved changes. const content = textDocument.getText(); - const problems = await engine.executeOn(url, { content }); - - // Clear problems to avoid duplicates since vscode remembers them for us. - engine.clear(); + const AnalzyerResult = await webhint.analyze({ + content, + url + }); // Send the computed diagnostics to VSCode. connection.sendDiagnostics({ - diagnostics: problems.map(problemToDiagnostic), + diagnostics: AnalzyerResult.length > 0 ? AnalzyerResult[0].problems.map(problemToDiagnostic) : [], uri: textDocument.uri }); @@ -316,7 +304,7 @@ const validateTextDocument = async (textDocument: TextDocument): Promise = // A watched .hintrc has changed. Reload the engine and re-validate documents. connection.onDidChangeWatchedFiles(async () => { - engine = await loadWebHint(workspace); + webhint = await loadWebHint(workspace); await Promise.all(documents.all().map((doc) => { return validateTextDocument(doc); })); diff --git a/packages/extension-vscode/tests/fixtures/mocks.ts b/packages/extension-vscode/tests/fixtures/mocks.ts index e81ec645ab1..45070857e65 100644 --- a/packages/extension-vscode/tests/fixtures/mocks.ts +++ b/packages/extension-vscode/tests/fixtures/mocks.ts @@ -1,5 +1,3 @@ -import * as url from 'url'; - import { InitializeParams, InitializeResult, @@ -7,7 +5,7 @@ import { TextDocumentChangeEvent, PublishDiagnosticsParams } from 'vscode-languageserver'; -import { Problem, IFetchOptions } from 'hint/dist/src/lib/types'; +import { Endpoint, AnalyzerResult } from 'hint'; export type Message = { title: string; @@ -31,11 +29,6 @@ export type Connection = { window: Window; } -export type EngineType = { - clear: () => void; - executeOn: (target: url.URL, options?: IFetchOptions) => Partial[]; -} - export type Std = { pipe: () => void; }; @@ -55,6 +48,19 @@ export type FilesType = { }; export const mocks = () => { + const analyzer = { + analyze(endpoints: Endpoint): Promise { + return Promise.resolve([]); + } + } + + class Analyzer { + private constructor() { } + public static create() { + return analyzer; + } + } + const child = { on(event: string, listener: () => void) { if (event === 'exit') { @@ -67,7 +73,6 @@ export const mocks = () => { stdout: { pipe() { } } }; - // eslint-disable-next-line const child_process = { spawn(cmd: string) { return child; @@ -88,13 +93,6 @@ export const mocks = () => { } }; - const engine = { - clear() { }, - executeOn(target: url.URL, options?: IFetchOptions): Partial[] { - return []; - } - }; - const Configuration = { fromConfig() { }, getFilenameForDirectory() { @@ -102,9 +100,6 @@ export const mocks = () => { }, loadConfigFile() { } }; - - const loadResources = () => { }; - let fileWatcher: () => any; let initializer: (params: Partial) => Promise; @@ -135,16 +130,8 @@ export const mocks = () => { return connection; }; - class Engine { - public constructor() { - return engine; - } - } - const modules: { [name: string]: any } = { - hint: { Engine }, - 'hint/dist/src/lib/config': { Configuration }, - 'hint/dist/src/lib/utils/resource-loader': { loadResources } + hint: { Analyzer, Configuration } }; const Files = { @@ -176,17 +163,21 @@ export const mocks = () => { } }; - - return { access, + /* + * 'as any' to avoid error: + * Exported variable 'mocks' has or is using private name 'Analyzer'. + * + * We need to export Analyzer to be able to stub 'Analyzer.create'. + */ + Analyzer: Analyzer as any, + analyzer, child_process, - Configuration, connection, createConnection, document, documents, - engine, Files, fs, getContentWatcher: () => { diff --git a/packages/extension-vscode/tests/server.ts b/packages/extension-vscode/tests/server.ts index 9655a60c82b..90fc271c12a 100644 --- a/packages/extension-vscode/tests/server.ts +++ b/packages/extension-vscode/tests/server.ts @@ -1,8 +1,10 @@ +import { URL } from 'url'; + import * as mock from './fixtures/mocks'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; import anyTest, { TestInterface } from 'ava'; -import { Problem, Severity } from 'hint/dist/src/lib/types'; +import { Problem, Severity, Target } from 'hint/dist/src/lib/types'; import { Diagnostic, DiagnosticSeverity, TextDocument } from 'vscode-languageserver'; type ServerContext = { @@ -15,8 +17,8 @@ const mockContext = () => { const mocks = mock.mocks(); proxyquire('../src/server', { + child_process: mocks.child_process, // eslint-disable-line camelcase fs: mocks.fs, - child_process: mocks.child_process, // eslint-disable-line 'vscode-languageserver': { createConnection: mocks.createConnection, Files: mocks.Files, @@ -27,13 +29,13 @@ const mockContext = () => { return { access: mocks.access, + Analyzer: mocks.Analyzer, + analyzer: mocks.analyzer, child_process: mocks.child_process, // eslint-disable-line camelcase - Configuration: mocks.Configuration, connection: mocks.connection, contentWatcher: mocks.getContentWatcher(), document: mocks.document, documents: mocks.documents, - engine: mocks.engine, Files: mocks.Files, fileWatcher: mocks.getFileWatcher(), initializer: mocks.getInitializer() @@ -52,7 +54,7 @@ test('It notifies if loading webhint fails', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { connection, contentWatcher, document, engine, Files, initializer } = mockContext(); + const { connection, contentWatcher, document, analyzer, Files, initializer } = mockContext(); sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { @@ -60,7 +62,7 @@ test('It notifies if loading webhint fails', async (t) => { }); const windowShowWarningMessageStub = sandbox.stub(connection.window, 'showWarningMessage').returns({ title: 'Cancel' }); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); sandbox.stub(Files, 'resolveModule2').throws(); @@ -68,14 +70,14 @@ test('It notifies if loading webhint fails', async (t) => { await contentWatcher({ document }); t.true(windowShowWarningMessageStub.calledOnce); - t.false(engineExecuteOnSpy.called); + t.false(analyzerAnalyzeStub.called); }); test('It installs webhint if needed', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { child_process, connection, contentWatcher, document, engine, Files, initializer } = mockContext(); // eslint-disable-line camelcase + const { child_process, connection, contentWatcher, document, analyzer, Files, initializer } = mockContext(); // eslint-disable-line camelcase sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { @@ -89,7 +91,7 @@ test('It installs webhint if needed', async (t) => { .throws(); const childProcessSpawnSpy = sandbox.spy(child_process, 'spawn'); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); await initializer({ rootPath: '' }); await contentWatcher({ document }); @@ -97,14 +99,14 @@ test('It installs webhint if needed', async (t) => { t.true(windowShowWarningMessageStub.calledOnce); t.true(childProcessSpawnSpy.calledOnce); t.is(childProcessSpawnSpy.args[0][0], `npm${process.platform === 'win32' ? '.cmd' : ''}`); - t.false(engineExecuteOnSpy.called); + t.false(analyzerAnalyzeStub.called); }); test('It installs webhint via yarn if `yarn.lock` is present', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { access, child_process, connection, contentWatcher, document, engine, Files, initializer } = mockContext(); // eslint-disable-line camelcase + const { access, child_process, connection, contentWatcher, document, analyzer, Files, initializer } = mockContext(); // eslint-disable-line camelcase sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { @@ -119,7 +121,7 @@ test('It installs webhint via yarn if `yarn.lock` is present', async (t) => { .throws(); const childProcessSpawnSpy = sandbox.spy(child_process, 'spawn'); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); await initializer({ rootPath: '' }); await contentWatcher({ document }); @@ -127,14 +129,14 @@ test('It installs webhint via yarn if `yarn.lock` is present', async (t) => { t.true(windowShowWarningMessageStub.calledOnce); t.true(childProcessSpawnSpy.calledOnce); t.is(childProcessSpawnSpy.args[0][0], `yarn${process.platform === 'win32' ? '.cmd' : ''}`); - t.false(engineExecuteOnSpy.called); + t.false(analyzerAnalyzeStub.called); }); test('It notifies if loading the configuration fails', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { Configuration, connection, contentWatcher, document, engine, initializer } = mockContext(); + const { connection, contentWatcher, document, Analyzer, analyzer, initializer } = mockContext(); sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { @@ -142,15 +144,15 @@ test('It notifies if loading the configuration fails', async (t) => { }); const windowShowErrorMessageStub = sandbox.stub(connection.window, 'showErrorMessage').returns({ title: 'Ignore' }); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); - sandbox.stub(Configuration, 'fromConfig').throws(); + sandbox.stub(Analyzer, 'create').throws(); await initializer({ rootPath: '' }); await contentWatcher({ document }); t.true(windowShowErrorMessageStub.calledOnce); - t.false(engineExecuteOnSpy.called); + t.false(analyzerAnalyzeStub.called); }); test('It loads a local copy of webhint', async (t) => { @@ -179,26 +181,28 @@ test('It runs webhint on content changes', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { contentWatcher, document, engine } = mockContext(); + const { contentWatcher, document, analyzer } = mockContext(); sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { return testUri; }); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); await contentWatcher({ document }); - t.true(engineExecuteOnSpy.calledOnce); - t.is(engineExecuteOnSpy.args[0][0].href, testUri); - t.is(engineExecuteOnSpy.args[0][1]!.content, testContent); + const target = analyzerAnalyzeStub.args[0][0] as Target; + + t.true(analyzerAnalyzeStub.calledOnce); + t.is((target.url as URL).href, testUri); + t.is(target.content, testContent); }); test('It processes multiple files serially', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { connection, contentWatcher, document, engine } = mockContext(); + const { connection, contentWatcher, document, analyzer } = mockContext(); const document2 = { getText() { @@ -213,19 +217,24 @@ test('It processes multiple files serially', async (t) => { sandbox.stub(document, 'uri').get(() => { return testUri; }); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); const p1 = contentWatcher({ document }); const p2 = contentWatcher({ document: document2 }); + sandbox.stub(connection, 'sendDiagnostics').value(() => { - t.true(engineExecuteOnSpy.calledOnce); - t.is(engineExecuteOnSpy.args[0][0].href, testUri); - t.is(engineExecuteOnSpy.args[0][1]!.content, testContent); + const target = analyzerAnalyzeStub.args[0][0] as Target; + + t.true(analyzerAnalyzeStub.calledOnce); + t.is((target.url as URL).href, testUri); + t.is(target.content, testContent); sandbox.stub(connection, 'sendDiagnostics').value(() => { - t.is(engineExecuteOnSpy.args[1][0].href, document2.uri); - t.is(engineExecuteOnSpy.args[1][1]!.content, document2.getText()); + const target = analyzerAnalyzeStub.args[1][0] as Target; + + t.is((target.url as URL).href, document2.uri); + t.is(target.content, document2.getText()); }); }); @@ -236,27 +245,29 @@ test('It reloads and runs webhint on watched file changes', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const { document, documents, engine, fileWatcher } = mockContext(); + const { document, documents, analyzer, fileWatcher } = mockContext(); sandbox.stub(documents, 'all').returns([document]); sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { return testUri; }); - const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const analyzerAnalyzeStub = sandbox.stub(analyzer, 'analyze').resolves([]); await fileWatcher(); - t.true(engineExecuteOnSpy.calledOnce); - t.is(engineExecuteOnSpy.args[0][0].href, testUri); - t.is(engineExecuteOnSpy.args[0][1]!.content, testContent); + const target = analyzerAnalyzeStub.args[0][0] as Target; + + t.true(analyzerAnalyzeStub.calledOnce); + t.is((target.url as URL).href, testUri); + t.is(target.content, testContent); }); test('It translates problems to diagnostics', async (t) => { const sandbox = t.context.sandbox; const testContent = 'Test Content'; const testUri = 'file:///test/uri'; - const problems: Partial[] = [ + const problems: Problem[] = [ { hintId: 'test-id-1', location: { @@ -284,21 +295,19 @@ test('It translates problems to diagnostics', async (t) => { message: 'Test Message 3', severity: Severity.off } - ]; - const { connection, contentWatcher, document, engine } = mockContext(); + ] as Problem[]; + const { connection, contentWatcher, document, analyzer } = mockContext(); sandbox.stub(document, 'getText').returns(testContent); sandbox.stub(document, 'uri').get(() => { return testUri; }); const connectionSendDiagnostics = sandbox.spy(connection, 'sendDiagnostics'); - const engineClearSpy = sandbox.spy(engine, 'clear'); - sandbox.stub(engine, 'executeOn').returns(problems); + sandbox.stub(analyzer, 'analyze').resolves([{ problems, url: testUri }]); await contentWatcher({ document }); - t.true(engineClearSpy.calledOnce); t.true(connectionSendDiagnostics.calledOnce); t.is(connectionSendDiagnostics.args[0][0].uri, testUri); diff --git a/packages/formatter-html/src/result.ts b/packages/formatter-html/src/result.ts index 08c68362b70..1ba8cdbb88b 100644 --- a/packages/formatter-html/src/result.ts +++ b/packages/formatter-html/src/result.ts @@ -200,7 +200,7 @@ export default class AnalysisResult { public permalink: string; /** List of categories. */ public categories: CategoryResult[]; - /** URL analized. */ + /** URL analyzed. */ public url: string; /** The analysis is finish. */ public isFinish: boolean; diff --git a/packages/hint-axe/src/meta.ts b/packages/hint-axe/src/meta.ts index 4fd2a3118a4..a358213a867 100644 --- a/packages/hint-axe/src/meta.ts +++ b/packages/hint-axe/src/meta.ts @@ -39,7 +39,7 @@ const meta: HintMetadata = { } }], /* - * axe can not analize a file itself, it needs a connector. + * axe can not analyze a file itself, it needs a connector. */ scope: HintScope.any }; diff --git a/packages/hint-no-vulnerable-javascript-libraries/src/meta.ts b/packages/hint-no-vulnerable-javascript-libraries/src/meta.ts index 0db6ab4d612..9225603144c 100644 --- a/packages/hint-no-vulnerable-javascript-libraries/src/meta.ts +++ b/packages/hint-no-vulnerable-javascript-libraries/src/meta.ts @@ -20,7 +20,7 @@ const meta: HintMetadata = { type: 'object' }], /* - * Snyk can not analize a file itself, it needs a connector. + * Snyk can not analyze a file itself, it needs a connector. * TODO: Change to any once the local connector has jsdom. */ scope: HintScope.site diff --git a/packages/hint/docs/contributor-guide/how-to/formatter.md b/packages/hint/docs/contributor-guide/how-to/formatter.md index 60afb76102f..269f7ad85a4 100644 --- a/packages/hint/docs/contributor-guide/how-to/formatter.md +++ b/packages/hint/docs/contributor-guide/how-to/formatter.md @@ -19,6 +19,8 @@ export default class JSONFormatter implements IFormatter { } ``` + + A `message` looks like this: ```json @@ -55,6 +57,8 @@ export default class JSONFormatter implements IFormatter { } ``` + + The `options` parameter is as follows: ```ts diff --git a/packages/hint/docs/user-guide/api/using-api.md b/packages/hint/docs/user-guide/api/using-api.md new file mode 100644 index 00000000000..76a3541db6e --- /dev/null +++ b/packages/hint/docs/user-guide/api/using-api.md @@ -0,0 +1,128 @@ +# Using the API. + +`webhint` expose an API allowing the users run webhint inside their code, +without using the CLI. + +With this API, the users have more control in what to analyze, when, and +what to do with the results. + +## How to use the API. + +To use the API, the first thing you need to do is import the class `Analyzer`. + +```js +import { Analyzer } from 'hint'; +``` + +Once you have the class `Analyzer`, you need to create an instance of +the class. + +To do so, you need to use the static method `create`. + +```ts +const userConfig: UserConfig; +const options: CreateAnalyzerOptions +const webhint = Analyzer.create(userConfig, options); +``` + +`Analyzer.create` will validate the configuration, load all the resources +needed, initialize the formatters and return an `Analyzer` instance. + +Now, you can analyze any url you need using `webhint.analyze`. + +```ts +const userConfig: UserConfig; +const options: CreateAnalyzerOptions +const webhint = Analyzer.create(userConfig, options); + +const analysisOptions: AnalyzerOptions; +const result: AnalyzerResult[] = await webhint.analyze('http://example.com', options); +``` + +`webhint.analyze` receive as a first parameter an `Endpoint`. + +```ts +export type Target = { + url: string | URL; + content?: string; +}; +export type Endpoint = string | URL | Target; +``` + +Because `webhint.analyze` can analyze multiple URLs, the result +is `AnalyzeResult[]` and not only `Problem[]` or `Problem`, so the users can +know easily for what URL the results are for. + +```ts +export type AnalyzerResult = { + url: string; + problems: Problem[]; +}; +``` + +After the analysis, if the users want to use the formatter configured to +show the problems, the users can use `webhint.format`. + +```ts +const options: FormatterOptions; +await webhint.format(results[0].problems, options); +``` + +Or they can control what to do the problems detected (e.g. ignore hint axe problems). + +```ts +results.forEach((result) => { + result.problems.forEach((problem) => { + // Print everything except axe hint problems. + if(problem.hintId !== 'axe') { + console.log(`${problem.hintId} - ${problem.resource} - ${problem.message}`); + } + }); +}); +``` + +## Examples + +Analyze website and print the results manually instead of using 'format'. + +```ts +import { Analyzer } from 'hint'; + +const userConfig = { + extends: ['web-recommended'], + formatters: [] +}; + +const webhint = Analyzer.create(userConfig); + +const results: AnalyzerResult[] = await webhint.analyze('http://example.com'); + +results.forEach((result) => { + console.log(`Result for: ${result.url}`); + + result.problems.forEach((problem) => { + console.log(`${problem.hintId} - ${problem.resource} - ${problem.message}`); + }); +}); +``` + +Analyze website and print the results using the formatters in the configuration. + +```ts +import { Analyzer } from 'hint'; + +const userConfig = { + extends: ['web-recommended'] +}; + +const webhint = Analyzer.create(userConfig); + +const results: AnalyzerResult[] = await webhint.analyze('http://example.com'); + +results.forEach((result) => { + console.log(`Result for: ${result.url}`); + + // Print the result using `formatter-html` and `formatter-summary` + webhint.format(result.problems); +}); +``` \ No newline at end of file diff --git a/packages/hint/package.json b/packages/hint/package.json index daf6a0b9e1e..98544bc2754 100644 --- a/packages/hint/package.json +++ b/packages/hint/package.json @@ -79,7 +79,7 @@ "security" ], "license": "Apache-2.0", - "main": "./dist/src/lib/engine.js", + "main": "./dist/src/lib/index.js", "name": "hint", "nyc": { "branches": 75, diff --git a/packages/hint/src/lib/cli.ts b/packages/hint/src/lib/cli.ts index ffb0cabfa47..f7af907dbfa 100644 --- a/packages/hint/src/lib/cli.ts +++ b/packages/hint/src/lib/cli.ts @@ -19,7 +19,7 @@ import * as updateNotifier from 'update-notifier'; import { logger } from '@hint/utils'; -import loadHintPackage from './utils/packages/load-hint-package'; +import { loadHintPackage } from './utils/packages'; import { CLIOptions } from './types'; import { options } from './cli/options'; import { cliActions } from './cli/actions'; diff --git a/packages/hint/src/lib/cli/analyze.ts b/packages/hint/src/lib/cli/analyze.ts index 56cd243fab2..ae7cbdcc5b7 100644 --- a/packages/hint/src/lib/cli/analyze.ts +++ b/packages/hint/src/lib/cli/analyze.ts @@ -1,27 +1,35 @@ -import { URL } from 'url'; import * as path from 'path'; import boxen from 'boxen'; -import { default as ora, Ora } from 'ora'; import * as chalk from 'chalk'; import * as isCI from 'is-ci'; -import { EventAndListener } from 'eventemitter2'; +import { default as ora } from 'ora'; import { appInsights, configStore, debug as d, fs, logger, misc, network, npm } from '@hint/utils'; import { Configuration } from '../config'; -import { Engine } from '../engine'; -import { CLIOptions, Problem, Severity, UserConfig, HintResources, FormatterOptions } from '../types'; -import * as resourceLoader from '../utils/resource-loader'; -import loadHintPackage from '../utils/packages/load-hint-package'; +import { + AnalyzerError, + AnalyzeOptions, + CLIOptions, + CreateAnalyzerOptions, + HintResources, + Problem, + Severity, + UserConfig +} from '../types'; +import { loadHintPackage } from '../utils/packages/load-hint-package'; + +import { Analyzer } from '../types/analyzer'; +import { AnalyzerErrorStatus } from '../enums/error-status'; const { getAsUris } = network; -const { askQuestion, cutString } = misc; +const { askQuestion } = misc; const { installPackages } = npm; const { cwd } = fs; - const debug: debug.IDebugger = d(__filename); const configStoreKey: string = 'run'; +const spinner = ora({ spinner: 'line' }); /* * ------------------------------------------------------------------------------ @@ -70,7 +78,7 @@ or set the flag --tracking=on|off`; }; /** Ask user if he wants to activate the telemetry or not. */ -const askForTelemetryConfirmation = async (config: Configuration) => { +const askForTelemetryConfirmation = async (userConfig: UserConfig) => { if (appInsights.isConfigured()) { return; } @@ -103,7 +111,7 @@ const askForTelemetryConfirmation = async (config: Configuration) => { appInsights.enable(); appInsights.trackEvent('SecondRun'); - appInsights.trackEvent('analyze', config); + appInsights.trackEvent('analyze', userConfig); return; } @@ -111,11 +119,41 @@ const askForTelemetryConfirmation = async (config: Configuration) => { appInsights.disable(); }; -const askUserToUseDefaultConfiguration = async (): Promise => { +/** + * Prints a message telling the user a valid configuration couldn't be found and the + * defaults will be used. + */ +const showDefaultMessage = () => { + const defaultMessage = `${chalk.default.yellow(`Couldn't find any valid configuration`)} + +Running hint with the default configuration. + +Learn more about how to create your own configuration at: + +${chalk.default.green('https://webhint.io/docs/user-guide/')}`; + + printFrame(defaultMessage); +}; + +/** + * Prints a message to the screen alerting the user the defautl configuration + * will be used and returns the default configuration. + */ +const getDefaultConfiguration = () => { + showDefaultMessage(); + + return { extends: ['web-recommended'] }; +}; + +const askUserToUseDefaultConfiguration = async (): Promise => { const question: string = `A valid configuration file can't be found. Do you want to use the default configuration? To know more about the default configuration see: https://webhint.io/docs/user-guide/#default-configuration`; const confirmation: boolean = await askQuestion(question); - return confirmation; + if (confirmation) { + return getDefaultConfiguration(); + } + + return null; }; /** Prints the list of missing and incompatible resources found. */ @@ -143,34 +181,8 @@ const askUserToInstallDependencies = async (resources: HintResources): Promise { - const defaultMessage = `${chalk.default.yellow(`Couldn't find any valid configuration`)} - -Running hint with the default configuration. - -Learn more about how to create your own configuration at: - -${chalk.default.green('https://webhint.io/docs/user-guide/')}`; - - printFrame(defaultMessage); -}; - -/** - * Prints a message to the screen alerting the user the defautl configuration - * will be used and returns the default configuration. - */ -const getDefaultConfiguration = () => { - showDefaultMessage(); - - return { extends: ['web-recommended'] }; -}; - const getUserConfig = (actions?: CLIOptions): UserConfig | null => { - const configPath: string | null = (actions && actions.config) || Configuration.getFilenameForDirectory(process.cwd()); + const configPath = (actions && actions.config) || Configuration.getFilenameForDirectory(process.cwd()); if (!configPath) { return getDefaultConfiguration(); @@ -178,86 +190,103 @@ const getUserConfig = (actions?: CLIOptions): UserConfig | null => { debug(`Loading configuration file from ${configPath}.`); try { - const resolvedPath: string = path.resolve(process.cwd(), configPath); - - const config: UserConfig | null = Configuration.loadConfigFile(resolvedPath); + const resolvedPath = path.resolve(process.cwd(), configPath); + const config = Configuration.loadConfigFile(resolvedPath); return config || getDefaultConfiguration(); } catch (e) { - logger.error(e); - + /* + * If there is an error with the configuration, + * returns null to ask later to the user. + */ return null; } }; -const messages: { [name: string]: string } = { - 'fetch::end': '%url% downloaded', - 'fetch::start': 'Downloading %url%', - 'scan::end': 'Finishing...', - 'scan::start': 'Analyzing %url%', - 'traverse::down': 'Traversing the DOM', - 'traverse::end': 'Traversing finished', - 'traverse::start': 'Traversing the DOM', - 'traverse::up': 'Traversing the DOM' -}; +const askToInstallPackages = async (resources: HintResources): Promise => { + if (resources.missing.length > 0) { + appInsights.trackEvent('missing', resources.missing); + } -const getEvent = (event: string) => { - if (event.startsWith('fetch::end')) { - return 'fetch::end'; + if (resources.incompatible.length > 0) { + appInsights.trackEvent('incompatible', resources.incompatible); } - return event; -}; + const missingPackages = resources.missing.map((name) => { + return `@hint/${name}`; + }); -const setUpUserFeedback = (engine: Engine, spinner: Ora) => { - engine.prependAny(((event: string, value: { resource: string }) => { - const message: string = messages[getEvent(event)]; + const incompatiblePackages = resources.incompatible.map((name) => { + // If the packages are incompatible, we need to force to install the latest version. + return `@hint/${name}@latest`; + }); - if (!message) { - return; - } + if (!(await askUserToInstallDependencies(resources) && + await installPackages(missingPackages) && + await installPackages(incompatiblePackages))) { + + // The user doesn't want to install the dependencies or something went wrong installing them + return false; + } - spinner.text = message.replace('%url%', cutString(value.resource)); - }) as EventAndListener); + // After installing all the packages, we need to load the resources again. + return true; }; -/** Asks the users if they want to create a new configuration file or use the default one. */ -const getDefaultOrCreateConfig = async (actions: CLIOptions): Promise => { - const useDefault = await askUserToUseDefaultConfiguration(); - let userConfig: UserConfig; +const getAnalyzer = async (userConfig: UserConfig, options: CreateAnalyzerOptions): Promise => { + let webhint: Analyzer; - if (useDefault) { - userConfig = getDefaultConfiguration(); - } else { - logger.error(`Unable to find a valid configuration file. Please create a valid .hintrc file using 'npm init hintrc'.`); + try { + webhint = Analyzer.create(userConfig, options); + } catch (e) { + const error = e as AnalyzerError; - return null; - } + if (error.status === AnalyzerErrorStatus.ConfigurationError) { + const config = await askUserToUseDefaultConfiguration(); - return Configuration.fromConfig(userConfig, actions); -}; + if (!config) { + throw e; + } -/** - * Returns the configuration to use for the current execution. - * Depending on the user, the configuration could be read from a file, - * could be a new created one, or use the defaults. - */ -const getHintConfiguration = async (userConfig: UserConfig | null, actions: CLIOptions): Promise => { - if (!userConfig) { - return getDefaultOrCreateConfig(actions); - } + return getAnalyzer(config, options); + } - let config: Configuration | null; + if (error.status === AnalyzerErrorStatus.ResourceError) { + const installed = await askToInstallPackages(error.resources!); - try { - config = Configuration.fromConfig(userConfig, actions); - } catch (err) { - logger.error(err.message); + if (!installed) { + throw e; + } - config = await getDefaultOrCreateConfig(actions); + return getAnalyzer(userConfig, options); + } + + if (error.status === AnalyzerErrorStatus.HintError) { + logger.error(`Invalid hint configuration in .hintrc: ${error.invalidHints!.join(', ')}.`); + + throw e; + } + + /* + * If the error is not an AnalyzerErrorStatus + * bubble up the exception. + */ + logger.error(e.message, e); + + throw e; } - return config; + return webhint; +}; + +const actionsToOptions = (actions: CLIOptions): CreateAnalyzerOptions => { + const options: CreateAnalyzerOptions = { + formatters: actions.formatters ? actions.formatters.split(',') : undefined, + hints: actions.hints ? actions.hints.split(',') : undefined, + watch: actions.watch + }; + + return options; }; /* @@ -266,79 +295,29 @@ const getHintConfiguration = async (userConfig: UserConfig | null, actions: CLIO * ------------------------------------------------------------------------------ */ -// HACK: we need this to correctly test the messages in tests/lib/cli.ts. - -export let engine: Engine | null = null; - /** Analyzes a website if indicated by `actions`. */ export default async (actions: CLIOptions): Promise => { - - const targets: URL[] = getAsUris(actions._); + const targets = getAsUris(actions._); if (targets.length === 0) { return false; } - // userConfig will be null if an error occurred loading the user configuration (error parsing a JSON) - const userConfig: UserConfig | null = await getUserConfig(actions); - const config: Configuration | null = await getHintConfiguration(userConfig, actions); - - if (!config) { - return false; - } - - let resources = resourceLoader.loadResources(config); - - appInsights.trackEvent('analyze', config); - - if (resources.missing.length > 0 || resources.incompatible.length > 0) { - if (resources.missing.length > 0) { - appInsights.trackEvent('missing', resources.missing); - } - - if (resources.incompatible.length > 0) { - appInsights.trackEvent('incompatible', resources.incompatible); - } - - const missingPackages = resources.missing.map((name) => { - return `@hint/${name}`; - }); - - const incompatiblePackages = resources.incompatible.map((name) => { - // If the packages are incompatible, we need to force to install the latest version. - return `@hint/${name}@latest`; - }); - - if (!(await askUserToInstallDependencies(resources) && - await installPackages(missingPackages) && - await installPackages(incompatiblePackages))) { - - // The user doesn't want to install the dependencies or something went wrong installing them - return false; - } - - // After installing all the packages, we need to load the resources again. - resources = resourceLoader.loadResources(config); - } - - const invalidConfigHints = Configuration.validateHintsConfig(config).invalid; + const userConfig = await getUserConfig(actions); - if (invalidConfigHints.length > 0) { - logger.error(`Invalid hint configuration in .hintrc: ${invalidConfigHints.join(', ')}.`); + const createAnalyzerOptions = actionsToOptions(actions); + let webhint: Analyzer; + try { + webhint = await getAnalyzer(userConfig!, createAnalyzerOptions); + } catch (e) { return false; } - engine = new Engine(config, resources); - - const start: number = Date.now(); - const spinner = ora({ spinner: 'line' }); - let exitCode: number = 0; + appInsights.trackEvent('analyze', userConfig!); - if (!actions.debug) { - spinner.start(); - setUpUserFeedback(engine, spinner); - } + const start = Date.now(); + let exitCode = 0; const endSpinner = (method: string) => { if (!actions.debug && (spinner as any)[method]) { @@ -353,48 +332,63 @@ export default async (actions: CLIOptions): Promise => { }; const print = async (reports: Problem[], target?: string, scanTime?: number, date?: string): Promise => { - const formatterOptions: FormatterOptions = { + await webhint.format(reports, { config: userConfig || undefined, date, output: actions.output ? path.resolve(cwd(), actions.output) : undefined, - resources, + resources: webhint.resources, scanTime, target, version: loadHintPackage().version + }); + }; + + const getAnalyzeOptions = (): AnalyzeOptions => { + const scanStart = new Map(); + const analyzerOptions: AnalyzeOptions = { + targetEndCallback: undefined, + targetStartCallback: undefined, + updateCallback: undefined }; - if (engine) { - for (const formatter of engine.formatters) { - await formatter.format(reports, formatterOptions); - } + if (!actions.debug) { + analyzerOptions.updateCallback = (update) => { + spinner.text = update.message; + }; } - }; - engine.on('print', print); - - for (const target of targets) { - try { - const scanStart = Date.now(); - const results: Problem[] = await engine.executeOn(target); + analyzerOptions.targetStartCallback = (start) => { + if (!actions.debug) { + spinner.start(); + } + scanStart.set(start.url, Date.now()); + }; + analyzerOptions.targetEndCallback = async (end) => { const scanEnd = Date.now(); + const start = scanStart.get(end.url) || 0; - if (hasError(results)) { + if (hasError(end.problems)) { exitCode = 1; } endSpinner(exitCode ? 'fail' : 'succeed'); - await askForTelemetryConfirmation(config); - await print(results, target.href, scanEnd - scanStart, new Date(scanStart).toISOString()); - } catch (e) { - exitCode = 1; - endSpinner('fail'); - debug(`Failed to analyze: ${target.href}`); - debug(e); - } - } + await print(end.problems, end.url, scanEnd - start, new Date(start).toISOString()); + }; + + return analyzerOptions; + }; - await engine.close(); + try { + await webhint.analyze(targets, getAnalyzeOptions()); + + await askForTelemetryConfirmation(userConfig!); + } catch (e) { + exitCode = 1; + endSpinner('fail'); + debug(`Failed to analyze: ${e.url}`); + debug(e); + } debug(`Total runtime: ${Date.now() - start}ms`); diff --git a/packages/hint/src/lib/cli/version.ts b/packages/hint/src/lib/cli/version.ts index b3a444c89e4..2c7fe8255d4 100644 --- a/packages/hint/src/lib/cli/version.ts +++ b/packages/hint/src/lib/cli/version.ts @@ -1,5 +1,5 @@ import { logger } from '@hint/utils'; -import loadHintPackage from '../utils/packages/load-hint-package'; +import { loadHintPackage } from '../utils/packages/load-hint-package'; /** Prints the current hint version in the console. */ diff --git a/packages/hint/src/lib/config.ts b/packages/hint/src/lib/config.ts index ab2a1fdeed9..d76b1658e08 100644 --- a/packages/hint/src/lib/config.ts +++ b/packages/hint/src/lib/config.ts @@ -20,9 +20,9 @@ import * as path from 'path'; import browserslist = require('browserslist'); // `require` used because `browserslist` exports a function import mergeWith = require('lodash/mergeWith'); -import { debug as d, fs as fsUtils, logger } from '@hint/utils'; +import { debug as d, fs as fsUtils } from '@hint/utils'; -import { UserConfig, IgnoredUrl, CLIOptions, ConnectorConfig, HintsConfigObject, HintSeverity } from './types'; +import { UserConfig, IgnoredUrl, ConnectorConfig, HintsConfigObject, HintSeverity, CreateAnalyzerOptions } from './types'; import { validateConfig } from './config/config-validator'; import normalizeHints from './config/normalize-hints'; import { validate as validateHint, getSeverity } from './config/config-hints'; @@ -151,21 +151,19 @@ const buildHintsConfigFromHintNames = (hintNames: string[], severity: HintSeveri /** * Overrides the config values with values obtained from the CLI, if any */ -const updateConfigWithCommandLineValues = (config: UserConfig, actions?: CLIOptions) => { +const updateConfigWithOptionsValues = (config: UserConfig, options: CreateAnalyzerOptions = {}) => { debug('overriding config settings with values provided via CLI'); // If formatters are provided, use them - if (actions && actions.formatters) { - config.formatters = actions.formatters.split(','); - debug(`Using formatters option provided from command line: ${actions.formatters}`); + if (options.formatters) { + config.formatters = options.formatters; + debug(`Using formatters option provided from Analyzer options: ${options.formatters.join(', ')}`); } // If hints are provided, use them - if (actions && actions.hints) { - const hintNames = actions.hints.split(','); - - config.hints = buildHintsConfigFromHintNames(hintNames, 'error'); - debug(`Using hints option provided from command line: ${actions.hints}`); + if (options.hints) { + config.hints = buildHintsConfigFromHintNames(options.hints, 'error'); + debug(`Using hints option provided from command line: ${options.hints.join(', ')}`); } }; @@ -212,50 +210,6 @@ export class Configuration { }, {} as HintsConfigObject); } - /** - * @deprecated - * Generates the list of browsers to target using the `browserslist` property - * of the `hint` configuration or `package.json` or uses the default one - */ - public static loadBrowsersList(config: UserConfig) { - logger.warn('`Configuration.loadBrowsersList` is deprecated. Use `Configuration.fromConfig` instead.'); - - const directory: string = process.cwd(); - const files: string[] = CONFIG_FILES.reduce((total, configFile) => { - const filename: string = path.join(directory, configFile); - - if (isFile(filename)) { - total.push(filename); - } - - return total; - }, [] as string[]); - - if (!config.browserslist) { - for (let i = 0; i < files.length; i++) { - const file: string = files[i]; - const tmpConfig: UserConfig | null = Configuration.loadConfigFile(file); - - if (tmpConfig && tmpConfig.browserslist) { - config.browserslist = tmpConfig.browserslist; - break; - } - - if (file.endsWith('package.json')) { - const packagejson = loadJSONFile(file); - - config.browserslist = packagejson.browserslist; - } - } - } - - if (!config.browserslist || config.browserslist.length === 0) { - return browserslist(); - } - - return browserslist(config.browserslist); - } - /** * Loads a configuration file regardless of the source. Inspects the file path * to determine the correctly way to load the config file. @@ -370,10 +324,10 @@ export class Configuration { return config; } - public static fromConfig(config: UserConfig | null, actions?: CLIOptions): Configuration { + public static fromConfig(config: UserConfig | null, options?: CreateAnalyzerOptions): Configuration { if (!config) { - throw new Error(`Couldn't find a configuration file`); + throw new Error(`Couldn't find a configuration`); } if (!validateConfig(config)) { @@ -391,11 +345,11 @@ export class Configuration { } // In case the user uses the --watch flag when running hint - if (actions && actions.watch && userConfig.connector && userConfig.connector.options) { - userConfig.connector.options.watch = actions.watch; + if (options && options.watch && userConfig.connector && userConfig.connector.options) { + userConfig.connector.options.watch = options.watch; } - updateConfigWithCommandLineValues(userConfig, actions); + updateConfigWithOptionsValues(userConfig, options); if (userConfig.formatters && !Array.isArray(userConfig.formatters)) { userConfig.formatters = [userConfig.formatters]; @@ -430,34 +384,6 @@ export class Configuration { return validateResult; } - /** - * @deprecated - * Loads a configuration file regardless of the source. Inspects the file path - * to determine the correctly way to load the config file. - */ - public static fromFilePath(filePath: string, actions: CLIOptions): Configuration { - logger.warn('`Configuration.fromFilePath` is deprecated. Use `Configuration.loadConfigFile` with `Configuration.fromConfig` instead.'); - - /** - * 1. Load the file from the HD - * 2. Validate it's OK - * 3. Read extends and validate they are OK - * 4. Apply extends - * 6. Return final configuration object with defaults if needed - */ - - // 1 - const resolvedPath: string = path.resolve(process.cwd(), filePath); - const userConfig = Configuration.loadConfigFile(resolvedPath); - const config = this.fromConfig(userConfig, actions); - - if (userConfig) { - userConfig.browserslist = userConfig.browserslist || Configuration.loadBrowsersList(userConfig); - } - - return config; - } - /** * Retrieves the configuration filename for a given directory. It loops over all * of the valid configuration filenames in order to find the first one that exists. diff --git a/packages/hint/src/lib/engine.ts b/packages/hint/src/lib/engine.ts index dd719d50869..e2156573ce6 100644 --- a/packages/hint/src/lib/engine.ts +++ b/packages/hint/src/lib/engine.ts @@ -308,8 +308,11 @@ export class Engine extends EventEmitter { this.messages = []; } - public async notify(this: Engine) { - await this.emitAsync('print', this.messages); + public async notify(this: Engine, resource: string) { + await this.emitAsync('print', { + problems: this.messages, + resource + }); } /** Runs all the configured hints on a target */ diff --git a/packages/hint/src/lib/enums/error-status.ts b/packages/hint/src/lib/enums/error-status.ts index 178e75e9903..1139e56c525 100644 --- a/packages/hint/src/lib/enums/error-status.ts +++ b/packages/hint/src/lib/enums/error-status.ts @@ -4,3 +4,10 @@ export enum ResourceErrorStatus { NotFound = 'NotFound', Unknown = 'Unknown' } + +export enum AnalyzerErrorStatus { + AnalyzeError = 'AnalyzeError', + ConfigurationError = 'ConfigurationError', + HintError = 'HintError', + ResourceError = 'ResourceError' +} diff --git a/packages/hint/src/lib/index.ts b/packages/hint/src/lib/index.ts new file mode 100644 index 00000000000..a1ec47fa003 --- /dev/null +++ b/packages/hint/src/lib/index.ts @@ -0,0 +1,7 @@ +export * from './engine'; +export * from './types/analyzer'; +export * from './types'; +export * from './config'; +import * as allUtils from './utils'; + +export const utils = allUtils; diff --git a/packages/hint/src/lib/types.ts b/packages/hint/src/lib/types.ts index a9c04249619..d1e3da7b76d 100644 --- a/packages/hint/src/lib/types.ts +++ b/packages/hint/src/lib/types.ts @@ -14,6 +14,8 @@ export * from './types/problems'; export * from './types/hints'; export * from './types/parser'; export * from './types/schema-validation-result'; +export * from './types/analyzer'; +export * from './types/analyzer-error'; /** * The `Severity` of a hint. diff --git a/packages/hint/src/lib/types/analyzer-error.ts b/packages/hint/src/lib/types/analyzer-error.ts new file mode 100644 index 00000000000..7b58cc0bb90 --- /dev/null +++ b/packages/hint/src/lib/types/analyzer-error.ts @@ -0,0 +1,28 @@ +import { AnalyzerErrorStatus } from '../enums/error-status'; +import { HintResources } from '../types'; + +export class AnalyzerError extends Error { + public status: AnalyzerErrorStatus; + public resources?: HintResources; + public invalidHints?: string[]; + + public constructor(error: Error | string, status: AnalyzerErrorStatus, items?: HintResources | string[]) { + const message: string = typeof error === 'string' ? error : error.message; + + super(message); + + this.name = 'AnalyzerError'; + this.status = status; + + switch (status) { + case AnalyzerErrorStatus.ResourceError: + this.resources = items as HintResources; + break; + case AnalyzerErrorStatus.HintError: + this.invalidHints = items as string[]; + break; + default: + break; + } + } +} diff --git a/packages/hint/src/lib/types/analyzer.ts b/packages/hint/src/lib/types/analyzer.ts new file mode 100644 index 00000000000..9c3f21b44b8 --- /dev/null +++ b/packages/hint/src/lib/types/analyzer.ts @@ -0,0 +1,267 @@ +import { URL } from 'url'; + +import { Configuration } from '../config'; +import { AnalyzerError, FormatterOptions, HintResources, IFormatter, Problem, UserConfig } from '../types'; +import { Engine } from '../engine'; +import { cutString } from '@hint/utils/dist/src/misc/cut-string'; +import { AnalyzerErrorStatus } from '../enums/error-status'; +import { logger } from '@hint/utils'; +import { IFormatterConstructor } from './formatters'; +import { loadResources } from '../utils/resource-loader'; + +export type CreateAnalyzerOptions = { + watch?: boolean; + formatters?: string[]; + hints?: string[]; +} + +export type Target = { + url: string | URL; + content?: string; +}; + +export type Endpoint = string | URL | Target; + +export type AnalyzerResult = { + url: string; + problems: Problem[]; +}; + +export type AnalyzerTargetStart = { + url: string; +}; + +export type AnalyzerTargetEnd = AnalyzerTargetStart & { + problems: Problem[]; +}; + +export type AnalyzerTargetUpdate = AnalyzerTargetStart & { + message: string; + resource?: string; +}; + +export type AnalyzeOptions = { + targetEndCallback?: (targetEvent: AnalyzerTargetEnd) => Promise | void; + targetStartCallback?: (targetEvent: AnalyzerTargetStart) => Promise | void; + updateCallback?: (update: AnalyzerTargetUpdate) => Promise | void; +}; + +const initFormatters = (formatters: IFormatterConstructor[]): IFormatter[] => { + const result = formatters.map((FormatterConstructor) => { + return new FormatterConstructor(); + }); + + return result; +}; + +const validateResources = (resources: HintResources) => { + if (resources.missing.length > 0 || resources.incompatible.length > 0) { + throw new AnalyzerError('Missing or incompatible dependencies', AnalyzerErrorStatus.ResourceError, resources); + } +}; + +const validateHints = (configuration: Configuration) => { + const hintsValidation = Configuration.validateHintsConfig(configuration); + + if (hintsValidation.invalid.length > 0) { + throw new AnalyzerError('Invalid Hints', AnalyzerErrorStatus.HintError, hintsValidation.invalid); + } +}; + +/** + * Node API. + */ +export class Analyzer { + private configuration: Configuration; + private _resources: HintResources; + private formatters: IFormatter[]; + private watch: boolean | undefined; + private messages: { [name: string]: string } = { + 'fetch::end': '%url% downloaded', + 'fetch::start': 'Downloading %url%', + 'scan::end': 'Finishing...', + 'scan::start': 'Analyzing %url%', + 'traverse::down': 'Traversing the DOM', + 'traverse::end': 'Traversing finished', + 'traverse::start': 'Traversing the DOM', + 'traverse::up': 'Traversing the DOM' + } + + private constructor(configuration: Configuration, resources: HintResources, formatters: IFormatter[]) { + this.configuration = configuration; + this._resources = resources; + this.formatters = formatters; + this.watch = this.configuration.connector && this.configuration.connector.options && this.configuration.connector.options.watch; + } + + /** + * Validates a configuration and return an Analyzer object. + * @param userConfiguration User configuration to load. + * @param options Options used to initialize the configuration. + */ + public static create(userConfiguration: UserConfig, options: CreateAnalyzerOptions = {}) { + let configuration: Configuration; + + if (!userConfiguration) { + throw new AnalyzerError('Missed configuration', AnalyzerErrorStatus.ConfigurationError); + } + + try { + configuration = Configuration.fromConfig(userConfiguration, options); + } catch (e) { + throw new AnalyzerError('Invalid configuration', AnalyzerErrorStatus.ConfigurationError); + } + + const resources = loadResources(configuration!); + const formatters = initFormatters(resources.formatters); + + validateResources(resources); + validateHints(configuration); + + return new Analyzer(configuration, resources, formatters); + } + + /** + * Normalize a given url. + * @param {string | URL | Target} inputUrl URL to convert. + */ + private normalizeTarget(inputUrl: string | URL | Target): Target { + if ((inputUrl as Target).url) { + const target = (inputUrl as Target); + const url = target.url instanceof URL ? target.url : new URL(target.url); + + return { + content: target.content, + url + }; + } + + const url = inputUrl instanceof URL ? inputUrl : new URL(inputUrl as string); + + return { + content: undefined, + url + }; + } + + /** + * Normalize a given event. + * @param {string} event Event to normalize. + */ + private normalizeEvent(event: string) { + if (event.startsWith('fetch::end')) { + return 'fetch::end'; + } + + return event; + } + + /** + * Configure an engine. + * @param {Engine} engine Engine to configure. + * @param {string} url URL that is going to be analyzed. + * @param {AnalyzeOptions} options Options to configure the analysis. + */ + private configureEngine(engine: Engine, url: string, options: AnalyzeOptions) { + if (options.updateCallback) { + engine.prependAny(((event: string, value: { resource: string }) => { + const message = this.messages[this.normalizeEvent(event)]; + + if (!message) { + return; + } + + options.updateCallback!({ + message: message.replace('%url%', cutString(value.resource)), + resource: value.resource, + url + }); + }) as import('eventemitter2').EventAndListener); + } + + if (this.watch) { + engine.on('print', async (event) => { + await this.format(event.problems); + }); + } + } + + /** + * Analyze the given URL(s). + * @param {Endpoint} endpoints Endpoint(s) to analyze. + * @param options Options to configure the analysis. + */ + public async analyze(endpoints: Endpoint | Endpoint[], options: AnalyzeOptions = {}): Promise { + let targets: Target[]; + const results: AnalyzerResult[] = []; + + if (Array.isArray(endpoints)) { + targets = endpoints.map(this.normalizeTarget); + } else { + targets = [this.normalizeTarget(endpoints)]; + } + + // TODO: Allow parallelism, indicating the number of simultaneous targets (open an issue for this). + for (const target of targets) { + const url = target.url as URL; + + if (target.content && this.configuration.connector!.name !== 'local') { + throw new AnalyzerError(`Property 'content' is only supported in formatter local. Webhint will analyze the url ${url.href}`, AnalyzerErrorStatus.AnalyzeError); + } + + const engine = new Engine(this.configuration, this._resources); + + this.configureEngine(engine, url.href, options); + + let problems: Problem[] | null = null; + + try { + if (options.targetStartCallback) { + await options.targetStartCallback({ url: url.href }); + } + problems = await engine.executeOn(url, { content: target.content }); + } catch (e) { + throw new AnalyzerError(e, AnalyzerErrorStatus.AnalyzeError); + } finally { + // TODO: Try if this is executed. + await engine.close(); + } + + if (options.targetEndCallback) { + await options.targetEndCallback({ + problems: problems!, + url: url.href + }); + } + + results.push({ + problems: problems!, + url: (target.url as URL).href + }); + } + + if (this.watch && this.configuration.connector!.name !== 'local') { + logger.warn(`WARNING: The option 'watch' is not supported in connector '${this.configuration.connector!.name}'`); + } + + return results; + } + + /** + * Run all the formatters configured in the Analyzer with the problems passed. + * @param {Problem[]} problems Problems to format. + * @param {FormatterOptions} options Options for the formatters. + */ + public async format(problems: Problem[], options?: FormatterOptions): Promise { + for (const formatter of this.formatters) { + await formatter.format(problems, options); + } + } + + /** + * Returns the resources configured loaded in the Analyzer. + */ + public get resources() { + return this._resources; + } +} diff --git a/packages/hint/src/lib/types/events.ts b/packages/hint/src/lib/types/events.ts index 3b6547a4df5..bc87f04bca5 100644 --- a/packages/hint/src/lib/types/events.ts +++ b/packages/hint/src/lib/types/events.ts @@ -62,6 +62,10 @@ export type TraverseDown = Event & { /** The object emitted by a connector on `can-evaluate` */ export type CanEvaluateScript = Event; +export type PrintEvent = Event & { + problems: Problem[]; +}; + export type Events = { 'can-evaluate::script': CanEvaluateScript; 'fetch::end::*': FetchEnd; @@ -79,7 +83,7 @@ export type Events = { 'fetch::start': FetchStart; 'fetch::start::target': FetchStart; 'parse::error::*': ErrorEvent; - 'print': Problem[]; + 'print': PrintEvent; 'scan::end': ScanEnd; 'scan::start': ScanStart; 'traverse::down': TraverseDown; diff --git a/packages/hint/src/lib/types/html.ts b/packages/hint/src/lib/types/html.ts index 69aecf59a5a..8b28a5f1572 100644 --- a/packages/hint/src/lib/types/html.ts +++ b/packages/hint/src/lib/types/html.ts @@ -3,7 +3,7 @@ import * as htmlparser2Adapter from 'parse5-htmlparser2-tree-adapter'; import * as cssSelect from 'css-select'; import { ProblemLocation } from '../types'; -import findOriginalElement from '../utils/dom/find-original-element'; +import { findOriginalElement } from '../utils/dom/find-original-element'; type Attrib = { [key: string]: string; diff --git a/packages/hint/src/lib/utils/dom/create-html-document.ts b/packages/hint/src/lib/utils/dom/create-html-document.ts index 3b6b420c11b..e0e7ec9b87b 100644 --- a/packages/hint/src/lib/utils/dom/create-html-document.ts +++ b/packages/hint/src/lib/utils/dom/create-html-document.ts @@ -2,7 +2,7 @@ import { HTMLDocument } from '../../types/html'; import * as parse5 from 'parse5'; import * as htmlparser2Adapter from 'parse5-htmlparser2-tree-adapter'; -export default (html: string, originalDocument?: HTMLDocument): HTMLDocument => { +export const createHTMLDocument = (html: string, originalDocument?: HTMLDocument): HTMLDocument => { const dom = parse5.parse(html, { sourceCodeLocationInfo: !originalDocument, treeAdapter: htmlparser2Adapter diff --git a/packages/hint/src/lib/utils/dom/find-original-element.ts b/packages/hint/src/lib/utils/dom/find-original-element.ts index 6e1d533c135..d80876c74c8 100644 --- a/packages/hint/src/lib/utils/dom/find-original-element.ts +++ b/packages/hint/src/lib/utils/dom/find-original-element.ts @@ -42,7 +42,7 @@ const findMatch = (document: HTMLDocument, element: HTMLElement, query: string, * which is likely the original source for the provided element. Used to * resolve element locations to the original HTML when possible. */ -export default (document: HTMLDocument, element: HTMLElement): HTMLElement | null => { +export const findOriginalElement = (document: HTMLDocument, element: HTMLElement): HTMLElement | null => { // Elements with attributes whose values are typically unique (e.g. IDs or URLs). for (const attribute of ['id', 'name', 'data', 'href', 'src', 'srcset', 'charset']) { diff --git a/packages/hint/src/lib/utils/dom/get-element-by-url.ts b/packages/hint/src/lib/utils/dom/get-element-by-url.ts index c5cce8ceace..2bc7c4a8fef 100644 --- a/packages/hint/src/lib/utils/dom/get-element-by-url.ts +++ b/packages/hint/src/lib/utils/dom/get-element-by-url.ts @@ -23,7 +23,7 @@ const getSrcsetUrls = (srcset: string): string[] => { return urls; }; -export default (dom: HTMLDocument, url: string, base: string): HTMLElement | null => { +export const getElementByUrl = (dom: HTMLDocument, url: string, base: string): HTMLElement | null => { // TODO: Cache dom.querySelectorAll?. const elements = dom.querySelectorAll('[href],[src],[poster],[srcset]').filter((element: any) => { const elementUrl = element.getAttribute('href') || element.getAttribute('src') || element.getAttribute('poster'); diff --git a/packages/hint/src/lib/utils/dom/index.ts b/packages/hint/src/lib/utils/dom/index.ts new file mode 100644 index 00000000000..fcd87f57e12 --- /dev/null +++ b/packages/hint/src/lib/utils/dom/index.ts @@ -0,0 +1,4 @@ +export * from './create-html-document'; +export * from './find-original-element'; +export * from './get-element-by-url'; +export * from './traverse'; diff --git a/packages/hint/src/lib/utils/dom/traverse.ts b/packages/hint/src/lib/utils/dom/traverse.ts index 4da556db13d..662e1c64686 100644 --- a/packages/hint/src/lib/utils/dom/traverse.ts +++ b/packages/hint/src/lib/utils/dom/traverse.ts @@ -24,7 +24,7 @@ const traverseAndNotify = async (element: HTMLElement, document: HTMLDocument, e await engine.emitAsync(`traverse::up`, traverseEvent); }; -export default async (document: HTMLDocument, engine: Engine, resource: string): Promise => { +export const traverse = async (document: HTMLDocument, engine: Engine, resource: string): Promise => { const documentElement = document.documentElement; const event = { resource } as Event; diff --git a/packages/hint/src/lib/utils/index.ts b/packages/hint/src/lib/utils/index.ts new file mode 100644 index 00000000000..538d950926c --- /dev/null +++ b/packages/hint/src/lib/utils/index.ts @@ -0,0 +1,13 @@ +import * as domUtils from './dom'; +import * as packagesUtils from './packages'; +import * as contentTypeUtils from './content-type'; +import * as resourceLoaderUtils from './resource-loader'; +import * as schemaValidatorUtils from './schema-validator'; + +export * from './resource-loader'; + +export const dom = domUtils; +export const packages = packagesUtils; +export const contentType = contentTypeUtils; +export const resourceLoader = resourceLoaderUtils; +export const schemaValidator = schemaValidatorUtils; diff --git a/packages/hint/src/lib/utils/packages/index.ts b/packages/hint/src/lib/utils/packages/index.ts new file mode 100644 index 00000000000..7e85131a9b1 --- /dev/null +++ b/packages/hint/src/lib/utils/packages/index.ts @@ -0,0 +1 @@ +export * from './load-hint-package'; diff --git a/packages/hint/src/lib/utils/packages/load-hint-package.ts b/packages/hint/src/lib/utils/packages/load-hint-package.ts index 200f08479d5..7d3791843f1 100644 --- a/packages/hint/src/lib/utils/packages/load-hint-package.ts +++ b/packages/hint/src/lib/utils/packages/load-hint-package.ts @@ -3,7 +3,7 @@ import { packages } from '@hint/utils'; const { findPackageRoot } = packages; /** Returns an object that represents the `package.json` version of `hint` */ -export default () => { +export const loadHintPackage = () => { // webpack will embed the package.json /* istanbul ignore if */ if (process.env.webpack) { // eslint-disable-line no-process-env diff --git a/packages/hint/src/lib/utils/resource-loader.ts b/packages/hint/src/lib/utils/resource-loader.ts index 8c7853aec4e..da35e09d884 100644 --- a/packages/hint/src/lib/utils/resource-loader.ts +++ b/packages/hint/src/lib/utils/resource-loader.ts @@ -27,10 +27,10 @@ import { ResourceType } from '../enums/resource-type'; import { ResourceErrorStatus } from '../enums/error-status'; import { ResourceError } from '../types/resource-error'; import { IConnectorConstructor } from '../types/connector'; -import loadHintPackage from '../utils/packages/load-hint-package'; +import { loadHintPackage } from './packages/load-hint-package'; const { cwd, loadJSONFile, readFile } = fsUtils; -const { loadPackage: getPackage, findNodeModulesRoot, findPackageRoot} = packages; +const { loadPackage: getPackage, findNodeModulesRoot, findPackageRoot } = packages; const { normalizeIncludes } = misc; const debug: debug.IDebugger = d(__filename); diff --git a/packages/hint/tests/lib/cli.ts b/packages/hint/tests/lib/cli.ts index 2b9b5c20ee2..6a2cd60e634 100644 --- a/packages/hint/tests/lib/cli.ts +++ b/packages/hint/tests/lib/cli.ts @@ -9,7 +9,7 @@ type Package = { }; type LoadHintPackage = { - default: () => Package; + loadHintPackage: () => Package; }; type Logger = { @@ -48,7 +48,7 @@ const initContext = (t: ExecutionContext) => { return t.context.notifier; }; t.context.loadHintPackage = { - default() { + loadHintPackage() { return { version: '' }; } }; @@ -63,7 +63,7 @@ const initContext = (t: ExecutionContext) => { const loadScript = (context: ConfigTestContext) => { return proxyquire('../../src/lib/cli', { './cli/actions': context.cliActions, - './utils/packages/load-hint-package': context.loadHintPackage, + './utils/packages': context.loadHintPackage, '@hint/utils': { logger: context.logger }, 'update-notifier': context.updateNotifier }); @@ -88,7 +88,7 @@ test('Users should be notified if there is a new version of hint', async (t) => See ${chalk.cyan('https://webhint.io/about/changelog/')} for details`; t.context.notifier.update = newUpdate; - sandbox.stub(t.context.loadHintPackage, 'default').returns({ version: '0.2.0' }); + sandbox.stub(t.context.loadHintPackage, 'loadHintPackage').returns({ version: '0.2.0' }); const cli = loadScript(t.context); @@ -118,7 +118,7 @@ test(`Users shouldn't be notified if they just updated to the latest version and }; t.context.notifier.update = newUpdate; - sandbox.stub(t.context.loadHintPackage, 'default').returns({ version: '0.3.0' }); + sandbox.stub(t.context.loadHintPackage, 'loadHintPackage').returns({ version: '0.3.0' }); const cli = loadScript(t.context); diff --git a/packages/hint/tests/lib/cli/analyze.ts b/packages/hint/tests/lib/cli/analyze.ts index 4c974bc871a..9a438bb6069 100644 --- a/packages/hint/tests/lib/cli/analyze.ts +++ b/packages/hint/tests/lib/cli/analyze.ts @@ -1,40 +1,42 @@ -import { URL } from 'url'; - -import { EventEmitter2 as EventEmitter } from 'eventemitter2'; import * as proxyquire from 'proxyquire'; import * as sinon from 'sinon'; import anyTest, { TestInterface, ExecutionContext } from 'ava'; import * as utils from '@hint/utils'; -import { CLIOptions, Severity, IFormatter, Problem, HintResources, IConnector, UserConfig } from '../../../src/lib/types'; +import { + AnalyzeOptions, + AnalyzerError, + AnalyzerResult, + CLIOptions, + CreateAnalyzerOptions, + Endpoint, + Problem, + Severity, + UserConfig +} from '../../../src/lib/types'; +import { AnalyzerErrorStatus } from '../../../src/lib/enums/error-status'; const actions = { _: ['http://localhost/'] } as CLIOptions; -class FakeConnector implements IConnector { - public collect(target: URL) { - return Promise.resolve(target); +class FakeAnalyzer { + public constructor() { } - public close() { - return Promise.resolve(); + public analyze(endpoints: Endpoint | Endpoint[], options: AnalyzeOptions = {}): Promise { + return Promise.resolve([]); } - public evaluate(): any { } + public async format() { + } - public fetchContent(): any { } + public resources() { + } - public querySelectorAll(): any { } + public static create() { + } } -type ResourceLoader = { - loadResources: () => HintResources | null; -}; - -type ValidateHintsConfigResult = { - invalid: any[]; -}; - type AskQuestion = () => any; type Logger = { @@ -43,31 +45,14 @@ type Logger = { }; type Configuration = { - fromConfig: (config: UserConfig | null) => {}; getFilenameForDirectory: () => string | null; loadConfigFile: (path: string) => {}; - validateHintsConfig: () => ValidateHintsConfigResult | null; }; type Config = { Configuration: Configuration; }; -type IEnginePrototype = { - formatters: any[]; - close(): void; - emitAsync(eventName: string, data: any): Promise; - executeOn(): Promise; -}; - -interface IEngine { - new(): IEnginePrototype; -} - -type EngineContainer = { - Engine: IEngine; -}; - type Spinner = { fail: () => void; start: () => void; @@ -79,16 +64,28 @@ type Ora = { default: () => Spinner; }; +type Analyzer = { + Analyzer: () => void; +} + +type AppInsight = { + disable: () => void; + enable: () => void; + isConfigured: () => boolean; + isEnabled: () => boolean; + trackEvent: () => void; +}; + type AnalyzeContext = { + analyzer: Analyzer; + appInsight: AppInsight; askQuestion: AskQuestion; config: Config; - engineContainer: EngineContainer; errorSpy: sinon.SinonSpy<[string]>; failSpy: sinon.SinonSpy<[]>; logger: Logger; logSpy: sinon.SinonSpy<[string]>; ora: Ora; - resourceLoader: ResourceLoader; sandbox: sinon.SinonSandbox; spinner: Spinner; startSpy: sinon.SinonSpy<[]>; @@ -96,14 +93,6 @@ type AnalyzeContext = { }; const test = anyTest as TestInterface; -const validateHintsConfigResult: ValidateHintsConfigResult = { invalid: [] }; -const appinsight = { - disable() { }, - enable() { }, - isConfigured() { }, - isEnabled() { }, - trackEvent() { } -}; const initContext = (t: ExecutionContext) => { const sandbox = sinon.createSandbox(); @@ -116,17 +105,11 @@ const initContext = (t: ExecutionContext) => { t.context.config = { Configuration: { - fromConfig(config: UserConfig | null) { - return {}; - }, getFilenameForDirectory(): string | null { return ''; }, loadConfigFile(path: string) { return {}; - }, - validateHintsConfig(): ValidateHintsConfigResult | null { - return null; } } }; @@ -146,35 +129,36 @@ const initContext = (t: ExecutionContext) => { t.context.startSpy = sandbox.spy(spinner, 'start'); t.context.failSpy = sandbox.spy(spinner, 'fail'); t.context.succeedSpy = sandbox.spy(spinner, 'succeed'); - t.context.engineContainer = { - Engine: class Engine extends EventEmitter { - public get formatters() { - return []; - } + t.context.askQuestion = () => { }; + t.context.sandbox = sandbox; - public close() { } - public executeOn() { - return Promise.resolve(); - } - } - }; + const analyzer: Analyzer = { Analyzer: function Analyzer() { } }; - t.context.askQuestion = () => { }; - t.context.resourceLoader = { - loadResources() { - return null; - } + analyzer.Analyzer.prototype.create = (userConfiguration: UserConfig, options: CreateAnalyzerOptions) => { }; + + t.context.analyzer = analyzer; + (t.context.analyzer.Analyzer as any).create = (userConfiguration: UserConfig, options: CreateAnalyzerOptions): Analyzer => { + return {} as Analyzer; + }; + t.context.appInsight = { + disable() { }, + enable() { }, + isConfigured() { + return false; + }, + isEnabled() { + return false; + }, + trackEvent() { } }; - t.context.sandbox = sandbox; }; const loadScript = (context: AnalyzeContext) => { const script = proxyquire('../../../src/lib/cli/analyze', { '../config': context.config, - '../engine': context.engineContainer, - '../utils/resource-loader': context.resourceLoader, + '../types/analyzer': context.analyzer, '@hint/utils': { - appInsights: appinsight, + appInsights: context.appInsight, configStore: utils.configStore, debug: utils.debug, logger: context.logger, @@ -200,26 +184,16 @@ test.afterEach.always((t) => { test('If config is not defined, it should get the config file from the directory process.cwd()', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); + + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').resolves([]); - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'executeOn').resolves([]); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); const getFilenameForDirectoryStub = sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); sandbox.stub(t.context.config.Configuration, 'loadConfigFile') .onFirstCall() .returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); const analyze = loadScript(t.context); @@ -231,80 +205,56 @@ test('If config is not defined, it should get the config file from the directory test('If config file does not exist, it should use `web-recommended` as default configuration', async (t) => { const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory') - .onFirstCall() - .returns(null); + const createAnalyzerStub = sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(new FakeAnalyzer()); + sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns(null); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - const fromConfigStub = sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - sandbox.stub(t.context, 'askQuestion').resolves(false); const analyze = loadScript(t.context); await analyze(actions); - t.true(fromConfigStub.calledOnce); - t.deepEqual(fromConfigStub.args[0][0], { extends: ['web-recommended'] }); + t.true(createAnalyzerStub.calledOnce); + t.deepEqual(createAnalyzerStub.args[0][0], { extends: ['web-recommended'] }); }); test('If config file is an invalid JSON, it should ask to use the default configuration', async (t) => { const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); + sandbox.stub(t.context.appInsight, 'isConfigured').returns(true); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory') .onFirstCall() .returns('config/path'); - - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').throws(new Error('Unexpected end of JSON input')); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const configurationFromConfigStub = sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); + const createAnalyzerStub = sandbox.stub(t.context.analyzer.Analyzer as any, 'create') + .onFirstCall() + .throws(new AnalyzerError('Missed configuration', AnalyzerErrorStatus.ConfigurationError)) + .onSecondCall() + .returns(new FakeAnalyzer()); + const configurationLoadConfigStub = sandbox.stub(t.context.config.Configuration, 'loadConfigFile').throws(new Error('Unexpected end of JSON input')); const askQuestionDefaultStub = sandbox.stub(t.context, 'askQuestion').resolves(true); const analyze = loadScript(t.context); await analyze(actions); - t.true(configurationFromConfigStub.calledOnce); - t.deepEqual(configurationFromConfigStub.args[0][0], { extends: ['web-recommended'] }); + t.true(configurationLoadConfigStub.calledOnce); t.true(askQuestionDefaultStub.calledOnce); + t.true(createAnalyzerStub.called); }); test('If config file has an invalid configuration, it should ask to use the default configuration', async (t) => { const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').throws(new Error('Unexpected end of JSON input')); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - const configurationFromConfigStub = sandbox.stub(t.context.config.Configuration, 'fromConfig') + sandbox.stub(t.context.appInsight, 'isConfigured').returns(true); + + const createAnalyzerStub = sandbox.stub(t.context.analyzer.Analyzer as any, 'create') + .onFirstCall() + .throws(new AnalyzerError('Missed configuration', AnalyzerErrorStatus.ConfigurationError)) .onSecondCall() - .returns({}); + .returns(new FakeAnalyzer()); + const configurationLoadConfigStub = sandbox.stub(t.context.config.Configuration, 'loadConfigFile').throws(new Error('Unexpected end of JSON input')); const askQuestionDefaultStub = sandbox.stub(t.context, 'askQuestion').resolves(true); const analyze = loadScript(t.context); @@ -312,25 +262,18 @@ test('If config file has an invalid configuration, it should ask to use the defa await analyze(actions); t.true(askQuestionDefaultStub.calledOnce); - t.true(configurationFromConfigStub.calledOnce); - t.deepEqual(configurationFromConfigStub.args[0][0], { extends: ['web-recommended'] }); + t.true(configurationLoadConfigStub.calledOnce); + t.is(createAnalyzerStub.args[0][0], null); }); test('If config file is invalid and user refuses to use the default or to create a configuration file, it should exit with code 1', async (t) => { - const error = { message: `Couldn't find any valid configuration` }; const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').throws(error); + sandbox.stub(t.context.config.Configuration, 'loadConfigFile').throws(new Error()); + const createAnalyzerStub = sandbox.stub(t.context.analyzer.Analyzer as any, 'create') + .onFirstCall() + .throws(new AnalyzerError('Missed configuration', AnalyzerErrorStatus.ConfigurationError)); const askQuestionDefaultStub = sandbox.stub(t.context, 'askQuestion').resolves(false); const analyze = loadScript(t.context); @@ -339,21 +282,13 @@ test('If config file is invalid and user refuses to use the default or to create t.true(askQuestionDefaultStub.calledOnce); t.false(result); + t.true(createAnalyzerStub.calledOnce); }); test('If configuration file exists, it should use it', async (t) => { const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); + const createAnalyzerSpy = sandbox.stub(t.context.analyzer.Analyzer as any, 'create'); const configurationGetFilenameForDirectoryStub = sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); const configurationLoadConfigFileStub = sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); @@ -365,67 +300,52 @@ test('If configuration file exists, it should use it', async (t) => { t.true(configurationGetFilenameForDirectoryStub.notCalled); t.true(configurationLoadConfigFileStub.args[0][0].endsWith('configfile.cfg')); + t.true(createAnalyzerSpy.called); }); -test('If executeOn returns an error, it should exit with code 1 and call formatter.format', async (t) => { +test('If executeOn returns an error, it should exit with code 1 and call to analyzer.format', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').callsFake(async (targets: Endpoint | Endpoint[], options?: AnalyzeOptions) => { + await options!.targetEndCallback!({ + problems: [{ severity: Severity.error } as Problem], + url: 'https://example.com' + }); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] + return []; }); + const analyzerFormatSpy = sandbox.spy(fakeAnalyzer, 'format'); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').resolves([{ severity: Severity.error }]); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); sandbox.stub(t.context, 'askQuestion').resolves(false); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); const analyze = loadScript(t.context); const exitCode = await analyze(actions); - t.true(FakeFormatter.called); t.false(exitCode); + t.true(analyzerFormatSpy.calledOnce); }); test('If executeOn returns an error, it should call to spinner.fail()', async (t) => { const sandbox = sinon.createSandbox(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] + const fakeAnalyzer = new FakeAnalyzer(); + + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').callsFake(async (targets: Endpoint | Endpoint[], options?: AnalyzeOptions) => { + await options!.targetEndCallback!({ + problems: [{ severity: Severity.error } as Problem], + url: 'https://example.com' + }); + + return []; }); + sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - sandbox.stub((t.context.engineContainer.Engine.prototype as IEnginePrototype), 'executeOn').resolves([{ severity: Severity.error }]); const analyze = loadScript(t.context); @@ -436,21 +356,12 @@ test('If executeOn returns an error, it should call to spinner.fail()', async (t test('If executeOn throws an exception, it should exit with code 1', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').rejects(new Error()); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.engineContainer.Engine.prototype, 'executeOn').throws(new Error()); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - const analyze = loadScript(t.context); const result = await analyze(actions); @@ -459,20 +370,12 @@ test('If executeOn throws an exception, it should exit with code 1', async (t) = test('If executeOn throws an exception, it should call to spinner.fail()', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').rejects(new Error()); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.engineContainer.Engine.prototype, 'executeOn').throws(new Error()); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); const analyze = loadScript(t.context); @@ -481,359 +384,77 @@ test('If executeOn throws an exception, it should call to spinner.fail()', async t.true(t.context.failSpy.calledOnce); }); -test('If executeOn returns no errors, it should exit with code 0 and call formatter.format', async (t) => { +test('If executeOn returns no errors, it should exit with code 0 and call analyzer.format', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').callsFake(async (targets: Endpoint | Endpoint[], options?: AnalyzeOptions) => { + await options!.targetEndCallback!({ + problems: [{ severity: 0 } as Problem], + url: 'https://example.com' + }); - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; + return []; }); - sandbox.stub(engineObj, 'executeOn').resolves([{ severity: 0 }]); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); + const analyzerFormatSpy = sandbox.spy(fakeAnalyzer, 'format'); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); const analyze = loadScript(t.context); const exitCode = await analyze(actions); - t.true(FakeFormatter.called); t.true(exitCode); + t.true(analyzerFormatSpy.calledOnce); }); test('If executeOn returns no errors, it should call to spinner.succeed()', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').resolves([{ severity: 0 }]); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const analyze = loadScript(t.context); - - await analyze(actions); - - t.true(t.context.succeedSpy.calledOnce); -}); - -test('Event fetch::start should write a message in the spinner', async (t) => { - const sandbox = sinon.createSandbox(); - - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('fetch::start', { resource: 'http://localhost/' }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const analyze = loadScript(t.context); - - await analyze(actions); - - t.is(t.context.spinner.text, 'Downloading http://localhost/'); -}); - -test('Event fetch::end should write a message in the spinner', async (t) => { - const sandbox = sinon.createSandbox(); - - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('fetch::end::html', { - element: null, - request: {} as any, - resource: 'http://localhost/', - response: {} as any + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').callsFake(async (targets: Endpoint | Endpoint[], options?: AnalyzeOptions) => { + await options!.targetEndCallback!({ + problems: [{ severity: 0 } as Problem], + url: 'https://example.com' }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const analyze = loadScript(t.context); - - await analyze(actions); - t.is(t.context.spinner.text, 'http://localhost/ downloaded'); -}); - -test('Event fetch::end::html should write a message in the spinner', async (t) => { - const sandbox = sinon.createSandbox(); - - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] + return []; }); - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('fetch::end::html', { - element: null, - request: {} as any, - resource: 'http://localhost/', - response: {} as any - }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); const analyze = loadScript(t.context); await analyze(actions); - t.is(t.context.spinner.text, 'http://localhost/ downloaded'); + t.true(t.context.succeedSpy.calledOnce); }); -test('Event traverse::up should write a message in the spinner', async (t) => { +test('updateCallback should write a message in the spinner', async (t) => { const sandbox = sinon.createSandbox(); + const fakeAnalyzer = new FakeAnalyzer(); - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('traverse::up', { - element: {} as any, - resource: 'http://localhost/' + sandbox.stub(t.context.analyzer.Analyzer as any, 'create').returns(fakeAnalyzer); + sandbox.stub(fakeAnalyzer, 'analyze').callsFake(async (targets: Endpoint | Endpoint[], options?: AnalyzeOptions) => { + await options!.updateCallback!({ + message: 'Downloading http://localhost/', + url: 'http://example.com' }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const analyze = loadScript(t.context); - - await analyze(actions); - - t.is(t.context.spinner.text, 'Traversing the DOM'); -}); - -test('Event traverse::end should write a message in the spinner', async (t) => { - const sandbox = sinon.createSandbox(); - - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] + return []; }); - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('traverse::end', { resource: 'http://localhost/' }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); - sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); - sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); - - const analyze = loadScript(t.context); - - await analyze(actions); - - t.is(t.context.spinner.text, 'Traversing finished'); -}); - -test('Event scan::end should write a message in the spinner', async (t) => { - const sandbox = sinon.createSandbox(); - - class FakeFormatter implements IFormatter { - public static called: boolean = false; - public constructor() { } - - public format(problems: Problem[]) { - FakeFormatter.called = true; - console.log(problems); - } - } - - sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ - connector: FakeConnector, - formatters: [FakeFormatter], - hints: [], - incompatible: [], - missing: [], - parsers: [] - }); - - const engineObj = new t.context.engineContainer.Engine(); - - sandbox.stub(engineObj, 'formatters').get(() => { - return [new FakeFormatter()]; - }); - sandbox.stub(engineObj, 'executeOn').callsFake(async () => { - await engineObj.emitAsync('scan::end', { resource: 'http://localhost/' }); - }); - sandbox.stub(t.context.engineContainer, 'Engine').returns(engineObj); sandbox.stub(t.context.config.Configuration, 'getFilenameForDirectory').returns('/config/path'); sandbox.stub(t.context.config.Configuration, 'loadConfigFile').returns({}); - sandbox.stub(t.context.config.Configuration, 'fromConfig').returns({}); - sandbox.stub(t.context.config.Configuration, 'validateHintsConfig').returns(validateHintsConfigResult); const analyze = loadScript(t.context); await analyze(actions); - t.is(t.context.spinner.text, 'Finishing...'); + t.is(t.context.spinner.text, 'Downloading http://localhost/'); }); test('If no sites are defined, it should return false', async (t) => { diff --git a/packages/hint/tests/lib/config.ts b/packages/hint/tests/lib/config.ts index 5ca19177a95..1d173a57911 100644 --- a/packages/hint/tests/lib/config.ts +++ b/packages/hint/tests/lib/config.ts @@ -7,7 +7,7 @@ import * as proxyquire from 'proxyquire'; import { fs } from '@hint/utils'; import { HintScope } from '../../src/lib/enums/hint-scope'; -import { ConnectorConfig, CLIOptions, IHint, HintsConfigObject, HintMetadata, UserConfig } from '../../src/lib/types'; +import { IHint, HintMetadata } from '../../src/lib/types'; type ResourceLoader = { loadConfiguration: () => string; @@ -84,28 +84,25 @@ test('if there is configuration file, it should return the path to the file', (t t.true(result.includes('.hintrc')); }); -test('if.hintConfig.fromFilePath is called with a non valid file extension, it should return an exception', (t) => { +test('if Configuration.loadConfigFile is called with a non valid file extension, it should return null', (t) => { const config = loadScript(t.context); - const error = t.throws(() => { - config.Configuration.fromFilePath(path.join(__dirname, './fixtures/notvalid/notvalid.css'), null); - }); - t.is(error.message, `Couldn't find a configuration file`); + const result = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/notvalid/notvalid.css')); + + t.is(result, null); }); -test(`if package.json doesn't have a hint configuration, it should return an exception`, (t) => { +test(`if package.json doesn't have a hint configuration, it should return null`, (t) => { const config = loadScript(t.context); - const error = t.throws(() => { - config.Configuration.fromFilePath(path.join(__dirname, './fixtures/notvalid/package.json'), null); - }); + const result = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/notvalid/package.json')); - t.is(error.message, `Couldn't find a configuration file`); + t.is(result, null); }); test(`if package.json is an invalid JSON, it should return an exception`, (t) => { const config = loadScript(t.context); const error = t.throws(() => { - config.Configuration.fromFilePath(path.join(__dirname, './fixtures/exception/package.json'), null); + config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/exception/package.json'), null); }); t.true(error.message.startsWith('Cannot read config file: ')); @@ -130,9 +127,10 @@ test(`if the config file doesn't have an extension, it should be parsed as JSON sandbox.stub(resourceLoader, 'loadHint').returns(FakeDisallowedHint); const config = loadScript(t.context); - const configuration = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/hintrc'), { watch: false } as CLIOptions); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/hintrc')); + const configuration = config.Configuration.fromConfig(userConfig); - t.is((configuration.connector as ConnectorConfig).name, 'chrome'); + t.is(configuration.connector.name, 'chrome'); t.is(configuration.hints['disallowed-headers'], 'warning'); }); @@ -155,9 +153,9 @@ test(`if the config file is JavaScript, it should return the configuration part` sandbox.stub(resourceLoader, 'loadHint').returns(FakeDisallowedHint); const config = loadScript(t.context); - const configuration = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/hintrc.js'), { watch: true } as CLIOptions); + const configuration = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/hintrc.js')); - t.is((configuration.connector as ConnectorConfig).name, 'chrome'); + t.is(configuration.connector.name, 'chrome'); t.is(configuration.hints['disallowed-headers'], 'warning'); }); @@ -180,9 +178,10 @@ test(`if package.json contains a valid hint configuration, it should return it`, sandbox.stub(resourceLoader, 'loadHint').returns(FakeDisallowedHint); const config = loadScript(t.context); - const configuration = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/package.json'), { watch: false } as CLIOptions); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/package.json')); + const configuration = config.Configuration.fromConfig(userConfig); - t.is((configuration.connector as ConnectorConfig).name, 'chrome'); + t.is(configuration.connector.name, 'chrome'); t.is(configuration.hints['disallowed-headers'][0], 'warning'); }); @@ -205,9 +204,10 @@ test(`if package.json contains the property "ignoredUrls", it shold return them` sandbox.stub(resourceLoader, 'loadHint').returns(FakeDisallowedHint); const config = loadScript(t.context); - const configuration = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/package.json'), { watch: false } as CLIOptions); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/package.json')); + const configuration = config.Configuration.fromConfig(userConfig); - t.is((configuration.connector as ConnectorConfig).name, 'chrome'); + t.is(configuration.connector.name, 'chrome'); t.is(configuration.ignoredUrls.size, 2); t.is(configuration.ignoredUrls.get('all').length, 2); t.is(configuration.ignoredUrls.get('disallowed-headers').length, 1); @@ -236,15 +236,15 @@ test(`if the configuration file contains an extends property, it should combine sandbox.stub(resourceLoader, 'loadConfiguration').returns(exts); const config = loadScript(t.context); - const configuration: UserConfig = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/withextends.json'), { watch: false } as CLIOptions); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/withextends.json')); + const configuration = config.Configuration.fromConfig(userConfig, { watch: false }); - t.is((configuration.connector as ConnectorConfig).name, 'chrome'); - t.is((configuration.hints as HintsConfigObject)['disallowed-headers'], 'error'); + t.is(configuration.connector.name, 'chrome'); + t.is(configuration.hints['disallowed-headers'], 'error'); t.is(configuration.formatters && configuration.formatters.length, 1); t.is(configuration.parsers && configuration.parsers.length, 2); }); - test(`if the configuration file contains an invalid extends property, returns an exception`, async (t) => { const { resourceLoader, sandbox } = t.context; const exts = JSON.parse(await readFileAsync(path.join(__dirname, './fixtures/notvalid/package.json'))).hintConfig; @@ -252,12 +252,12 @@ test(`if the configuration file contains an invalid extends property, returns an sandbox.stub(resourceLoader, 'loadConfiguration').returns(exts); const config = loadScript(t.context); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/withextends.json')); const err = t.throws(() => { - config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/withextends.json'), { watch: false } as CLIOptions); + config.Configuration.fromConfig(userConfig, { watch: false }); }); t.is(err.message, 'Configuration package "basics" is not valid'); - }); test(`if a Hint has an invalid configuration, it should tell which ones are invalid`, (t) => { @@ -290,22 +290,23 @@ test(`if a Hint has an invalid configuration, it should tell which ones are inva sandbox.stub(resourceLoader, 'loadHint').returns(FakeDisallowedHint); const config = loadScript(t.context); - const configuration = config.Configuration.fromFilePath(path.join(__dirname, './fixtures/valid/package.json'), { watch: false } as CLIOptions); + const userConfig = config.Configuration.loadConfigFile(path.join(__dirname, './fixtures/valid/package.json')); + const configuration = config.Configuration.fromConfig(userConfig); const { invalid } = config.Configuration.validateHintsConfig(configuration); t.is(invalid.length, 1); }); -test('If formatter is specified as CLI argument, fromConfig method will use that to build.hintConfig', (t) => { +test('If formatter is specified in the options, fromConfig method will use that to build the configuration', (t) => { const userConfig = { connector: { name: 'chrome' }, formatters: ['summary', 'excel'], hints: { 'apple-touch-icons': 'warning' } - } as UserConfig; - const cliOptions = { _: ['https://example.com'], formatters: 'database' } as CLIOptions; + }; + const options = { formatters: ['database'] }; const config = loadScript(t.context); - const result = config.Configuration.fromConfig(userConfig, cliOptions); + const result = config.Configuration.fromConfig(userConfig, options); t.is(result.formatters.length, 1); t.is(result.formatters[0], 'database'); @@ -313,32 +314,31 @@ test('If formatter is specified as CLI argument, fromConfig method will use that t.is(result.connector.name, 'chrome'); }); -test('If formatter is not specified as CLI argument, fromConfig method will use the formatter specified in the userConfig object as it is to build.hintConfig', (t) => { +test('If formatter is not specified in the options, fromConfig method will use the formatter specified in the userConfig object as it is to build the configuration', (t) => { const userConfig = { connector: { name: 'chrome' }, formatters: ['summary', 'excel'], hints: { 'apple-touch-icons': 'warning' } - } as UserConfig; - const cliOptions = { _: ['https://example.com'] } as CLIOptions; + }; const config = loadScript(t.context); - const result = config.Configuration.fromConfig(userConfig, cliOptions); + const result = config.Configuration.fromConfig(userConfig); t.is(result.formatters.length, 2); t.is(result.formatters[0], 'summary'); t.is(result.formatters[1], 'excel'); }); -test('If hints option is specified as CLI argument, fromConfig method will use that to build.hintConfig', (t) => { +test('If hints option is specified in the options, fromConfig method will use that to build the configuration', (t) => { const userConfig = { connector: { name: 'chrome' }, formatters: ['summary'], hints: { 'apple-touch-icons': 'warning' } - } as UserConfig; - const cliOptions = { _: ['https://example.com'], hints: 'html-checker,content-type' } as CLIOptions; + }; + const options = { hints: ['html-checker', 'content-type'] }; const config = loadScript(t.context); - const result = config.Configuration.fromConfig(userConfig, cliOptions); + const result = config.Configuration.fromConfig(userConfig, options); t.is(result.hints.hasOwnProperty('html-checker'), true); t.is(result.hints.hasOwnProperty('content-type'), true); @@ -346,30 +346,29 @@ test('If hints option is specified as CLI argument, fromConfig method will use t t.is(result.formatters[0], 'summary'); }); -test('If hints option is not specified as CLI argument, fromConfig method will use the hints specified in the userConfig object as it is to build.hintConfig', (t) => { +test('If hints option is not specified in the options, fromConfig method will use the hints specified in the userConfig object as it is to build.hintConfig', (t) => { const userConfig = { connector: { name: 'chrome' }, formatters: ['summary'], hints: { 'apple-touch-icons': 'warning' } - } as UserConfig; - const cliOptions = { _: ['https://example.com'] } as CLIOptions; + }; const config = loadScript(t.context); - const result = config.Configuration.fromConfig(userConfig, cliOptions); + const result = config.Configuration.fromConfig(userConfig); t.is(result.hints.hasOwnProperty('apple-touch-icons'), true); }); -test('If both hints and formatters options are specified as CLI arguments, fromConfig method will use that to build.hintConfig', (t) => { +test('If both hints and formatters options are specified in the options, fromConfig method will use that to build the configuration', (t) => { const userConfig = { connector: { name: 'chrome' }, formatters: ['summary', 'excel'], hints: { 'apple-touch-icons': 'warning' } - } as UserConfig; - const cliOptions = { _: ['https://example.com'], formatters: 'database', hints: 'html-checker' } as CLIOptions; + }; + const options = { formatters: ['database'], hints: ['html-checker'] }; const config = loadScript(t.context); - const result = config.Configuration.fromConfig(userConfig, cliOptions); + const result = config.Configuration.fromConfig(userConfig, options); // verify formatters t.is(result.formatters.length, 1); diff --git a/packages/hint/tests/lib/types/analyzer.ts b/packages/hint/tests/lib/types/analyzer.ts new file mode 100644 index 00000000000..a706ee42e45 --- /dev/null +++ b/packages/hint/tests/lib/types/analyzer.ts @@ -0,0 +1,489 @@ +import { URL } from 'url'; + +import anyTest, { TestInterface } from 'ava'; +import * as proxyquire from 'proxyquire'; +import * as sinon from 'sinon'; + +import { + AnalyzerError, + ConnectorConfig, + HintResources, + IFetchOptions, + Problem, + IFormatter +} from '../../../src/lib/types'; +import { AnalyzerErrorStatus } from '../../../src/lib/enums/error-status'; + +type Logger = { + warn: () => void; +}; + +type Configuration = { + connector: ConnectorConfig; + fromConfig: () => Configuration; + validateHintsConfig: () => { invalid: string[]; valid: string[] }; +}; + +type ResourceLoader = { + loadResources: () => HintResources; +}; + +type AnalyzerContext = { + configuration: { Configuration: Configuration }; + logger: Logger; + resourceLoader: ResourceLoader; + sandbox: sinon.SinonSandbox; +}; + +const test = anyTest as TestInterface; + +const loadScript = (context: AnalyzerContext) => { + const engine = { + close() { }, + executeOn(url: URL, options?: IFetchOptions): Promise { + return Promise.resolve([]); + }, + on(eventName: string, listener: () => {}) { }, + prependAny() { } + }; + + const engineWrapper = { + Engine: function Engine() { + return engine; + } + }; + + const script = proxyquire('../../../src/lib/types/analyzer', { + '../config': context.configuration, + '../engine': engineWrapper, + '../utils/resource-loader': context.resourceLoader, + '@hint/utils': { logger: context.logger } + }); + + return { Analyzer: script.Analyzer, engine }; +}; + +test.beforeEach((t) => { + t.context.configuration = { + Configuration: { + connector: { name: 'chrome' }, + fromConfig: () => { + return {} as any; + }, + validateHintsConfig: () => { + return {} as any; + } + } + }; + + t.context.logger = { warn() { } }; + + t.context.resourceLoader = { + loadResources: () => { + return {} as any; + } + }; + + t.context.sandbox = sinon.createSandbox(); +}); + +test(`If userConfig not defined, it should return an error with the status 'ConfigurationError'`, (t) => { + const { Analyzer } = loadScript(t.context); + + const error = t.throws(() => { + Analyzer.create(); + }); + + t.is(error.status, AnalyzerErrorStatus.ConfigurationError); +}); + +test(`If there is an error loading the configuration, it should return an error with the status 'ConfigurationError'`, (t) => { + const { Analyzer } = loadScript(t.context); + const sandbox = t.context.sandbox; + + const fromConfigStub = sandbox.stub(t.context.configuration.Configuration, 'fromConfig').throws(new Error()); + + const error = t.throws(() => { + Analyzer.create({}); + }); + + t.true(fromConfigStub.calledOnce); + t.is(error.status, AnalyzerErrorStatus.ConfigurationError); +}); + +test(`If there is any missing or incompatible resource, it should return an error with the status 'ResourceError'`, (t) => { + const { Analyzer } = loadScript(t.context); + const sandbox = t.context.sandbox; + + const fromConfigStub = sandbox.stub(t.context.configuration.Configuration, 'fromConfig').returns(t.context.configuration.Configuration); + const resourceLoaderStub = sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ + connector: null, + formatters: [], + hints: [], + incompatible: ['hint1'], + missing: ['hint2'], + parsers: [] + }); + + const error = t.throws(() => { + Analyzer.create({}); + }); + + t.true(resourceLoaderStub.calledOnce); + t.true(fromConfigStub.calledOnce); + t.is(error.status, AnalyzerErrorStatus.ResourceError); +}); + +test(`If there is any invalid hint, it should return an error with the status 'HintError'`, (t) => { + const { Analyzer } = loadScript(t.context); + const sandbox = t.context.sandbox; + + const fromConfigStub = sandbox.stub(t.context.configuration.Configuration, 'fromConfig').returns(t.context.configuration.Configuration); + const resourceLoaderStub = sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ + connector: null, + formatters: [], + hints: [], + incompatible: [], + missing: [], + parsers: [] + }); + const validateHintsConfigStub = sandbox.stub(t.context.configuration.Configuration, 'validateHintsConfig').returns({ + invalid: ['hint1', 'hint2'], + valid: [] + }); + + const error = t.throws(() => { + Analyzer.create({}); + }); + + t.true(validateHintsConfigStub.calledOnce); + t.true(resourceLoaderStub.calledOnce); + t.true(fromConfigStub.calledOnce); + t.is(error.status, AnalyzerErrorStatus.HintError); +}); + +test('If everything is valid, it will create an instance of the class Analyzer', (t) => { + const { Analyzer } = loadScript(t.context); + const sandbox = t.context.sandbox; + + const fromConfigStub = sandbox.stub(t.context.configuration.Configuration, 'fromConfig').returns(t.context.configuration.Configuration); + const resourceLoaderStub = sandbox.stub(t.context.resourceLoader, 'loadResources').returns({ + connector: null, + formatters: [], + hints: [], + incompatible: [], + missing: [], + parsers: [] + }); + const validateHintsConfigStub = sandbox.stub(t.context.configuration.Configuration, 'validateHintsConfig').returns({ + invalid: [], + valid: [] + }); + + Analyzer.create({}); + + t.true(validateHintsConfigStub.calledOnce); + t.true(resourceLoaderStub.calledOnce); + t.true(fromConfigStub.calledOnce); +}); + +test('If the target is an string, it will analyze the url', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + /* + * Analyzer constructor is private, but for testing it + * is easy if we call the constructor direcly. + */ + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze('http://example.com/'); + + t.true(engineExecuteOnStub.calledOnce); + t.true(engineCloseStub.calledOnce); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); +}); + +test('If the target is an URL, it will analyze it', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze(new URL('http://example.com/')); + + t.true(engineExecuteOnStub.calledOnce); + t.true(engineCloseStub.calledOnce); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); +}); + +test('If the target is a Target with a string url, it will analyze it', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze({ url: 'http://example.com/' }); + + t.true(engineExecuteOnStub.calledOnce); + t.true(engineCloseStub.calledOnce); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); +}); + +test('If the target is a Target with a URL, it will analyze it', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze({ url: new URL('http://example.com/') }); + + t.true(engineExecuteOnStub.calledOnce); + t.true(engineCloseStub.calledOnce); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); +}); + +test('If the target is an Array of strings, it will analyze all of them', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze(['http://example.com/', 'http://example2.com/', 'http://example3.com/']); + + t.true(engineExecuteOnStub.calledThrice); + t.true(engineCloseStub.calledThrice); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); + t.is(engineExecuteOnStub.args[1][0].href, 'http://example2.com/'); + t.is(engineExecuteOnStub.args[2][0].href, 'http://example3.com/'); +}); + +test('If the target is an Array of URLs, it will analyze all of them', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze([new URL('http://example.com/'), new URL('http://example2.com/'), new URL('http://example3.com/')]); + + t.true(engineExecuteOnStub.calledThrice); + t.true(engineCloseStub.calledThrice); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); + t.is(engineExecuteOnStub.args[1][0].href, 'http://example2.com/'); + t.is(engineExecuteOnStub.args[2][0].href, 'http://example3.com/'); +}); + +test('If the target is an Array of Targets, it will analyze all of them', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze([{ url: new URL('http://example.com/') }, { url: 'http://example2.com/' }, { url: new URL('http://example3.com/') }]); + + t.true(engineExecuteOnStub.calledThrice); + t.true(engineCloseStub.calledThrice); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); + t.is(engineExecuteOnStub.args[1][0].href, 'http://example2.com/'); + t.is(engineExecuteOnStub.args[2][0].href, 'http://example3.com/'); +}); + +test('If the target is an Array of strings, URLs and Targets, it will analyze all of them', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze([{ url: new URL('http://example.com/') }, 'http://example2.com/', new URL('http://example3.com/')]); + + t.true(engineExecuteOnStub.calledThrice); + t.true(engineCloseStub.calledThrice); + t.is(engineExecuteOnStub.args[0][0].href, 'http://example.com/'); + t.is(engineExecuteOnStub.args[1][0].href, 'http://example2.com/'); + t.is(engineExecuteOnStub.args[2][0].href, 'http://example3.com/'); +}); + +test('If options includes an updateCallback, it will call to engine.prependAny', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + sandbox.stub(engine, 'executeOn').resolves([]); + sandbox.stub(engine, 'close').resolves(); + const enginePrependAnySpy = sandbox.spy(engine, 'prependAny'); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze('http://example.com/', { updateCallback: () => { } }); + + t.true(enginePrependAnySpy.calledOnce); +}); + +test(`If the option watch was configured in the connector, the analyzer will subscribe to the event 'print' in the engine`, async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + sandbox.stub(engine, 'executeOn').resolves([]); + sandbox.stub(engine, 'close').resolves(); + const engineOnSpy = sandbox.spy(engine, 'on'); + const webhint = new Analyzer({ connector: { options: { watch: true } } }, {}, []); + + await webhint.analyze('http://example.com/'); + + t.true(engineOnSpy.calledOnce); + t.is(engineOnSpy.args[0][0], 'print'); +}); + +test(`If target.content is defined and the connector is not the local connector, it should return an exception`, async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const engineExecuteOnSpy = sandbox.spy(engine, 'executeOn'); + const engineCloseSpy = sandbox.spy(engine, 'close'); + const webhint = new Analyzer({ connector: { name: 'notLocal' } }, {}, []); + + const error: AnalyzerError = await t.throwsAsync(async () => { + await webhint.analyze({ content: '', url: 'http://example.com/' }); + }); + + t.false(engineCloseSpy.called); + t.false(engineExecuteOnSpy.called); + + t.is(error.status, AnalyzerErrorStatus.AnalyzeError); +}); + +test('If options includes a targetStartCallback, it will be call before engine.executeOn', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const options = { targetStartCallback() { } }; + + sandbox.stub(engine, 'close').resolves(); + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const targetStartCallbackStub = sandbox.stub(options, 'targetStartCallback').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze('http://example.com/', options); + + t.true(targetStartCallbackStub.calledOnce); + t.true(targetStartCallbackStub.calledBefore(engineExecuteOnStub)); +}); + +test('If options includes a targetEndCallback, it will be call after engine.executeOn', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + const options = { targetEndCallback() { } }; + + sandbox.stub(engine, 'close').resolves(); + const engineExecuteOnStub = sandbox.stub(engine, 'executeOn').resolves([]); + const targetEndCallbackStub = sandbox.stub(options, 'targetEndCallback').resolves(); + const webhint = new Analyzer({}, {}, []); + + await webhint.analyze('http://example.com/', options); + + t.true(targetEndCallbackStub.calledOnce); + t.true(targetEndCallbackStub.calledAfter(engineExecuteOnStub)); +}); + +test('If engine.executeOn throws an exception, it should close the engine', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + sandbox.stub(engine, 'executeOn').throws(new Error()); + + const engineCloseStub = sandbox.stub(engine, 'close').resolves(); + const webhint = new Analyzer({}, {}, []); + + try { + await webhint.analyze('http://example.com/'); + } catch { + // do nothing. + } + + t.true(engineCloseStub.calledOnce); +}); + +test('If the option watch was configured in the connector, and the connector is not the local connector, it should print a warning message.', async (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + sandbox.stub(engine, 'executeOn').resolves([]); + sandbox.stub(engine, 'close').resolves(); + + const loggerWarn = sandbox.spy(t.context.logger, 'warn'); + const webhint = new Analyzer({ connector: { options: { watch: true } } }, {}, []); + + await webhint.analyze('http://example.com/'); + + t.true(loggerWarn.calledOnce); +}); + +test('format should call to all the formatters', async (t) => { + const sandbox = t.context.sandbox; + + class FakeFormatter implements IFormatter { + public constructor() { } + + public format(problems: Problem[]) { + } + } + + const formatter = new FakeFormatter(); + + const { Analyzer, engine } = loadScript(t.context); + + const formatterFormatStub = sandbox.stub(formatter, 'format').resolves(); + + sandbox.stub(engine, 'executeOn').resolves([]); + sandbox.stub(engine, 'close').resolves(); + + const webhint = new Analyzer({}, {}, [formatter]); + + await webhint.format([]); + + t.true(formatterFormatStub.calledOnce); +}); + +test('resources should returns all the resources', (t) => { + const sandbox = t.context.sandbox; + + const { Analyzer, engine } = loadScript(t.context); + + sandbox.stub(engine, 'executeOn').resolves([]); + sandbox.stub(engine, 'close').resolves(); + + const resources = {}; + const webhint = new Analyzer({}, resources, []); + + t.is(webhint.resources, resources); +}); diff --git a/packages/hint/tests/lib/types/html.ts b/packages/hint/tests/lib/types/html.ts index b367aad7608..bc06b579f23 100644 --- a/packages/hint/tests/lib/types/html.ts +++ b/packages/hint/tests/lib/types/html.ts @@ -3,7 +3,7 @@ import * as path from 'path'; import anyTest, { TestInterface } from 'ava'; -import createHtmlDocument from '../../../src/lib/utils/dom/create-html-document'; +import { createHTMLDocument } from '../../../src/lib/utils/dom/create-html-document'; import { HTMLDocument } from '../../../src/lib/types'; type HTMLContext = { @@ -16,7 +16,7 @@ const html = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'test-html.h const serializedHTML = fs.readFileSync(path.join(__dirname, '..', 'fixtures', 'serialized-test-html.html'), 'utf-8'); // eslint-disable-line no-sync test.beforeEach((t) => { - t.context.document = createHtmlDocument(html); + t.context.document = createHTMLDocument(html); }); test('HTMLDocument.dom() should return the html node', (t) => { diff --git a/packages/hint/tests/lib/utils/dom/find-original-element.ts b/packages/hint/tests/lib/utils/dom/find-original-element.ts index 8bd06db8c56..a74fe6ccf2a 100644 --- a/packages/hint/tests/lib/utils/dom/find-original-element.ts +++ b/packages/hint/tests/lib/utils/dom/find-original-element.ts @@ -1,7 +1,7 @@ import test from 'ava'; -import createHTMLDocument from '../../../../src/lib/utils/dom/create-html-document'; -import findOriginalElement from '../../../../src/lib/utils/dom/find-original-element'; +import { createHTMLDocument } from '../../../../src/lib/utils/dom/create-html-document'; +import { findOriginalElement } from '../../../../src/lib/utils/dom/find-original-element'; const compare = (originalSource: string, snapshotSource: string) => { const originalDocument = createHTMLDocument(originalSource); diff --git a/packages/hint/tests/lib/utils/dom/get-element-by-url.ts b/packages/hint/tests/lib/utils/dom/get-element-by-url.ts index f1688c861ac..a70e36a73a0 100644 --- a/packages/hint/tests/lib/utils/dom/get-element-by-url.ts +++ b/packages/hint/tests/lib/utils/dom/get-element-by-url.ts @@ -1,7 +1,7 @@ import test from 'ava'; -import createHTMLDocument from '../../../../src/lib/utils/dom/create-html-document'; -import getElementByUrl from '../../../../src/lib/utils/dom/get-element-by-url'; +import { createHTMLDocument } from '../../../../src/lib/utils/dom/create-html-document'; +import { getElementByUrl } from '../../../../src/lib/utils/dom/get-element-by-url'; test('Find by URL match (no match)', (t) => { const dom = createHTMLDocument(` diff --git a/packages/hint/tests/lib/utils/resource-loader.ts b/packages/hint/tests/lib/utils/resource-loader.ts index 853cd53b094..bde11028140 100644 --- a/packages/hint/tests/lib/utils/resource-loader.ts +++ b/packages/hint/tests/lib/utils/resource-loader.ts @@ -181,7 +181,7 @@ test.serial('loadResource throws an error if the version is incompatible when us proxyquire('../../../src/lib/utils/resource-loader', { '../utils/packages/load-hint-package': { - default() { + loadHintPackage() { return { version: '1.1.0' }; } }, @@ -216,7 +216,7 @@ test.serial('loadResource returns the resource if versions are compatible', asyn proxyquire('../../../src/lib/utils/resource-loader', { '../utils/packages/load-hint-package': { - default() { + loadHintPackage() { return { version: '0.1.0' }; } }, diff --git a/packages/parser-html/src/parser.ts b/packages/parser-html/src/parser.ts index 5f5b9f24b47..2398b200154 100644 --- a/packages/parser-html/src/parser.ts +++ b/packages/parser-html/src/parser.ts @@ -28,7 +28,7 @@ try { import { Parser, FetchEnd } from 'hint/dist/src/lib/types'; import { Engine } from 'hint/dist/src/lib/engine'; -import createHTMLDocument from 'hint/dist/src/lib/utils/dom/create-html-document'; +import { createHTMLDocument } from 'hint/dist/src/lib/utils/dom/create-html-document'; import { HTMLEvents } from './types'; export * from './types'; diff --git a/packages/utils-debugging-protocol-common/src/debugging-protocol-connector.ts b/packages/utils-debugging-protocol-common/src/debugging-protocol-connector.ts index 22efa00d7b0..d868f9bd363 100644 --- a/packages/utils-debugging-protocol-common/src/debugging-protocol-connector.ts +++ b/packages/utils-debugging-protocol-common/src/debugging-protocol-connector.ts @@ -25,9 +25,9 @@ import { Crdp } from 'chrome-remote-debug-protocol'; import { debug as d, HttpHeaders, misc, network } from '@hint/utils'; import { getType } from 'hint/dist/src/lib/utils/content-type'; -import getElementByUrl from 'hint/dist/src/lib/utils/dom/get-element-by-url'; -import createHTMLDocument from 'hint/dist/src/lib/utils/dom/create-html-document'; -import traverse from 'hint/dist/src/lib/utils/dom/traverse'; +import { getElementByUrl } from 'hint/dist/src/lib/utils/dom/get-element-by-url'; +import { createHTMLDocument } from 'hint/dist/src/lib/utils/dom/create-html-document'; +import { traverse } from 'hint/dist/src/lib/utils/dom/traverse'; import { BrowserInfo, IConnector, diff --git a/yarn.lock b/yarn.lock index 2d9893ba296..ec1a915c2f5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -693,13 +693,6 @@ resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.3.29.tgz#7f2ad7ec55f914482fc9b1ec4bb1ae6028d46066" integrity sha1-fyrX7FX5FEgvybHsS7GuYCjUYGY= -"@types/mkdirp@^0.5.2": - version "0.5.2" - resolved "https://registry.yarnpkg.com/@types/mkdirp/-/mkdirp-0.5.2.tgz#503aacfe5cc2703d5484326b1b27efa67a339c1f" - integrity sha512-U5icWpv7YnZYGsN4/cmh3WD2onMY0aJIiTE6+51TwJCttdHvtCYmkBNOobHlXwrJRL0nkH9jH4kD+1FAdMN4Tg== - dependencies: - "@types/node" "*" - "@types/mock-require@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@types/mock-require/-/mock-require-2.0.0.tgz#57a4f0db0b4b6274f610a2d2c20beb3c842181e1"