Skip to content

Commit

Permalink
feat: unit (#73)
Browse files Browse the repository at this point in the history
* feat: unit

* fix: wait for async task

* test: fix test script

* fix: bin script

* test: fix TestStream
  • Loading branch information
climba03003 authored Apr 12, 2024
1 parent 5b944e7 commit 749e506
Show file tree
Hide file tree
Showing 12 changed files with 1,308 additions and 104 deletions.
115 changes: 115 additions & 0 deletions .github/workflows/ci-unit.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
name: Continuous Integration - Unit

on:
push:
paths:
- ".github/workflows/ci-unit.yml"
- "packages/unit/**"
pull_request:
paths:
- ".github/workflows/ci-unit.yml"
- "packages/unit/**"

jobs:
linter:
name: Lint Code
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 18

- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 8
run_install: false

- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-pnpm-store-
- name: Install dependencies
run: pnpm install

- name: Lint code
run: pnpm --filter "./packages/unit" run lint

test:
name: Test
runs-on: ${{ matrix.os }}
permissions:
contents: read
strategy:
matrix:
node-version: [18, 20]
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- name: Check out repo
uses: actions/checkout@v4
with:
persist-credentials: false

- name: Setup Node ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}

- name: Install pnpm
uses: pnpm/action-setup@v3
with:
version: 8
run_install: false

- name: Get pnpm store directory
shell: bash
run: |
echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV
- uses: actions/cache@v4
name: Setup pnpm cache
with:
path: ${{ env.STORE_PATH }}
key: ${{ runner.os }}-${{ matrix.node-version }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
restore-keys: |
${{ runner.os }}-${{ matrix.node-version }}-pnpm-store-
- name: Install dependencies
run: pnpm install

- name: Run tests
run: pnpm --filter "./packages/unit" run test

automerge:
name: Automerge Dependabot PRs
if: >
github.event_name == 'pull_request' &&
github.event.pull_request.user.login == 'dependabot[bot]'
needs: test
permissions:
pull-requests: write
contents: write
runs-on: ubuntu-latest
steps:
- uses: fastify/github-action-merge-dependabot@v3
with:
exclude: ${{ inputs.auto-merge-exclude }}
github-token: ${{ secrets.GITHUB_TOKEN }}
target: major
24 changes: 24 additions & 0 deletions packages/unit/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"extends": "standard-with-typescript",
"parserOptions": {
"project": "./tsconfig.json"
},
"rules": {
// conflict between standard and standard-typescript
"no-void": ["error", { "allowAsStatement": true }]
},
"overrides": [
{
"files": ["**/*.test.ts"],
"rules": {
"@typescript-eslint/no-floating-promises": "off"
}
},
{
"files": ["scripts/*.mjs"],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off"
}
}
]
}
28 changes: 28 additions & 0 deletions packages/unit/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# @kakang/unit

This package is a wrapper over the `node:test` module,
which provides the ability to use `node:assert` directly
inside the unit test. It also provides `plan` feature,
to ensure the test you specify are all run.

## Usage

```js
import { test } from '@kakang/unit'

test('test', (t) => {
// .test also asserted
// .equal and .test is counted as 2
t.plan(2)

const expect = 1
const actual = 1
t.equal(actual, expect)

// To ease the usage, unless `node:test`
// nested `test` nolonger required to await
t.test('sub test', async (t) => {
t.plan(1) // it should fail
})
})
```
29 changes: 29 additions & 0 deletions packages/unit/bin/run.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/usr/bin/env node

import { glob } from 'glob'
import { compose } from 'node:stream'
import { run } from 'node:test'
import { spec as Spec } from 'node:test/reporters'
import { parseArgs } from 'node:util'

const { values } = parseArgs({
args: process.args,
options: {
timeout: { type: 'string' }
}
})

run({
concurrency: true,
timeout: Number(values.timeout ?? 30_000),
setup: (test) => {
const hasReporter = typeof test.reporter !== 'undefined'
const reportor = new Spec()
compose(hasReporter ? test.reporter : test, reportor).pipe(process.stdout)
},
files: await glob(['**/*.test.{js,ts}'], { ignore: 'node_modules/**' })
}).on('test:fail', (data) => {
if (data.todo === undefined || data.todo === false) {
process.exitCode = 1
}
})
169 changes: 169 additions & 0 deletions packages/unit/lib/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import assert from 'node:assert'
import {
test as nodeTest
} from 'node:test'

