diff --git a/package.json b/package.json index 6ca3c08..1a939d6 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "chalk": "^2.0.0", "debug": "^3.0.0", "indent-string": "^4.0.0", + "strip-ansi": "^6.0.0", "wrap-ansi": "^6.0.0", "xml": "^1.0.0", "yargs": "^15.0.0" @@ -37,7 +38,7 @@ "prettier": "^1.19.1", "tmp-promise": "^2.0.2", "ts-jest": "^25.2.1", - "typescript": "^3.7.5" + "typescript": "^3.8.3" }, "scripts": { "prettier": "prettier --single-quote --write './src/**/*.ts'", diff --git a/src/crawler.ts b/src/Crawler.ts similarity index 86% rename from src/crawler.ts rename to src/Crawler.ts index 7fdc80e..a4643c4 100644 --- a/src/crawler.ts +++ b/src/Crawler.ts @@ -1,15 +1,11 @@ import debug from 'debug'; import NativeDriver from './driver/native'; -import { toAsyncIterable } from './cli/util'; +import { isCrawlerRequest, toAsyncIterable } from './util'; const log = debug('nightcrawler:info'); const error = debug('nightcrawler:error'); -import { Driver, CrawlerRequest, CrawlerUnit } from './types'; - -type RequestIterable = - | Iterable - | AsyncIterable; +import { RequestIterable, Driver, CrawlerRequest, CrawlerUnit } from './types'; export default class Crawler { driver: Driver; @@ -39,6 +35,11 @@ export default class Crawler { const queueOne = async (): Promise => { const next = await iterator.next(); if (next.value) { + if (!isCrawlerRequest(next.value)) { + throw new Error( + `This item does not look like a crawler request: ${next.value.toString()}` + ); + } const prom = this._fetch(next.value) .then(collectToBuffer, collectToBuffer) .finally(() => pool.delete(prom)); @@ -62,11 +63,6 @@ export default class Crawler { yield buffer.pop() as CrawlerUnit; } } - - // Yield any leftover buffered results. - while (buffer.length > 0) { - yield buffer.pop() as CrawlerUnit; - } } /** diff --git a/src/__tests__/crawler.ts b/src/__tests__/crawler.ts index 3ad34d3..af2016b 100644 --- a/src/__tests__/crawler.ts +++ b/src/__tests__/crawler.ts @@ -1,4 +1,4 @@ -import Crawler from '../crawler'; +import Crawler from '../Crawler'; import DummyDriver from '../driver/dummy'; import { performance } from 'perf_hooks'; import { CrawlerRequest } from '../types'; @@ -37,6 +37,11 @@ describe('Crawler', () => { const c = new Crawler(requests, new DummyDriver()); await expect(all(c.crawl())).resolves.toHaveLength(1); }); + it('Should throw a meaningful error when an invalid iterator is given', function() { + expect(() => { + new Crawler((1 as unknown) as AsyncIterable); + }).toThrow('Unable to create an async iterator from the request iterable.'); + }); it('Should respect concurrency in crawling', async () => { /** @@ -105,4 +110,12 @@ describe('Crawler', () => { } ]); }); + + it('Should fail if any item in the iterator is not in the shape of a request', async function() { + const items = ['foo']; + const crawler = new Crawler(items as Iterable); + await expect(all(crawler.crawl(1))).rejects.toThrow( + 'This item does not look like a crawler request: foo' + ); + }); }); diff --git a/src/cli/commands/__tests__/crawl.ts b/src/cli/commands/__tests__/crawl.ts index 9d72cfa..bfb6623 100644 --- a/src/cli/commands/__tests__/crawl.ts +++ b/src/cli/commands/__tests__/crawl.ts @@ -1,20 +1,19 @@ import { CrawlCommandArgs, handler } from '../crawl'; import stream from 'stream'; -import Crawler from '../../../crawler'; -import DummyDriver from '../../../driver/dummy'; import { FailedAnalysisError } from '../../errors'; import yargs from 'yargs'; import * as crawlModule from '../crawl'; import TestContext from '../../../testing/TestContext'; -import { CrawlerRequest } from '../../../types'; import ConsoleReporter from '../../formatters/ConsoleReporter'; import JUnitReporter from '../../formatters/JUnitReporter'; import JSONReporter from '../../formatters/JSONReporter'; -import { mocked } from 'ts-jest'; +import { mocked } from 'ts-jest/utils'; +import { makeResult } from '../../util'; +jest.mock('../../../testing/TestContext'); jest.mock('../../formatters/JUnitReporter'); jest.mock('../../formatters/ConsoleReporter'); jest.mock('../../formatters/JSONReporter'); @@ -96,112 +95,114 @@ describe('Crawl Handler', function() { it('Executes the crawl', async function() { // eslint-disable-next-line - const crawl = jest.fn(async function*() {}); - const crawler = new Crawler([]); - const tests = new TestContext(); - crawler.crawl = crawl; - await handler({ stdout, crawler, tests, concurrency: 1 }); - expect(crawl).toHaveBeenCalledWith(1); + const context = new TestContext(''); + context.crawl = jest.fn(async function*(): ReturnType< + TestContext['crawl'] + > { + // No-op. + }); + const mockedContext = mocked(context); + await handler({ stdout, context, concurrency: 1 }); + expect(mockedContext.crawl).toHaveBeenCalledWith(1); }); it('Displays console output.', async function() { - const crawler = new Crawler( - [{ url: 'http://example.com' }], - new DummyDriver() - ); - const tests = new TestContext(); - tests.each('Testing', () => { - throw new Error('Test'); - }); - tests.all('Testing', () => { - throw new Error('Test'); - }); + const context = new TestContext(''); + context.crawl = async function*(): ReturnType { + yield [ + 'http://example.com', + makeResult({ Testing: { pass: false, message: 'Test' } }) + ]; + }; try { - await handler({ stdout, crawler, tests }); + await handler({ stdout, context }); } catch (e) { // no-op - we don't care. } expect(MockedConsoleReporter).toHaveBeenCalled(); const reporter = MockedConsoleReporter.mock.instances[0]; expect(reporter.start).toHaveBeenCalledTimes(1); - expect(reporter.report).toHaveBeenCalledTimes(2); + expect(reporter.report).toHaveBeenCalledTimes(1); expect(reporter.stop).toHaveBeenCalledTimes(1); }); + it('Does not display console output if passed --silent', async function() { + const context = new TestContext(''); + context.crawl = async function*(): ReturnType { + yield [ + 'http://example.com', + makeResult({ Testing: { pass: false, message: 'Test' } }) + ]; + }; + try { + await handler({ stdout, context, silent: true }); + } catch (e) { + // no-op - we don't care. + } + expect(MockedConsoleReporter).not.toHaveBeenCalled(); + }); + it('Writes a JUnit report', async function() { - const crawler = new Crawler( - [{ url: 'http://example.com' }], - new DummyDriver() - ); - const tests = new TestContext(); - tests.each('Testing', () => { - throw new Error('Test'); - }); - tests.all('Testing', () => { - throw new Error('Test'); - }); + const context = new TestContext(''); + context.crawl = async function*(): ReturnType { + yield [ + 'http://example.com', + makeResult({ Testing: { pass: false, message: 'Test' } }) + ]; + }; try { - await handler({ stdout, crawler, tests, junit: 'test' }); + await handler({ stdout, context, junit: 'test.xml' }); } catch (e) { // no-op - we don't care. } - expect(MockedJUnitReporter).toHaveBeenCalled(); + expect(MockedJUnitReporter).toHaveBeenCalledWith('test.xml'); const reporter = MockedJUnitReporter.mock.instances[0]; expect(reporter.start).toHaveBeenCalledTimes(1); - expect(reporter.report).toHaveBeenCalledTimes(2); + expect(reporter.report).toHaveBeenCalledTimes(1); expect(reporter.stop).toHaveBeenCalledTimes(1); }); it('Writes a JSON report', async function() { - const crawler = new Crawler( - [{ url: 'http://example.com' }], - new DummyDriver() - ); - const tests = new TestContext(); - tests.each('Testing', () => { - throw new Error('Test'); - }); - tests.all('Testing', () => { - throw new Error('Test'); - }); + const context = new TestContext(''); + context.crawl = async function*(): ReturnType { + yield [ + 'http://example.com', + makeResult({ Testing: { pass: false, message: 'Test' } }) + ]; + }; try { - await handler({ stdout, crawler, tests, json: 'test' }); + await handler({ stdout, context, json: 'test.json' }); } catch (e) { // no-op - we don't care. } - expect(MockedJSONReporter).toHaveBeenCalled(); + expect(MockedJSONReporter).toHaveBeenCalledWith('test.json'); const reporter = MockedJSONReporter.mock.instances[0]; expect(reporter.start).toHaveBeenCalledTimes(1); - expect(reporter.report).toHaveBeenCalledTimes(2); + expect(reporter.report).toHaveBeenCalledTimes(1); expect(reporter.stop).toHaveBeenCalledTimes(1); }); it('Throws an error if the analysis contains failures', async function() { - const crawler = new Crawler( - [{ url: 'https://example.com' }], - new DummyDriver() - ); - const tests = new TestContext(); - tests.each('testing', () => { - throw new Error('test'); - }); - await expect(handler({ stdout, crawler, tests })).rejects.toBeInstanceOf( + const context = new TestContext(''); + context.crawl = async function*(): ReturnType { + yield [ + 'http://example.com', + makeResult({ Testing: { pass: false, message: 'Test' } }) + ]; + }; + await expect(handler({ stdout, context })).rejects.toBeInstanceOf( FailedAnalysisError ); }); it('Should stop the crawl if setup fails', async function() { - const crawler = new Crawler( - // eslint-disable-next-line require-yield - (async function*(): AsyncIterable { - throw new Error('Oh no!'); - })(), - new DummyDriver() - ); + const context = new TestContext(''); + context.crawl = (): ReturnType => { + throw new Error('Oh no!'); + }; const p = handler({ stdout, - crawler, - tests: new TestContext() + context }); return expect(p).rejects.toThrow('Oh no!'); }); diff --git a/src/cli/commands/crawl.ts b/src/cli/commands/crawl.ts index 98b463c..3627416 100644 --- a/src/cli/commands/crawl.ts +++ b/src/cli/commands/crawl.ts @@ -62,30 +62,20 @@ function getReporters(argv: CrawlCommandArgs): Reporter[] { return reporters; } export const handler = async function(argv: CrawlCommandArgs): Promise { - const { crawler, tests, concurrency = 3 } = argv; + const { context, concurrency = 3 } = argv; + let hasAnyFailure = false; const reporters: Reporter[] = getReporters(argv); await Promise.all(reporters.map(reporter => reporter.start())); - let hasError = false; - const allUnits = []; - for await (const unit of crawler.crawl(concurrency)) { - const result = tests.testUnit(unit); - reporters.forEach(r => r.report(unit.request.url, result)); - hasError = hasError || hasFailure(result); - allUnits.push(unit); + for await (const [url, result] of context.crawl(concurrency)) { + reporters.forEach(r => r.report(url, result)); + hasAnyFailure = hasAnyFailure || hasFailure(result); } - const allResults = tests.testUnits(allUnits); - await Promise.all( - reporters.map(reporter => { - if (allResults.size > 0) { - reporter.report('All Requests', allResults); - } - return reporter.stop(); - }) - ); - if (hasFailure(allResults) || hasError) { + await Promise.all(reporters.map(r => r.stop())); + + if (hasAnyFailure) { throw new FailedAnalysisError('Testing reported an error.'); } }; diff --git a/src/cli/formatters/JSONReporter.ts b/src/cli/formatters/JSONReporter.ts index f8fd16c..2e5b32e 100644 --- a/src/cli/formatters/JSONReporter.ts +++ b/src/cli/formatters/JSONReporter.ts @@ -2,13 +2,24 @@ import Reporter from './Reporter'; import { TestResult, TestResultMap } from '../../testing/TestContext'; import { writeFile } from 'fs'; import { promisify } from 'util'; +import strip from 'strip-ansi'; const writeFileP = promisify(writeFile); -function mapToObj(map: Map): { [k: string]: T } { +function stripResult(object: { + [k: string]: string | boolean; +}): { [k: string]: string | boolean } { + const ret = Object.create(null); + for (const [k, v] of Object.entries(object)) { + ret[k] = typeof v === 'string' ? strip(v) : v; + } + return ret; +} + +function mapToObj(map: Map): { [k: string]: TestResult } { const obj = Object.create(null); for (const [k, v] of map) { - obj[k] = v; + obj[strip(k)] = stripResult(v); } return obj; } @@ -24,7 +35,7 @@ export default class JSONReporter implements Reporter { // no-op. } report(url: string, result: TestResultMap): void { - this.collected.push({ url, result: mapToObj(result) }); + this.collected.push({ url, result: mapToObj(result) }); } async stop(): Promise { await writeFileP(this.path, JSON.stringify(this.collected, null, 4)); diff --git a/src/cli/formatters/JUnitReporter.ts b/src/cli/formatters/JUnitReporter.ts index c88a1f9..c7be4a8 100644 --- a/src/cli/formatters/JUnitReporter.ts +++ b/src/cli/formatters/JUnitReporter.ts @@ -3,6 +3,7 @@ import { TestResultMap } from '../../testing/TestContext'; import Reporter from './Reporter'; import { writeFile } from 'fs'; import { promisify } from 'util'; +import strip from 'strip-ansi'; const writeFileP = promisify(writeFile); @@ -99,7 +100,7 @@ function formatResultSet(name: string, results: TestResultMap): XMLElement { const thisCase = new TestCase(url); if (!result.pass) { thisCase.failure = true; - thisCase.output = result.message; + thisCase.output = strip(result.message); } suite.addCase(thisCase); }); diff --git a/src/cli/formatters/__tests__/ConsoleReporter.ts b/src/cli/formatters/__tests__/ConsoleReporter.ts index 2aa26d6..663497b 100644 --- a/src/cli/formatters/__tests__/ConsoleReporter.ts +++ b/src/cli/formatters/__tests__/ConsoleReporter.ts @@ -1,19 +1,15 @@ -import { TestResultMap, TestResult } from '../../../testing/TestContext'; import { PassThrough } from 'stream'; import ConsoleReporter from '../ConsoleReporter'; - -function r(obj: { [k: string]: TestResult }): TestResultMap { - return new Map(Object.entries(obj)); -} +import { makeResult } from '../../util'; describe('Console Formatter', function() { - const pass = r({ + const pass = makeResult({ ok: { pass: true } }); - const fail = r({ + const fail = makeResult({ notok: { pass: false, message: 'There was an error.' } }); - const mix = r({ + const mix = makeResult({ ok: { pass: true }, notok: { pass: false, message: 'something failed' } }); @@ -34,6 +30,22 @@ describe('Console Formatter', function() { reporter = new ConsoleReporter(stdout); }); + it('Should output start message', async function() { + await reporter.start(); + expect(stdout.read().toString()).toMatchInlineSnapshot(` + "Crawling... + " + `); + }); + + it('Should output completion message', async function() { + await reporter.stop(); + expect(stdout.read().toString()).toMatchInlineSnapshot(` +"Crawling complete. +" +`); + }); + it('Should output successful results', function() { reporter.report('http://example.com', pass); expect(stdout.read().toString()).toMatchInlineSnapshot(` diff --git a/src/cli/formatters/__tests__/JSONReporter.ts b/src/cli/formatters/__tests__/JSONReporter.ts index 58209a4..02cb98b 100644 --- a/src/cli/formatters/__tests__/JSONReporter.ts +++ b/src/cli/formatters/__tests__/JSONReporter.ts @@ -1,23 +1,26 @@ import { file } from 'tmp-promise'; import { promises as fs } from 'fs'; -import { TestResultMap, TestResult } from '../../../testing/TestContext'; import JSONReporter from '../JSONReporter'; - -function r(obj: { [k: string]: TestResult }): TestResultMap { - return new Map(Object.entries(obj)); -} +import { makeResult } from '../../util'; describe('JSON Reporter', function() { - const pass = r({ + const pass = makeResult({ pass: { pass: true } }); - const fail = r({ + const fail = makeResult({ fail: { pass: false, message: 'There was an error.' } }); - const mix = r({ + const mix = makeResult({ pass: { pass: true }, fail: { pass: false, message: 'there was an error.' } }); + const ansi = makeResult({ + ansi: { + pass: false, + message: + '\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m)' + } + }); it('Should write a json file containing results', async function() { const { path, cleanup } = await file(); @@ -29,4 +32,14 @@ describe('JSON Reporter', function() { await expect(fs.readFile(path, 'utf8')).resolves.toMatchSnapshot(); await cleanup(); }); + + it('Should strip ansi codes from output', async function() { + const { path, cleanup } = await file(); + const reporter = new JSONReporter(path); + reporter.report('ansi', ansi); + await reporter.stop(); + const contents = JSON.parse(await fs.readFile(path, 'utf8')); + expect(contents[0].result.ansi.message).toBe('expect(received)'); + await cleanup(); + }); }); diff --git a/src/cli/formatters/__tests__/JUnitReporter.ts b/src/cli/formatters/__tests__/JUnitReporter.ts index 6aaa6bc..3be7c2e 100644 --- a/src/cli/formatters/__tests__/JUnitReporter.ts +++ b/src/cli/formatters/__tests__/JUnitReporter.ts @@ -1,23 +1,26 @@ import JUnitReporter from '../JUnitReporter'; import { file } from 'tmp-promise'; import { promises as fs } from 'fs'; -import { TestResultMap, TestResult } from '../../../testing/TestContext'; - -function r(obj: { [k: string]: TestResult }): TestResultMap { - return new Map(Object.entries(obj)); -} +import { makeResult } from '../../util'; describe('JUnit Reporter', function() { - const pass = r({ + const pass = makeResult({ pass: { pass: true } }); - const fail = r({ + const fail = makeResult({ fail: { pass: false, message: 'There was an error.' } }); - const mix = r({ + const mix = makeResult({ pass: { pass: true }, fail: { pass: false, message: 'there was an error.' } }); + const ansi = makeResult({ + ansi: { + pass: false, + message: + '\u001b[2mexpect(\u001b[22m\u001b[31mreceived\u001b[39m\u001b[2m)' + } + }); it('Should write a junit file containing results', async function() { const { path, cleanup } = await file(); @@ -29,4 +32,14 @@ describe('JUnit Reporter', function() { await expect(fs.readFile(path, 'utf8')).resolves.toMatchSnapshot(); await cleanup(); }); + + it('Should strip ANSI output', async function() { + const { path, cleanup } = await file(); + const reporter = new JUnitReporter(path); + reporter.report('ansi', ansi); + await reporter.stop(); + const contents = await fs.readFile(path, 'utf8'); + await expect(contents).toContain('expect(received)'); + await cleanup(); + }); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 7f47f69..2748e96 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,33 +1,26 @@ import yargs, { MiddlewareFunction } from 'yargs'; -import Crawler from '../crawler'; import path from 'path'; import TestContext from '../testing/TestContext'; interface PreloadCrawlerArgs { config: string; - crawler?: Crawler; } export interface ConfigArgs { config?: string; - crawler: Crawler; - tests: TestContext; -} - -function requireConfig(file: string): { crawler: Crawler; tests: TestContext } { - const resolved = path.resolve(process.cwd(), file); - return require(resolved); + context: TestContext; } const loadConfig: MiddlewareFunction = argv => { - try { - const config = requireConfig(argv.config); - argv.crawler = config.crawler; - argv.tests = config.tests; - } catch (e) { - throw new Error( - `Unable to load crawler from ${argv.config} due to error: ${e.toString()}` - ); + // eslint-disable-next-line @typescript-eslint/no-var-requires + const context = require(path.resolve(process.cwd(), argv.config)); + if (context instanceof TestContext) { + return { + context + }; } + throw new Error( + `The configuration file at ${argv.config} does not export a valid test context.` + ); }; yargs diff --git a/src/cli/util.ts b/src/cli/util.ts index a4b4d84..ee33077 100644 --- a/src/cli/util.ts +++ b/src/cli/util.ts @@ -1,36 +1,9 @@ -import { TestResultMap } from '../testing/TestContext'; +import { TestResult, TestResultMap } from '../testing/TestContext'; -export function hasFailure(result: TestResultMap): boolean { - return Array.from(result.values()).some(r => !r.pass); +export function makeResult(obj: { [k: string]: TestResult }): TestResultMap { + return new Map(Object.entries(obj)); } -export function pickFailures(result: TestResultMap): TestResultMap { - return new Map( - Array.from(result.entries()).filter(([, result]) => !result.pass) - ); -} - -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isAsyncIterable(x: any): x is AsyncIterable { - return x !== null && typeof x[Symbol.asyncIterator] === 'function'; -} -// eslint-disable-next-line @typescript-eslint/no-explicit-any -function isIterable(x: any): x is Iterable { - return typeof x !== null && typeof x[Symbol.iterator] === 'function'; -} - -export function toAsyncIterable( - iter: Iterable | AsyncIterable -): AsyncIterable { - if (isAsyncIterable(iter)) { - return iter; - } - if (isIterable(iter)) { - return (async function*(): AsyncIterable { - yield* iter; - })(); - } - throw new Error( - 'Unable to create an async iterator from the request iterable.' - ); +export function hasFailure(result: TestResultMap): boolean { + return Array.from(result.values()).some(r => !r.pass); } diff --git a/src/index.ts b/src/index.ts index fa270e1..02d0f2f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,6 @@ -import Crawler from './crawler'; +import Crawler from './Crawler'; import TestContext from './testing/TestContext'; export default Crawler; export { Crawler, TestContext }; +export * from './testing/functions'; diff --git a/src/testing/TestContext.ts b/src/testing/TestContext.ts index c8cd738..6ed0a7c 100644 --- a/src/testing/TestContext.ts +++ b/src/testing/TestContext.ts @@ -1,78 +1,61 @@ import { CrawlerUnit } from '../types'; +import Crawler from '../Crawler'; +import { makeResult } from '../cli/util'; -function unitInGroup(unit: CrawlerUnit, group: string): boolean { - return !!unit.request.groups?.includes(group); -} - -function filterOneByGroup( - group: string, - cb: OneHandler['cb'] -): OneHandler['cb'] { - return (unit): void => { - if (unitInGroup(unit, group)) { - cb(unit); - } - }; -} -function filterManyByGroup( - group: string, - cb: ManyHandler['cb'] -): ManyHandler['cb'] { - return (units): void => { - const matching = units.filter(unit => unitInGroup(unit, group)); - if (matching.length) { - cb(matching); - } - }; -} interface OneHandler { description: string; cb: (unit: CrawlerUnit) => void; } -interface ManyHandler { +interface AfterHandler { description: string; - cb: (units: CrawlerUnit[]) => void; + cb: () => void; } export type PassingResult = { pass: true }; export type FailingResult = { pass: false; message: string }; export type TestResult = PassingResult | FailingResult; export type TestResultMap = Map; -export type EachResultMap = Map; export default class TestContext { - oneHandlers: OneHandler[]; - manyHandlers: ManyHandler[]; + description: string; + crawler?: Crawler; + testHandlers: OneHandler[]; + afterHandlers: AfterHandler[]; - constructor() { - this.oneHandlers = []; - this.manyHandlers = []; + constructor(description: string) { + this.description = description; + this.testHandlers = []; + this.afterHandlers = []; } - each(description: OneHandler['description'], cb: OneHandler['cb']): this { - this.oneHandlers.push({ description, cb }); + test(description: OneHandler['description'], cb: OneHandler['cb']): this { + this.testHandlers.push({ description, cb }); return this; } - all(description: ManyHandler['description'], cb: ManyHandler['cb']): this { - this.manyHandlers.push({ description, cb }); - return this; - } - eachInGroup( - description: OneHandler['description'], - group: string, - cb: OneHandler['cb'] + after( + description: AfterHandler['description'], + cb: AfterHandler['cb'] ): this { - return this.each(description, filterOneByGroup(group, cb)); + this.afterHandlers.push({ description, cb }); + return this; } - allInGroup( - description: ManyHandler['description'], - group: string, - cb: ManyHandler['cb'] - ): this { - return this.all(description, filterManyByGroup(group, cb)); + async *crawl(concurrency: number): AsyncIterable<[string, TestResultMap]> { + if (!this.crawler) { + throw new Error(`crawl was invoked without a valid crawler.`); + } + for await (const unit of this.crawler.crawl(concurrency)) { + yield [unit.request.url, this.testUnit(unit)]; + } + yield ['All', this.testAfter()]; } - testUnit(unit: CrawlerUnit): TestResultMap { - return this.oneHandlers.reduce((results, handler) => { + private testUnit(unit: CrawlerUnit): TestResultMap { + // Short circuit when there is a request error. + if (unit.error) { + return makeResult({ + 'Request Failure': { pass: false, message: unit.error.toString() } + }); + } + return this.testHandlers.reduce((results, handler) => { try { handler.cb(unit); results.set(handler.description, { pass: true }); @@ -85,10 +68,10 @@ export default class TestContext { return results; }, new Map()); } - testUnits(units: CrawlerUnit[]): TestResultMap { - return this.manyHandlers.reduce((results, handler) => { + private testAfter(): TestResultMap { + return this.afterHandlers.reduce((results, handler) => { try { - handler.cb(units); + handler.cb(); results.set(handler.description, { pass: true }); } catch (e) { results.set(handler.description, { diff --git a/src/testing/__tests__/TestContext.ts b/src/testing/__tests__/TestContext.ts index 34c69b9..d1c3360 100644 --- a/src/testing/__tests__/TestContext.ts +++ b/src/testing/__tests__/TestContext.ts @@ -1,93 +1,133 @@ import TestContext from '../TestContext'; +import Crawler from '../../Crawler'; +import { CrawlerUnit } from '../../types'; +import { makeResult } from '../../cli/util'; +import { mocked } from 'ts-jest/utils'; + +jest.mock('../../Crawler'); + +async function all( + iterator: AsyncIterable +): Promise { + const collected = []; + for await (const i of iterator) { + collected.push(i); + } + return collected; +} describe('TestContext', function() { - const unit = { request: { url: 'foo' } }; const units = [ { request: { url: 'foo', groups: ['foo'] } }, { request: { url: 'bar', groups: ['bar'] } } ]; + const crawler = new Crawler([]); + crawler.crawl = jest.fn(async function*() { + yield* [ + { request: { url: 'foo', groups: ['foo'] } }, + { request: { url: 'bar', groups: ['bar'] } } + ]; + }); + const failingCrawler = new Crawler([]); + failingCrawler.crawl = jest.fn(async function*() { + yield* [{ request: { url: 'foo' }, error: new Error('Request failure') }]; + }); - it('Should invoke "each" handlers', () => { - const handler = jest.fn(); - const context = new TestContext(); - context.each('Test', handler); - context.testUnit(unit); - expect(handler).toHaveBeenCalled(); - expect(handler).toHaveBeenCalledWith(unit); + it('Should require a crawler to run', async () => { + const context = new TestContext(''); + await expect(all(context.crawl(1))).rejects.toThrow( + 'crawl was invoked without a valid crawler' + ); }); - it('Should invoke "all" handlers', () => { - const handler = jest.fn(); - const context = new TestContext(); - context.all('Test', handler); - context.testUnits(units); - expect(handler).toHaveBeenCalled(); - expect(handler).toHaveBeenCalledWith(units); + it('Should pass concurrency to crawler', async () => { + const context = new TestContext(''); + context.crawler = crawler; + await all(context.crawl(21)); + expect(mocked(crawler).crawl).toHaveBeenCalledWith(21); }); - it('Should invoke "eachInGroup" handlers.', () => { + it('Should invoke "test" handlers', async () => { const handler = jest.fn(); - const context = new TestContext(); - context.eachInGroup('Test', 'foo', handler); - context.testUnit(units[0]); - context.testUnit(units[1]); - expect(handler).toHaveBeenCalledTimes(1); + const context = new TestContext(''); + context.crawler = crawler; + context.test('Test', handler); + await all(context.crawl(1)); + expect(handler).toHaveBeenCalledTimes(2); expect(handler).toHaveBeenCalledWith(units[0]); + expect(handler).toHaveBeenCalledWith(units[1]); }); - it('Should invoke "allInGroup" handlers.', () => { + it('Should invoke "after" handlers', async () => { const handler = jest.fn(); - const context = new TestContext(); - context.allInGroup('Test', 'foo', handler); - context.testUnits(units); + const context = new TestContext(''); + context.crawler = crawler; + context.after('Test', handler); + await all(context.crawl(1)); expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith([units[0]]); }); - it('Should not invoke "allInGroup" handlers when there are no matching requests', () => { - const handler = jest.fn(); - const context = new TestContext(); - context.allInGroup('Test', 'baz', handler); - context.testUnits(units); - expect(handler).not.toHaveBeenCalled(); + it('Should return results from `test` handlers', async () => { + const handler = jest.fn((unit: CrawlerUnit) => { + if (unit.request.url === 'foo') { + throw new Error('test error'); + } + }); + const context = new TestContext(''); + context.crawler = crawler; + context.test('Test', handler); + const results = await all(context.crawl(1)); + expect(results[0]).toEqual([ + 'foo', + makeResult({ + Test: { pass: false, message: 'Error: test error' } + }) + ]); + expect(results[1]).toEqual([ + 'bar', + makeResult({ + Test: { pass: true } + }) + ]); }); - it('Should collect results for "each" handlers', function() { - const unit = { request: { url: 'foo' } }; - const context = new TestContext(); - context.each('pass', () => { - /* no-op */ + it('Should return results from `after` handlers', async () => { + const context = new TestContext(''); + context.crawler = crawler; + context.after('failing', () => { + throw new Error('fail'); }); - context.each('fail', () => { - throw new Error(''); + context.after('passing', () => { + // no-op. }); - const result = context.testUnit(unit); - expect(result).toEqual( - new Map( - Object.entries({ - pass: { pass: true }, - fail: { pass: false, message: 'Error' } - }) - ) - ); + const results = await all(context.crawl(1)); + expect(results[2]).toEqual([ + 'All', + makeResult({ + failing: { pass: false, message: 'Error: fail' }, + passing: { pass: true } + }) + ]); }); - it('Should collect results for "all" handlers', function() { - const context = new TestContext(); - context.all('pass', () => { - /* no-op */ - }); - context.all('fail', () => { - throw new Error('Test'); - }); - const result = context.testUnits(units); - expect(result).toEqual( - new Map( - Object.entries({ - pass: { pass: true }, - fail: { pass: false, message: 'Error: Test' } - }) - ) - ); + it('Should not invoke test handlers when requests fail.', async () => { + const handler = jest.fn(); + const context = new TestContext(''); + context.crawler = failingCrawler; + context.test('test', handler); + await all(context.crawl(1)); + expect(handler).not.toHaveBeenCalled(); + }); + + it('Should return a meaningful result when requests fail.', async () => { + const context = new TestContext(''); + context.crawler = failingCrawler; + const result = await all(context.crawl(1)); + expect(result[0]).toEqual([ + 'foo', + makeResult({ + 'Request Failure': { pass: false, message: 'Error: Request failure' } + }) + ]); }); }); diff --git a/src/testing/__tests__/functions.ts b/src/testing/__tests__/functions.ts new file mode 100644 index 0000000..7a83132 --- /dev/null +++ b/src/testing/__tests__/functions.ts @@ -0,0 +1,72 @@ +import { crawl, test, after } from '../functions'; +import TestContext from '../TestContext'; +import { mocked } from 'ts-jest/utils'; +import Crawler from '../../Crawler'; +import { CrawlerRequest } from '../../types'; + +jest.mock('../TestContext'); +jest.mock('../../Crawler'); + +const MockedContext = mocked(TestContext); +const MockedCrawler = mocked(Crawler); + +describe('crawl function', function() { + beforeEach(function() { + MockedContext.mockReset(); + MockedCrawler.mockReset(); + }); + + it('crawl should create a crawler when an iterable is returned.', function() { + const queue = [{ url: 'foo' }]; + const context = crawl('Test', function() { + return queue; + }); + expect(MockedContext).toHaveBeenCalledWith('Test'); + expect(MockedCrawler).toHaveBeenCalledWith(queue); + expect(context.crawler).toBe(MockedCrawler.mock.instances[0]); + }); + it('crawl should use a crawler that is returned', function() { + const crawler = new Crawler([]); + const context = crawl('Test', function() { + return crawler; + }); + expect(context.crawler).toBe(crawler); + }); + + it('A crawl function that returns no iterable should result in an error', function() { + expect(() => { + crawl('Test', function() { + /* no-op */ + } as () => Iterable); + }).toThrow('This crawl function did not return requests'); + }); + + it('Should proxy `test` calls to the context', function() { + const fn = jest.fn(); + const context = crawl('Test', function() { + test('foo', fn); + return []; + }); + expect(mocked(context).test).toHaveBeenCalledWith('foo', fn); + }); + + it('Should proxy `after` calls to the context', function() { + const fn = jest.fn(); + const context = crawl('Test', function() { + after('foo', fn); + return []; + }); + expect(mocked(context).after).toHaveBeenCalledWith('foo', fn); + }); + + it('each should not be callable outside of a crawl function', function() { + expect(() => test('foo', jest.fn())).toThrow( + 'You may not call test outside of a crawler function.' + ); + }); + it('after should not be callable outside of a crawl function', function() { + expect(() => after('foo', jest.fn())).toThrow( + 'You may not call after outside of a crawler function.' + ); + }); +}); diff --git a/src/testing/functions.ts b/src/testing/functions.ts new file mode 100644 index 0000000..2b487a1 --- /dev/null +++ b/src/testing/functions.ts @@ -0,0 +1,51 @@ +import TestContext from './TestContext'; +import Crawler from '../Crawler'; +import { RequestIterable } from '../types'; + +let activeContext: TestContext | null = null; + +const crawl = function( + description: string, + cb: () => RequestIterable | Crawler +): TestContext { + const context = new TestContext(description); + activeContext = context; + let result: RequestIterable | Crawler; + try { + result = cb(); + if (!result) { + throw new Error( + `This crawl function did not return requests. Make sure you return either an iterable containing requests, or a Crawler object.` + ); + } + context.crawler = result instanceof Crawler ? result : new Crawler(result); + return context; + } finally { + activeContext = null; + } +}; + +const withActive = ( + cb: (context: TestContext, ...args: A) => R, + name: string +) => { + return function(...args: A): R { + if (activeContext === null) { + throw new Error( + `You may not call ${name} outside of a crawler function.` + ); + } + return cb(activeContext, ...args); + }; +}; + +const test = withActive( + (ctx, ...args: Parameters) => ctx.test(...args), + 'test' +); +const after = withActive( + (ctx, ...args: Parameters) => ctx.after(...args), + 'after' +); + +export { crawl, test, after }; diff --git a/src/types.ts b/src/types.ts index 172394c..aece3db 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5,6 +5,10 @@ export type CrawlerRequest = { [key: string]: unknown; }; +export type RequestIterable = + | Iterable + | AsyncIterable; + export type DriverResponse = { statusCode: number; time: number; diff --git a/src/util.ts b/src/util.ts new file mode 100644 index 0000000..dfc06e5 --- /dev/null +++ b/src/util.ts @@ -0,0 +1,39 @@ +import { CrawlerRequest } from './types'; + +function isAsyncIterable(x: unknown): x is AsyncIterable { + return ( + x !== null && + typeof (x as AsyncIterable)[Symbol.asyncIterator] === 'function' + ); +} + +function isIterable(x: unknown): x is Iterable { + return ( + typeof x !== null && + typeof (x as Iterable)[Symbol.iterator] === 'function' + ); +} + +export function toAsyncIterable( + iter: Iterable | AsyncIterable +): AsyncIterable { + if (isAsyncIterable(iter)) { + return iter; + } + if (isIterable(iter)) { + return (async function*(): AsyncIterable { + yield* iter; + })(); + } + throw new Error( + 'Unable to create an async iterator from the request iterable.' + ); +} + +export function isCrawlerRequest(request: unknown): request is CrawlerRequest { + return ( + request !== null && + typeof request === 'object' && + typeof (request as CrawlerRequest).url === 'string' + ); +}