From bc2bd85422045a0b7015ab6c2f83d54d2a92d376 Mon Sep 17 00:00:00 2001 From: Marc Cataford Date: Tue, 18 Apr 2023 23:17:48 -0400 Subject: [PATCH] Build/autodoc generation (#32) * feat: autodoc * docs: autodoc output docs: autodoc output * build: document generator * ci: add doc check * chore: add docs * ci: finagle with docs stale check ci: target generated docs for diff * ci: remove check * docs: add links * docs: more matcher documentation --- README.md | 2 + docs/API.md | 274 +++++++++++++++++++++++++++++++++ docs/README.md | 1 + docs/generate.ts | 111 +++++++++++++ package.json | 7 +- src/testComponents/matchers.ts | 22 ++- src/worker.ts | 31 ++-- 7 files changed, 433 insertions(+), 15 deletions(-) create mode 100644 docs/API.md create mode 100644 docs/generate.ts diff --git a/README.md b/README.md index bba159f..a55ca7c 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ can peek at the opinions baked into this [here](./DESIGN_DECISIONS.md). Documentation can be found [here](./docs/INDEX.md) and welcomes contributions! +API documentation can also be found [here](./docs/API.md). + ## Development This uses [Corepack](https://github.com/nodejs/corepack), which comes bundled with `node>=16` to manage which Yarn version to use. diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..76bc7b6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,274 @@ +# API documentation +--- +## ./src/worker.ts + +--- + +Worker runtime + +The worker executes the tests by called `node` on them. Since each test +is an self-contained executable file, the worker can run each of them, +collect output and relay it back to the runner process via IPC. + +Each worker process is responsible for as many test files as the runner +decides to assign it and files assigned to the worker are only +touched by the worker assigned to them. + + +--- +### function / work +```ts +async function work() +``` + + +Entrypoint for the worker. + +Retrieves paths assigned to the worker from the arguments passed when +calling the worker runtime and spawns processes to run the test file +pointed at by each of the paths. + +This will spawn one process per file and each process will communicate back +to the worker's parent process as it finishes. + +If the `TS` flag is passed, the worker runs the test file using ts-node +for Typescript compatibility. + + +## ./src/testComponents/matchers.ts + +--- + +Built-in matchers + +All the matchers defined and exported as part of the default export +of this file are available to each `expect` statement made in tests. + +A matcher's inverse is defined by its behaviour when the `negated` flag +that is passed to it is truthy. + + +--- +### function / toEqual +```ts +function toEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport +``` + + +`toEqual` asserts whether `value` and `other` are strictly equal. + +Once registered, it can be used as + +```ts +expect(value).toEqual(other) +``` + +and in negated form + +```ts +expect(value).not.toEqual(other) +``` + + +--- +### function / toNotEqual +```ts +function toNotEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport +``` + + +`toNotEqual` is the inverse of `toEqual` and behaves the same way as +`expect(...).not.toEqual(...)`. Negating it behaves like `toEqual`. + + +--- +### function / toBe +```ts +function toBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport +``` + + +Asserts whether value and other are the same entity. + + +--- +### function / toNotBe +```ts +function toNotBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport +``` + + +Inverse of toBe. + + +--- +### function / toThrow +```ts +function toThrow(func: () => unknown, negated: boolean = false): MatcherReport +``` + + +Asserts whether the provided function throws the provided error. + + +--- +### function / toNotThrow +```ts +function toNotThrow(func: () => unknown, negated: boolean = false): MatcherReport +``` + + +Inverse of toThrow. + + +--- +### function / toHaveLength +```ts +function toHaveLength(value: unknown, length: unknown, negated: boolean = false): MatcherReport +``` + + +Validates that the `value` has a length of `length`. The value provided to `value` should +have a defined length (i.e. it can be a string or some sort of iterable). + + +## ./src/testComponents/test.ts + +--- +### function / test +```ts +function test(label: TestCaseLabel, testCase: TestCaseFunction): void +``` + + +`test` defines a single test case. + +``` +test('My test', () => { +// Assert things. +}) +``` + + +--- + +`it` is an alias of `test`. + + +## ./src/testComponents/expect.ts + +--- + +The `expect` function returned is the main access point +to create Expect objects. On import, all the built-in matchers +are registered, but more can be registered ad-hoc via `addMatcher`. + + +## ./src/testComponents/describe.ts + +--- +### function / describe +```ts +function describe(label: TestCaseLabel, testGroup: TestCaseGroup) +``` + + +`describe` facilitates grouping tests together. + +``` +describe('My test group', () => { +test('My first test', ...) + +test('My second test', ...) +}) +``` + + +## ./src/runner.ts + +--- +### function / collectTests +```ts +async function collectTests(roots: Array): Promise> +``` + + +Collects test files recursively starting from the provided root +path. + + +--- +### function / assignTestsToWorkers +```ts +async function assignTestsToWorkers( + context: Context, + collectedPaths: Array, + workerCount: number = 1, +): Promise<{ [key: number]: WorkerReport }> +``` + + +Splits the list of collected test files into `workerCount` batches and starts +worker processes. + + +## ./src/logging.ts + +--- +### class / Logger + +Standard logger for anything that needs to print messages to the user. + +This supports the same general functionality as the `Console` logger, +including `group` and various levels of logging. + + +## ./src/utils.ts + +--- +### function / boldText +```ts +export function boldText(text: string | number): string +``` + + +Terminal text style + + +--- +### function / assertTsNodeInstall +```ts +export function assertTsNodeInstall() +``` + + +To support typescript source directly, womm uses ts-node in +workers to execute test files. + +If ts-node is not installed, this throws. + + +--- +### function / getContext +```ts +export function getContext(runnerPath: string, ts: boolean = false): Context +``` + + +Generates a context object that contains general information +about the test runner. The parameter here should always be +`process.argv[1]`, which will allow all the other paths +to be set properly. + + +--- +### function / splitIntoBatches +```ts +export function splitIntoBatches(data: Array, desiredBatchCount: number = 1): Array> +``` + + +Divides the given list into `desiredBatchCount` batches, returning +an array of arrays which add up to the given list. + + diff --git a/docs/README.md b/docs/README.md index 02fc2f1..b668d27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,3 +2,4 @@ - [Writing tests](./WRITING_TESTS.md) - [Extending with custom matchers](./CUSTOM_MATCHERS.md) +- [API documentation](./API.md) diff --git a/docs/generate.ts b/docs/generate.ts new file mode 100644 index 0000000..72b036e --- /dev/null +++ b/docs/generate.ts @@ -0,0 +1,111 @@ +import * as ts from 'typescript' +import { promises as fs } from 'fs' +import childProcess from 'child_process' +import path from 'path' + +interface ExtractedNode { + name?: string + snippet?: string + comment?: string + type?: string +} + +interface APIDocumentationNode { + sourcePath: string + targetPath: string + content: string +} + +const API_DOC_PATH = './docs/API.md' + +function formatFunctionDeclaration(node: ts.FunctionDeclaration, fullText: string): string { + const declarationStart = node.getStart() + const declarationEnd = node.body?.pos ?? declarationStart + return fullText.slice(declarationStart, declarationEnd) +} + +/* + * Documentation extraction + * + * This extracts block comments and declared entities from source files + * and reformats it so publishable documentation can be generated from it. + */ +async function generateDocumentation() { + const rootPaths = process.argv.slice(2) + const output = childProcess.execSync(`find ${rootPaths.join(' ')} -name *.ts`, { encoding: 'utf8' }) + + const files: Array = [] + + const sourcePaths = output.split('\n').filter((filePath) => Boolean(filePath)) + + for await (const sourcePath of sourcePaths) { + const rawContent = await fs.readFile(sourcePath, { encoding: 'utf8' }) + + const source = ts.createSourceFile(sourcePath, rawContent, ts.ScriptTarget.ES2015, true) + const fullText = source.getFullText() + const extractedNodes: Array = [] + + ts.forEachChild(source, (node: ts.Node) => { + const extractedNode: ExtractedNode = {} + + const commentRanges = ts.getLeadingCommentRanges(fullText, node.getFullStart()) + const blockComments = (commentRanges ?? []) + .filter((comment) => comment.kind === ts.SyntaxKind.MultiLineCommentTrivia) + .map((r) => { + return fullText.slice(r.pos, r.end) + }) + + if (node.kind === ts.SyntaxKind.FunctionDeclaration) { + const functionDeclarationNode = node as ts.FunctionDeclaration + extractedNode.snippet = formatFunctionDeclaration(functionDeclarationNode, fullText) + extractedNode.name = String(functionDeclarationNode?.name?.escapedText ?? 'unknown function') + extractedNode.type = 'function' + } else if (node.kind === ts.SyntaxKind.ClassDeclaration) { + const classDeclarationNode = node as ts.ClassDeclaration + extractedNode.name = String(classDeclarationNode?.name?.escapedText ?? 'unknown class') + extractedNode.type = 'class' + } + + if (blockComments.length === 0) return + + extractedNode.comment = blockComments[0] + + extractedNodes.push(extractedNode) + }) + + if (extractedNodes.length === 0) continue + + const targetPath = `docs/api/${path.basename(sourcePath, '.ts')}.md` + + let content = '' + + content += `## ${sourcePath}\n\n` + + for (const entry of extractedNodes) { + content += '---\n' + if (entry.name) content += `### ${entry?.type ?? ''} / ${entry.name}\n` + if (entry.snippet) content += `\`\`\`ts\n${entry.snippet}\n\`\`\`\n\n` + + const entryDescription = (entry?.comment ?? '') + .split('\n') + .map((line) => line.replace(/(\/\*)|(\*\/)|(\*)/g, '').trim()) + .join('\n') + + content += `${entryDescription}\n\n` + } + + files.push({ + sourcePath, + targetPath, + content, + }) + } + + await fs.writeFile(API_DOC_PATH, '# API documentation\n---\n') + + for await (const item of files) { + await fs.appendFile(API_DOC_PATH, item.content) + } +} + +generateDocumentation() diff --git a/package.json b/package.json index a4141bd..cf0c440 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,14 @@ "prepack": "yarn build", "prebuild": "rm -rf dist", "cli": "$SHELL ./script/run", - "lint": "rome format src tests && rome check src tests", - "lint:fix": "rome format src tests --write && rome check src tests --apply", + "lint": "rome format src tests docs && rome check src tests docs", + "lint:fix": "rome format src tests docs --write && rome check src tests docs --apply", "types:check": "yarn tsc --project . --noEmit", "test": "$SHELL ./script/test-suite", "test:integration": "$SHELL ./script/integration-tests", "build:ts": "tsc --project .", - "build": "$SHELL ./script/build" + "build": "$SHELL ./script/build", + "docs": "yarn dlx ts-node ./docs/generate.ts ./src" }, "devDependencies": { "@types/node": "^18.15.10", diff --git a/src/testComponents/matchers.ts b/src/testComponents/matchers.ts index ff61ffe..340ebdf 100644 --- a/src/testComponents/matchers.ts +++ b/src/testComponents/matchers.ts @@ -3,13 +3,28 @@ * * All the matchers defined and exported as part of the default export * of this file are available to each `expect` statement made in tests. + * + * A matcher's inverse is defined by its behaviour when the `negated` flag + * that is passed to it is truthy. */ import assert from 'assert' import { type MatcherReport, type WithLength } from '../types' /* - * Asserts whether value and other are strictly equal. + * `toEqual` asserts whether `value` and `other` are strictly equal. + * + * Once registered, it can be used as + * + * ```ts + * expect(value).toEqual(other) + * ``` + * + * and in negated form + * + * ```ts + * expect(value).not.toEqual(other) + * ``` */ function toEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport { if (negated) return toNotEqual(value, other) @@ -26,7 +41,8 @@ function toEqual(value: unknown, other: unknown, negated: boolean = false): Matc } /* - * Inverse of toEqual. + * `toNotEqual` is the inverse of `toEqual` and behaves the same way as + * `expect(...).not.toEqual(...)`. Negating it behaves like `toEqual`. */ function toNotEqual(value: unknown, other: unknown, negated: boolean = false): MatcherReport { if (negated) return toEqual(value, other) @@ -50,7 +66,7 @@ function toBe(value: unknown, other: unknown, negated: boolean = false): Matcher } /* - * Inverse ot toBe. + * Inverse of toBe. */ function toNotBe(value: unknown, other: unknown, negated: boolean = false): MatcherReport { if (negated) return toBe(value, other) diff --git a/src/worker.ts b/src/worker.ts index 900f520..1dc25dc 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,12 +1,3 @@ -import path from 'path' - -import { getContext, spawnProcess } from './utils' - -// TODO: What should be message protocol / format be? -function formatMessage(results: string, failed: boolean): string { - return JSON.stringify({ results, failed }) -} - /* * Worker runtime * @@ -18,6 +9,28 @@ function formatMessage(results: string, failed: boolean): string { * decides to assign it and files assigned to the worker are only * touched by the worker assigned to them. */ + +import path from 'path' + +import { getContext, spawnProcess } from './utils' + +function formatMessage(results: string, failed: boolean): string { + return JSON.stringify({ results, failed }) +} + +/* + * Entrypoint for the worker. + * + * Retrieves paths assigned to the worker from the arguments passed when + * calling the worker runtime and spawns processes to run the test file + * pointed at by each of the paths. + * + * This will spawn one process per file and each process will communicate back + * to the worker's parent process as it finishes. + * + * If the `TS` flag is passed, the worker runs the test file using ts-node + * for Typescript compatibility. + */ async function work() { if (process?.send === undefined) throw Error('No process global found') const tsMode = Boolean(process.env.TS === '1')