type TestFn = NonNullable<Parameters<typeof nodeTest>[0]>

interface TestOptions {
/**
* If a number is provided, then that many tests would run in parallel.
* If truthy, it would run (number of cpu cores - 1) tests in parallel.
* For subtests, it will be `Infinity` tests in parallel.
* If falsy, it would only run one test at a time.
* If unspecified, subtests inherit this value from their parent.
* @default false
*/
concurrency?: number | boolean | undefined
/**
* If truthy, and the test context is configured to run `only` tests, then this test will be
* run. Otherwise, the test is skipped.
* @default false
*/
only?: boolean | undefined
/**
* Allows aborting an in-progress test.
* @since v18.8.0
*/
signal?: AbortSignal | undefined
/**
* If truthy, the test is skipped. If a string is provided, that string is displayed in the
* test results as the reason for skipping the test.
* @default false
*/
skip?: boolean | string | undefined
/**
* A number of milliseconds the test will fail after. If unspecified, subtests inherit this
* value from their parent.
* @default Infinity
* @since v18.7.0
*/
timeout?: number | undefined
/**
* If truthy, the test marked as `TODO`. If a string is provided, that string is displayed in
* the test results as the reason why the test is `TODO`.
* @default false
*/
todo?: boolean | string | undefined
}

type TestContext = Parameters<TestFn>[0]

type ExtendedTestFn = (t: ExtendedTestContext, done: (result?: any) => void) => void | Promise<void>

interface Test {
(name?: string, fn?: ExtendedTestFn): Promise<void>
(name?: string, options?: TestOptions, fn?: ExtendedTestFn): Promise<void>
(options?: TestOptions, fn?: ExtendedTestFn): Promise<void>
(fn?: ExtendedTestFn): Promise<void>
}

interface Assert extends Omit<typeof assert, 'CallTracker' | 'AssertionError' | 'strict'> {}

interface ExtendedTestContext extends Omit<TestContext, 'test'>, Assert {
plan: (count: number) => void
test: Test
}

interface DeferredPromise<T = unknown> {
promise: Promise<T>
resolve: (...args: any[]) => void
reject: (...args: any[]) => void
}
function createDeferredPromise (replace: any = {}): DeferredPromise {
const promise: any = replace
promise.promise = new Promise((resolve, reject) => {
promise.resolve = resolve
promise.reject = reject
})
return promise
}

export const test: Test = wrapTest(nodeTest)

function wrapTest (testFn: any): Test {
const test: Test = async function (...args: any[]) {
const fn: TestFn = args.pop() // TestFn must be the last one

const customFn: ExtendedTestFn = async function (context) {
const {
promises: contextPromises,
teardown,
completed
} = wrapContext(context)

// either return promise or using done
const promise = createDeferredPromise()
const fnPromise = fn(context as never as TestContext, () => {
promise.resolve()
})

await completed.promise
// resolve sub context
await Promise.all(contextPromises)
// resolve current context
await Promise.race([promise.promise, fnPromise])

teardown()
}

await testFn(...[...args, customFn] as TestFn[])
}

return test
}

function wrapContext (context: ExtendedTestContext): {
promises: Array<Promise<unknown>>
teardown: () => void
completed: DeferredPromise
} {
const promises: Array<Promise<unknown>> = []
const completed: DeferredPromise = {} as any
let expect = -1
let actual = 0

const teardown = (): void => {
if (expect > -1) {
assert.strictEqual(actual, expect)
}
}

const validate = (): void => {
if (expect > -1 && actual === expect) {
completed?.resolve()
}
}

context.plan = function plan (num: number) {
expect = num
if (num > -1) {
createDeferredPromise(completed)
} else {
completed?.resolve()
}
}

const contextTest = context.test.bind(context)
const test = async function (...args: unknown[]): Promise<void> {
const promise = wrapTest(contextTest)(...(args as []))
promises.push(promise)
await promise
actual++
validate()
}
context.test = test

for (const method of Object.keys(assert)) {
if (method.match(/^[a-z]/) !== null) {
(context as any)[method] = (...args: any[]) => {
actual++
const res = (assert as any)[method](...args)
validate()
return res
}
}
}

return { promises, teardown, completed }
}
Loading

0 comments on commit 749e506

Please sign in to comment.