-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* feat: unit * fix: wait for async task * test: fix test script * fix: bin script * test: fix TestStream
- Loading branch information
1 parent
5b944e7
commit 749e506
Showing
12 changed files
with
1,308 additions
and
104 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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" | ||
} | ||
} | ||
] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
}) | ||
}) | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } | ||
} |
Oops, something went wrong.