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
This commit is contained in:
Marc 2023-04-18 23:17:48 -04:00 committed by GitHub
parent 29202908d4
commit bc2bd85422
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 433 additions and 15 deletions

View file

@ -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.

274
docs/API.md Normal file
View file

@ -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<string>): Promise<Array<string>>
```
Collects test files recursively starting from the provided root
path.
---
### function / assignTestsToWorkers
```ts
async function assignTestsToWorkers(
context: Context,
collectedPaths: Array<string>,
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<T>(data: Array<T>, desiredBatchCount: number = 1): Array<Array<T>>
```
Divides the given list into `desiredBatchCount` batches, returning
an array of arrays which add up to the given list.

View file

@ -2,3 +2,4 @@
- [Writing tests](./WRITING_TESTS.md)
- [Extending with custom matchers](./CUSTOM_MATCHERS.md)
- [API documentation](./API.md)

111
docs/generate.ts Normal file
View file

@ -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<APIDocumentationNode> = []
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<ExtractedNode> = []
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()

View file

@ -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",

View file

@ -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)

View file

@ -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')