├── .github └── workflows │ ├── test.yml │ ├── publish.yml │ └── copilot-setup-steps.yml ├── LICENSE ├── deno.json ├── AGENTS.md ├── .gitignore ├── mod.ts ├── shims ├── bun.ts ├── deno.ts ├── node.ts └── browser.ts ├── README.md └── mod.test.ts /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: [main, dev] 4 | pull_request: 5 | branches: [main, dev] 6 | 7 | jobs: 8 | deno_ci: 9 | uses: cross-org/workflows/.github/workflows/deno-ci.yml@main 10 | with: 11 | entrypoint: mod.ts 12 | lint_docs: false 13 | bun_ci: 14 | uses: cross-org/workflows/.github/workflows/bun-ci.yml@main 15 | with: 16 | jsr_dependencies: "@std/assert @std/async @cross/runtime" 17 | npm_dependencies: "sinon" 18 | node_ci: 19 | uses: cross-org/workflows/.github/workflows/node-ci.yml@main 20 | with: 21 | test_target: "*.test.ts" 22 | jsr_dependencies: "@std/assert @std/async @cross/runtime" 23 | npm_dependencies: "sinon" 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Hexagon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to JSR and npm 2 | on: 3 | release: 4 | types: [released] 5 | 6 | workflow_dispatch: 7 | 8 | jobs: 9 | publish-jsr: 10 | runs-on: ubuntu-latest 11 | 12 | permissions: 13 | contents: read 14 | id-token: write 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Setup Deno 20 | uses: denoland/setup-deno@v1 21 | with: 22 | deno-version: v2.x 23 | 24 | - name: Publish package to JSR 25 | run: deno publish 26 | 27 | publish-npm: 28 | runs-on: ubuntu-latest 29 | 30 | permissions: 31 | contents: read 32 | 33 | steps: 34 | - uses: actions/checkout@v4 35 | 36 | - name: Setup Deno 37 | uses: denoland/setup-deno@v1 38 | with: 39 | deno-version: v2.x 40 | 41 | - name: Setup Node.js 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: 20.x 45 | registry-url: "https://registry.npmjs.org" 46 | 47 | - name: Build npm package 48 | run: deno task build:dist 49 | 50 | - name: Publish package to npm 51 | run: npm publish 52 | env: 53 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 54 | -------------------------------------------------------------------------------- /deno.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@cross/test", 3 | "version": "0.0.15", 4 | "exports": "./mod.ts", 5 | "fmt": { 6 | "lineWidth": 200 7 | }, 8 | "tasks": { 9 | "build:prep": "deno cache --allow-scripts=npm:esbuild build/build.ts", 10 | "build:clean": "deno run --allow-read --allow-write --allow-env build/build.ts -- clean", 11 | "build:npm": "deno run --allow-read --allow-write --allow-env build/build.ts -- package", 12 | "build:esbuild": "deno run --allow-read --allow-write --allow-env --allow-run build/build.ts -- build", 13 | "build:dist": "deno task build:prep && deno task build:clean && deno task build:esbuild && deno task build:npm", 14 | "build": "deno task build:prep && deno test -A && deno task build:clean && deno task build:esbuild && deno task build:npm" 15 | }, 16 | "imports": { 17 | "@cross/fs": "jsr:@cross/fs@^0.1.11", 18 | "@cross/runtime": "jsr:@cross/runtime@~1.0.0", 19 | "@std/assert": "jsr:@std/assert@~1.0.8", 20 | "@std/async": "jsr:@std/async@~1.0.9", 21 | "@std/path": "jsr:@std/path@^1.0.6", 22 | "esbuild": "npm:esbuild@^0.24.0", 23 | "esbuild-plugin-d.ts": "npm:esbuild-plugin-d.ts@^1.3.1", 24 | "sinon": "npm:sinon@~19.0.2" 25 | }, 26 | "publish": { 27 | "exclude": [".github", "*.test.ts", "build", "dist"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /AGENTS.md: -------------------------------------------------------------------------------- 1 | # Agents quick checklist 2 | 3 | This repo uses cross-org reusable CI for Deno, Bun, and Node. Make your changes pass the same checks locally. 4 | 5 | Source of truth: 6 | 7 | - Deno CI: https://github.com/cross-org/workflows/blob/main/.github/workflows/deno-ci.yml 8 | - Bun CI: https://github.com/cross-org/workflows/blob/main/.github/workflows/bun-ci.yml 9 | - Node CI: https://github.com/cross-org/workflows/blob/main/.github/workflows/node-ci.yml 10 | 11 | Repo CI inputs (`.github/workflows/test.yml`): 12 | 13 | - Deno: entrypoint=mod.ts, lint_docs=false 14 | - Bun: jsr deps: @std/assert @std/async @cross/runtime; npm deps: sinon 15 | - Node: test_target=*.test.ts; jsr deps: @std/assert @std/async @cross/runtime; npm deps: sinon 16 | 17 | Do before you commit: 18 | 19 | - Deno: deno fmt --check; deno lint; deno check mod.ts; deno test -A 20 | - Bun: tests run with bun test after jsr/npm deps install 21 | - Node (18/20/22): tests run with tsx; ESM required 22 | 23 | Keep in mind: 24 | 25 | - Don't break the public entrypoint (mod.ts). If you change it, update test.yml. 26 | - Prefer minimal diffs and stable public APIs. 27 | - New deps must resolve via JSR/NPM across Deno/Bun/Node. 28 | - Keep this file (AGENTS.md) lean if requested to add stuff. 29 | - This is a cross-runtime testing framework - changes must work identically across all three runtimes. 30 | 31 | Docs: 32 | 33 | - Keep README concise and focused on usage examples. 34 | - Inline documentation in mod.ts should be comprehensive for JSR docs. 35 | 36 | Network access (Copilot workspace): 37 | 38 | - npmjs.org, registry.npmjs.org, deno.land, jsr.io 39 | - github.com, raw.githubusercontent.com, bun.sh 40 | -------------------------------------------------------------------------------- /.github/workflows/copilot-setup-steps.yml: -------------------------------------------------------------------------------- 1 | name: "Copilot Setup Steps" 2 | 3 | # Automatically run the setup steps when they are changed to allow for easy validation, and 4 | # allow manual testing through the repository's "Actions" tab 5 | on: 6 | workflow_dispatch: 7 | push: 8 | paths: 9 | - .github/workflows/copilot-setup-steps.yml 10 | pull_request: 11 | paths: 12 | - .github/workflows/copilot-setup-steps.yml 13 | 14 | jobs: 15 | # The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot. 16 | copilot-setup-steps: 17 | runs-on: ubuntu-latest 18 | 19 | # Set the permissions to the lowest permissions possible needed for your steps. 20 | # Copilot will be given its own token for its operations. 21 | permissions: 22 | # If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete. 23 | contents: read 24 | 25 | # You can define any steps you want, and they will run before the agent starts. 26 | # If you do not check out your code, Copilot will do this for you. 27 | steps: 28 | - name: Checkout code 29 | uses: actions/checkout@v5 30 | 31 | - name: Setup Deno 32 | uses: denoland/setup-deno@v2 33 | with: 34 | deno-version: v2.x 35 | cache: true 36 | 37 | - name: Setup Node.js 38 | uses: actions/setup-node@v4 39 | with: 40 | node-version: "lts/*" 41 | 42 | - name: Setup Bun 43 | uses: oven-sh/setup-bun@v2 44 | with: 45 | bun-version: latest 46 | 47 | - name: Install dependencies 48 | run: deno install 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | .npmrc 133 | deno.lock 134 | bun.lockb 135 | package.json 136 | bunfig.toml 137 | package-lock.json -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | import { CurrentRuntime, Runtime } from "@cross/runtime"; 2 | 3 | /** 4 | * Simple step function without context or callback 5 | */ 6 | export type SimpleStepFunction = () => void | Promise; 7 | 8 | /** 9 | * Context step function - function with context parameter for nested steps 10 | */ 11 | export type ContextStepFunction = (context: TestContext) => void | Promise; 12 | 13 | /** 14 | * Step subject - the function executed within a step with context and callback support 15 | */ 16 | export type StepSubject = (context: TestContext, done: (value?: unknown) => void) => void | Promise; 17 | 18 | /** 19 | * Step options 20 | */ 21 | export interface StepOptions { 22 | waitForCallback?: boolean; // Whether to wait for the done-callback to be called 23 | } 24 | 25 | /** 26 | * Step function for nested tests - supports simple functions, context functions, and callback functions 27 | */ 28 | export type StepFunction = { 29 | (name: string, fn: SimpleStepFunction): Promise; 30 | (name: string, fn: ContextStepFunction): Promise; 31 | (name: string, fn: StepSubject, options: StepOptions): Promise; 32 | }; 33 | 34 | /** 35 | * Test context with step support 36 | */ 37 | export interface TestContext { 38 | /** 39 | * Run a sub-test as a step of the parent test 40 | * @param name - The name of the step 41 | * @param fn - The function to run for this step 42 | * @param options - Optional configuration for the step 43 | */ 44 | step: StepFunction; 45 | } 46 | 47 | /** 48 | * Test subject 49 | */ 50 | export type TestSubject = (context: TestContext, done: (value?: unknown) => void) => void | Promise; 51 | 52 | /** 53 | * Runtime independent test function 54 | */ 55 | export interface WrappedTest { 56 | (name: string, testFn: TestSubject, options?: WrappedTestOptions): Promise; 57 | } 58 | 59 | /** 60 | * Runtime independent test options 61 | */ 62 | export interface WrappedTestOptions { 63 | timeout?: number; // Timeout duration in milliseconds (optional) 64 | skip?: boolean; // Whether to skip the test (optional) 65 | waitForCallback?: boolean; // Whether to wait for the done-callback to be called 66 | } 67 | 68 | /** 69 | * Browser test result entry 70 | */ 71 | export interface BrowserTestResult { 72 | name: string; 73 | passed: boolean; 74 | error?: Error; 75 | duration: number; 76 | } 77 | 78 | /** 79 | * Type for browser-only helper functions 80 | */ 81 | type BrowserTestHelpers = { 82 | getTestResults: () => BrowserTestResult[]; 83 | printTestSummary: () => void; 84 | }; 85 | 86 | let wrappedTestToUse: WrappedTest; 87 | let browserHelpers: BrowserTestHelpers | undefined; 88 | 89 | if (CurrentRuntime == Runtime.Deno) { 90 | const { wrappedTest } = await import("./shims/deno.ts"); 91 | // @ts-ignore js 92 | wrappedTestToUse = wrappedTest; 93 | } else if (CurrentRuntime == Runtime.Node) { 94 | const { wrappedTest } = await import("./shims/node.ts"); 95 | // @ts-ignore js 96 | wrappedTestToUse = wrappedTest; 97 | } else if (CurrentRuntime == Runtime.Bun) { 98 | const { wrappedTest } = await import("./shims/bun.ts"); 99 | // @ts-ignore js 100 | wrappedTestToUse = wrappedTest; 101 | } else if (CurrentRuntime == Runtime.Browser) { 102 | const browserShim = await import("./shims/browser.ts"); 103 | // @ts-ignore js 104 | wrappedTestToUse = browserShim.wrappedTest; 105 | browserHelpers = { 106 | getTestResults: browserShim.getTestResults, 107 | printTestSummary: browserShim.printTestSummary, 108 | }; 109 | } else { 110 | throw new Error("Unsupported runtime"); 111 | } 112 | /** 113 | * Defines and executes a single test. 114 | * @param name - The name of the test. 115 | * @param options? - Options for the test (structure depends on your shim) 116 | * @param testFn - The function containing the test logic. 117 | */ 118 | export async function test(name: string, testFn: TestSubject, options: WrappedTestOptions = {}) { 119 | await wrappedTestToUse(name, testFn, options); 120 | } 121 | 122 | /** 123 | * Get a summary of all test results (browser only). 124 | * Returns undefined when not running in a browser environment. 125 | * Useful for integrating with CI systems or custom reporting. 126 | */ 127 | export function getTestResults(): BrowserTestResult[] | undefined { 128 | return browserHelpers?.getTestResults(); 129 | } 130 | 131 | /** 132 | * Print a summary of all test results to the console (browser only). 133 | * Does nothing when not running in a browser environment. 134 | * Call this at the end of your test file to see the overall results. 135 | */ 136 | export function printTestSummary(): void { 137 | browserHelpers?.printTestSummary(); 138 | } 139 | -------------------------------------------------------------------------------- /shims/bun.ts: -------------------------------------------------------------------------------- 1 | import { test } from "bun:test"; 2 | import type { ContextStepFunction, SimpleStepFunction, StepOptions, StepSubject, TestContext, TestSubject, WrappedTestOptions } from "../mod.ts"; 3 | 4 | export async function wrappedTest( 5 | name: string, 6 | testFn: TestSubject, 7 | options: WrappedTestOptions, 8 | ): Promise { 9 | // Build Bun test options - pass timeout to Bun's native test runner 10 | // If timeout is 0 or not specified, Bun will use its default 5s timeout 11 | // If we're using custom timeout logic, we need to set a high value for Bun 12 | const bunOptions: { timeout?: number; skip?: boolean } = { 13 | skip: options?.skip || false, 14 | }; 15 | 16 | // If a timeout is specified and > 0, pass it to Bun's native timeout 17 | // This makes Bun's timeout and our custom timeout work together 18 | if (options.timeout && options.timeout > 0) { 19 | bunOptions.timeout = options.timeout; 20 | } 21 | 22 | return await test(name, async () => { 23 | // Create wrapped context with step method 24 | const wrappedContext: TestContext = { 25 | // deno-lint-ignore no-explicit-any 26 | step: async (_stepName: string, stepFn: SimpleStepFunction | ContextStepFunction | StepSubject, stepOptions?: StepOptions): Promise => { 27 | // Bun doesn't support nested tests like Deno, so we run steps inline 28 | // We could log the step name for debugging if needed 29 | 30 | // Check function arity to determine how to handle it: 31 | // - length 0: Simple function with no parameters 32 | // - length 1: Function with context parameter for nesting 33 | // - length 2: Function with context and done callback 34 | const isSimpleFunction = stepFn.length === 0; 35 | const isContextFunction = stepFn.length === 1 && !stepOptions?.waitForCallback; 36 | const isCallbackFunction = stepOptions?.waitForCallback === true; 37 | 38 | if (isSimpleFunction && !isCallbackFunction) { 39 | // Simple function without context or callback 40 | await (stepFn as SimpleStepFunction)(); 41 | } else if (isContextFunction) { 42 | // Function with context parameter - create proper nested context 43 | const nestedWrappedContext: TestContext = createNestedContext(); 44 | await (stepFn as (context: TestContext) => void | Promise)(nestedWrappedContext); 45 | } else { 46 | // Callback-based function 47 | const nestedWrappedContext: TestContext = createNestedContext(); 48 | let stepFnPromise = undefined; 49 | const stepCallbackPromise = new Promise((resolve, reject) => { 50 | stepFnPromise = (stepFn as StepSubject)(nestedWrappedContext, (e) => { 51 | if (e) reject(e); 52 | else resolve(0); 53 | }); 54 | }); 55 | if (stepOptions?.waitForCallback) await stepCallbackPromise; 56 | await stepFnPromise; 57 | } 58 | }, 59 | }; 60 | 61 | // Helper function to create nested context with proper step support 62 | function createNestedContext(): TestContext { 63 | return { 64 | // deno-lint-ignore no-explicit-any 65 | step: async (_nestedStepName: string, nestedStepFn: SimpleStepFunction | ContextStepFunction | StepSubject, nestedStepOptions?: StepOptions): Promise => { 66 | const isNestedSimple = nestedStepFn.length === 0; 67 | const isNestedContext = nestedStepFn.length === 1 && !nestedStepOptions?.waitForCallback; 68 | const isNestedCallback = nestedStepOptions?.waitForCallback === true; 69 | 70 | if (isNestedSimple && !isNestedCallback) { 71 | await (nestedStepFn as SimpleStepFunction)(); 72 | } else if (isNestedContext) { 73 | // Recursive: create another level of nesting 74 | const deeperWrappedContext = createNestedContext(); 75 | await (nestedStepFn as (context: TestContext) => void | Promise)(deeperWrappedContext); 76 | } else { 77 | // Callback-based nested step 78 | const deeperWrappedContext = createNestedContext(); 79 | let nestedStepFnPromise = undefined; 80 | const nestedCallbackPromise = new Promise((resolve, reject) => { 81 | nestedStepFnPromise = (nestedStepFn as StepSubject)(deeperWrappedContext, (e) => { 82 | if (e) reject(e); 83 | else resolve(0); 84 | }); 85 | }); 86 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 87 | await nestedStepFnPromise; 88 | } 89 | }, 90 | }; 91 | } 92 | 93 | // Adapt the context here 94 | let testFnPromise = undefined; 95 | const callbackPromise = new Promise((resolve, reject) => { 96 | testFnPromise = testFn(wrappedContext, (e) => { 97 | if (e) reject(e); 98 | else resolve(0); 99 | }); 100 | }); 101 | let timeoutId: number = -1; // Store the timeout ID 102 | try { 103 | if (options.timeout && options.timeout > 0) { 104 | const timeoutPromise = new Promise((_, reject) => { 105 | timeoutId = setTimeout(() => { 106 | reject(new Error("Test timed out")); 107 | }, options.timeout); 108 | }); 109 | await Promise.race([options.waitForCallback ? callbackPromise : testFnPromise, timeoutPromise]); 110 | } else { 111 | // No timeout, just await testFn 112 | await options.waitForCallback ? callbackPromise : testFnPromise; 113 | } 114 | } finally { 115 | if (timeoutId !== -1) clearTimeout(timeoutId); 116 | // Make sure testFnPromise has completed 117 | await testFnPromise; 118 | if (options.waitForCallback) await callbackPromise; 119 | } 120 | }, bunOptions); 121 | } 122 | -------------------------------------------------------------------------------- /shims/deno.ts: -------------------------------------------------------------------------------- 1 | import type { ContextStepFunction, SimpleStepFunction, StepOptions, StepSubject, TestContext, TestSubject, WrappedTestOptions } from "../mod.ts"; // Assuming cross runtime types are here 2 | 3 | export function wrappedTest(name: string, testFn: TestSubject, options: WrappedTestOptions): Promise { 4 | // @ts-ignore The Deno namespace isn't available in Node or Bun 5 | Deno.test({ 6 | name, 7 | ignore: options?.skip || false, 8 | async fn(context) { 9 | // Create wrapped context with step method 10 | const wrappedContext: TestContext = { 11 | // deno-lint-ignore no-explicit-any 12 | step: async (stepName: string, stepFn: SimpleStepFunction | ContextStepFunction | StepSubject, stepOptions?: StepOptions): Promise => { 13 | // Check function arity to determine how to handle it: 14 | // - length 0: Simple function with no parameters 15 | // - length 1: Function with context parameter for nesting 16 | // - length 2: Function with context and done callback 17 | const isSimpleFunction = stepFn.length === 0; 18 | const isContextFunction = stepFn.length === 1 && !stepOptions?.waitForCallback; 19 | const isCallbackFunction = stepOptions?.waitForCallback === true; 20 | 21 | // @ts-ignore context.step exists in Deno 22 | await context.step(stepName, async (stepContext) => { 23 | if (isSimpleFunction && !isCallbackFunction) { 24 | // Simple function without context or callback 25 | await (stepFn as SimpleStepFunction)(); 26 | } else if (isContextFunction) { 27 | // Function with context parameter - create proper nested context 28 | const nestedWrappedContext: TestContext = createNestedContext(stepContext); 29 | await (stepFn as (context: TestContext) => void | Promise)(nestedWrappedContext); 30 | } else { 31 | // Callback-based function 32 | const nestedWrappedContext: TestContext = createNestedContext(stepContext); 33 | let stepFnPromise = undefined; 34 | const stepCallbackPromise = new Promise((resolve, reject) => { 35 | stepFnPromise = (stepFn as StepSubject)(nestedWrappedContext, (e) => { 36 | if (e) reject(e); 37 | else resolve(0); 38 | }); 39 | }); 40 | if (stepOptions?.waitForCallback) await stepCallbackPromise; 41 | await stepFnPromise; 42 | } 43 | }); 44 | }, 45 | }; 46 | 47 | // Helper function to create nested context with proper step support 48 | // deno-lint-ignore no-explicit-any 49 | function createNestedContext(denoContext: any): TestContext { 50 | return { 51 | // deno-lint-ignore no-explicit-any 52 | step: async (nestedStepName: string, nestedStepFn: SimpleStepFunction | ContextStepFunction | StepSubject, nestedStepOptions?: StepOptions): Promise => { 53 | const isNestedSimple = nestedStepFn.length === 0; 54 | const isNestedContext = nestedStepFn.length === 1 && !nestedStepOptions?.waitForCallback; 55 | const isNestedCallback = nestedStepOptions?.waitForCallback === true; 56 | 57 | if (denoContext && typeof denoContext.step === "function") { 58 | // @ts-ignore context.step exists in Deno 59 | await denoContext.step(nestedStepName, async (deeperContext) => { 60 | if (isNestedSimple && !isNestedCallback) { 61 | await (nestedStepFn as SimpleStepFunction)(); 62 | } else if (isNestedContext) { 63 | // Recursive: create another level of nesting 64 | const deeperWrappedContext = createNestedContext(deeperContext); 65 | await (nestedStepFn as (context: TestContext) => void | Promise)(deeperWrappedContext); 66 | } else { 67 | // Callback-based nested step 68 | const deeperWrappedContext = createNestedContext(deeperContext); 69 | let nestedStepFnPromise = undefined; 70 | const nestedCallbackPromise = new Promise((resolve, reject) => { 71 | nestedStepFnPromise = (nestedStepFn as StepSubject)(deeperWrappedContext, (e) => { 72 | if (e) reject(e); 73 | else resolve(0); 74 | }); 75 | }); 76 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 77 | await nestedStepFnPromise; 78 | } 79 | }); 80 | } else { 81 | // Fallback: execute step directly without Deno nesting when context lacks step method or is undefined 82 | // This can occur at deeper nesting levels where Deno's context.step may not be available 83 | if (isNestedSimple && !isNestedCallback) { 84 | await (nestedStepFn as SimpleStepFunction)(); 85 | } else if (isNestedContext) { 86 | // Create a fallback context for deeper nesting 87 | const fallbackContext = createNestedContext(undefined); 88 | await (nestedStepFn as (context: TestContext) => void | Promise)(fallbackContext); 89 | } else { 90 | // Callback-based step without Deno context 91 | const fallbackContext = createNestedContext(undefined); 92 | let nestedStepFnPromise = undefined; 93 | const nestedCallbackPromise = new Promise((resolve, reject) => { 94 | nestedStepFnPromise = (nestedStepFn as StepSubject)(fallbackContext, (e) => { 95 | if (e) reject(e); 96 | else resolve(0); 97 | }); 98 | }); 99 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 100 | await nestedStepFnPromise; 101 | } 102 | } 103 | }, 104 | }; 105 | } 106 | 107 | // Adapt the context here 108 | let testFnPromise = undefined; 109 | const callbackPromise = new Promise((resolve, reject) => { 110 | testFnPromise = testFn(wrappedContext, (e) => { 111 | if (e) reject(e); 112 | else resolve(0); 113 | }); 114 | }); 115 | let timeoutId: number = -1; // Store the timeout ID 116 | try { 117 | if (options.timeout && options.timeout > 0) { 118 | const timeoutPromise = new Promise((_, reject) => { 119 | timeoutId = setTimeout(() => { 120 | reject(new Error("Test timed out")); 121 | }, options.timeout); 122 | }); 123 | await Promise.race([options.waitForCallback ? callbackPromise : testFnPromise, timeoutPromise]); 124 | } else { 125 | await options.waitForCallback ? callbackPromise : testFnPromise; 126 | } 127 | } finally { 128 | if (timeoutId !== -1) clearTimeout(timeoutId); 129 | await testFnPromise; 130 | if (options.waitForCallback) await callbackPromise; 131 | } 132 | }, 133 | }); 134 | return Promise.resolve(); 135 | } 136 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Cross-runtime Testing for Deno, Bun, Node.js, and Browsers 2 | 3 | [![JSR Version](https://jsr.io/badges/@cross/test?v=bust)](https://jsr.io/@cross/test) [![JSR Score](https://jsr.io/badges/@cross/test/score?v=bust)](https://jsr.io/@cross/test/score) 4 | 5 | A minimal, focused testing framework for writing tests that run identically across Deno, Bun, Node.js, and browsers. Part of the @cross suite - check out our growing collection of cross-runtime tools 6 | at [github.com/cross-org](https://github.com/cross-org). 7 | 8 | ### Why @cross/test? 9 | 10 | While `node:test` now works across runtimes, @cross/test provides unique advantages: 11 | 12 | - **Unified Simple API** - Single `test()` function with consistent behavior across all runtimes 13 | - **JSR-First** - Seamlessly works with JSR packages like `@std/assert` and `@std/expect` 14 | - **Test Steps** - Built-in `context.step()` support for organizing tests into sequential steps with shared state 15 | - **Callback Support** - Native `waitForCallback` option for callback-based async tests 16 | - **Browser Support** - Run the same tests in browser environments with console output 17 | - **Minimal Surface** - Focused API that abstracts runtime differences without bloat 18 | 19 | ### Installation 20 | 21 | Install `@cross/test` along with the assertion library of your choice. We recommend using `@std/assert` for consistency across runtimes: 22 | 23 | ```bash 24 | # Pick your runtime and package manager: 25 | npx jsr add @cross/test @std/assert # Node.js 26 | deno add jsr:@cross/test jsr:@std/assert # Deno 27 | bunx jsr add @cross/test @std/assert # Bun 28 | ``` 29 | 30 | ### Examples 31 | 32 | #### Simple tests with @std/assert 33 | 34 | ```javascript 35 | import { test } from "@cross/test"; 36 | import { assertEquals, assertNotEquals } from "@std/assert"; 37 | 38 | test("Multiplication", () => { 39 | assertEquals(5 * 4, 20); 40 | }); 41 | 42 | test("Test with timeout", () => { 43 | assertEquals(5 * 4, 20); 44 | }, { timeout: 1000 }); 45 | 46 | // Callback-based async test (unique to @cross/test) 47 | test("Callback-based async", (_context, done) => { 48 | setTimeout(() => { 49 | assertNotEquals(5, 4); 50 | done(); 51 | }, 500); 52 | }, { waitForCallback: true }); 53 | ``` 54 | 55 | #### Test steps for sequential workflows 56 | 57 | Organize tests into steps with shared state - perfect for integration tests and workflows: 58 | 59 | ```javascript 60 | import { test } from "@cross/test"; 61 | import { assertEquals } from "@std/assert"; 62 | 63 | test("User registration flow", async (context) => { 64 | let userId; 65 | 66 | await context.step("Create user", () => { 67 | userId = createUser("john@example.com"); 68 | assertEquals(typeof userId, "string"); 69 | }); 70 | 71 | await context.step("Verify user exists", () => { 72 | const user = getUser(userId); 73 | assertEquals(user.email, "john@example.com"); 74 | }); 75 | 76 | await context.step("Delete user", () => { 77 | deleteUser(userId); 78 | assertEquals(getUser(userId), null); 79 | }); 80 | }); 81 | ``` 82 | 83 | Steps share the parent test's scope and execute sequentially, making complex test flows easy to write and debug. 84 | 85 | #### Callback support in test steps 86 | 87 | Just like tests, steps can also use callbacks for async operations with the `waitForCallback` option: 88 | 89 | ```javascript 90 | import { test } from "@cross/test"; 91 | import { assertEquals } from "@std/assert"; 92 | 93 | test("Test with callback-based steps", async (context) => { 94 | let completed = false; 95 | 96 | await context.step("Async operation", (_context, done) => { 97 | setTimeout(() => { 98 | completed = true; 99 | done(); 100 | }, 100); 101 | }, { waitForCallback: true }); 102 | 103 | assertEquals(completed, true); 104 | }); 105 | ``` 106 | 107 | #### Spying, mocking and stubbing using sinon 108 | 109 | ```js 110 | import { test } from "@cross/test"; 111 | import { assertEquals } from "@std/assert"; 112 | import sinon from "sinon"; 113 | 114 | // Prepare the "environment" 115 | function bar() {/*...*/} 116 | export const funcs = { 117 | bar, 118 | }; 119 | export function foo() { 120 | funcs.bar(); 121 | } 122 | 123 | test("calls bar during execution of foo", () => { 124 | const spy = sinon.spy(funcs, "bar"); 125 | 126 | foo(); 127 | 128 | assertEquals(spy.called, true); 129 | assertEquals(spy.getCalls().length, 1); 130 | }); 131 | ``` 132 | 133 | ### Running the tests 134 | 135 | - **Node.js:** `node --test` 136 | - **Node.js (TS):** `npx tsx --test` _Remember `{ "type": "module" }` in package.json_ 137 | - **Deno:** `deno test` 138 | - **Bun:** `bun test` 139 | - **Browser:** Include the bundled test file in an HTML page (see below) 140 | 141 | ### Browser Usage 142 | 143 | @cross/test can run tests directly in the browser. Results are output to the browser's developer console with styled formatting. 144 | 145 | ```html 146 | 147 | 148 | 149 | Browser Tests 150 | 151 | 152 | 167 | 168 | 169 | ``` 170 | 171 | The browser shim provides: 172 | 173 | - `test()` - Same API as other runtimes 174 | - `getTestResults()` - Get an array of test results for custom reporting 175 | - `printTestSummary()` - Print a formatted summary to the console 176 | 177 | ### Configuring CI 178 | 179 | - **Bun (GitHub Actions):** 180 | 181 | ```yaml 182 | name: Bun CI 183 | 184 | on: [push, pull_request] 185 | 186 | jobs: 187 | test: 188 | runs-on: ubuntu-latest 189 | steps: 190 | - uses: actions/checkout@v3 191 | - uses: antongolub/action-setup-bun@v1.12.8 192 | with: 193 | bun-version: v1.x # Uses latest bun 1 194 | - run: bun x jsr add @cross/test @std/assert # Installs dependencies 195 | - run: bun test # Runs the tests 196 | ``` 197 | 198 | - **Deno (GitHub Actions):** 199 | 200 | ```yaml 201 | name: Deno CI 202 | 203 | on: [push, pull_request] 204 | 205 | jobs: 206 | test: 207 | runs-on: ubuntu-latest 208 | steps: 209 | - uses: actions/checkout@v4 210 | - uses: denoland/setup-deno@v1 211 | with: 212 | deno-version: v1.x # Uses latest deno version 1 213 | - run: deno add @cross/test @std/assert # Installs dependencies from jsr.io 214 | - run: deno test # Runs tests 215 | ``` 216 | 217 | - **Node (GitHub actions):** 218 | 219 | ```yaml 220 | name: Node.js CI 221 | 222 | on: 223 | push: 224 | branches: [main] 225 | pull_request: 226 | branches: [main] 227 | 228 | jobs: 229 | build: 230 | runs-on: ubuntu-latest 231 | 232 | strategy: 233 | matrix: 234 | node-version: [18.x, 21.x] 235 | 236 | steps: 237 | - uses: actions/checkout@v3 238 | - run: npx jsr add @cross/test @std/assert 239 | - run: "echo '{ \"type\": \"module\" }' > package.json" # Needed for tsx to work 240 | - run: npx --yes tsx --test *.test.ts 241 | ``` 242 | -------------------------------------------------------------------------------- /mod.test.ts: -------------------------------------------------------------------------------- 1 | import { test } from "./mod.ts"; 2 | import { assertEquals, assertNotEquals } from "@std/assert"; 3 | import { delay } from "@std/async"; 4 | 5 | // Simple test 6 | test("Multiplication", () => { 7 | assertEquals(5 * 4, 20); 8 | }); 9 | 10 | // Simple test with timeout 11 | test("Multiplication with timeout", () => { 12 | assertEquals(5 * 4, 20); 13 | }, { timeout: 1000 }); 14 | 15 | // Failing async test with done callback 16 | test("Long async test", (_context, done) => { 17 | setTimeout(() => { 18 | assertNotEquals(5, 4); 19 | done(); // Signal test completion 20 | }, 500); 21 | }, { waitForCallback: true }); 22 | 23 | // Test with done callback (useful for async operations) 24 | test("Async test", (_context, done) => { 25 | setTimeout(() => { 26 | assertNotEquals(5, 4); 27 | done(); // Signal test completion 28 | }, 4500); 29 | }, { waitForCallback: true, timeout: 5500 }); 30 | 31 | // Test async 32 | test("async hello world", async () => { 33 | const x = 1 + 2; 34 | 35 | // await some async task 36 | await delay(100); 37 | 38 | if (x !== 3) { 39 | throw Error("x should be equal to 3"); 40 | } 41 | }); 42 | 43 | /* Test sinon */ 44 | import sinon from "sinon"; 45 | 46 | function bar() {/*...*/} 47 | 48 | export const funcs = { 49 | bar, 50 | }; 51 | 52 | // 'foo' no longer takes a parameter, but calls 'bar' from an object 53 | export function foo() { 54 | funcs.bar(); 55 | } 56 | 57 | test("calls bar during execution of foo", () => { 58 | // create a test spy that wraps 'bar' on the 'funcs' object 59 | const spy = sinon.spy(funcs, "bar"); 60 | 61 | // call function 'foo' without an argument 62 | foo(); 63 | 64 | assertEquals(spy.called, true); 65 | assertEquals(spy.getCalls().length, 1); 66 | }); 67 | 68 | // Test with nested steps 69 | test("Parent test with nested steps", async (context) => { 70 | const results: number[] = []; 71 | 72 | await context.step("Step 1: Initialize", () => { 73 | results.push(1); 74 | assertEquals(results.length, 1); 75 | }); 76 | 77 | await context.step("Step 2: Add more data", () => { 78 | results.push(2); 79 | assertEquals(results.length, 2); 80 | }); 81 | 82 | await context.step("Step 3: Verify final state", () => { 83 | results.push(3); 84 | assertEquals(results, [1, 2, 3]); 85 | }); 86 | }); 87 | 88 | // Test with async nested steps 89 | test("Async parent test with nested steps", async (context) => { 90 | let counter = 0; 91 | 92 | await context.step("Async step 1", async () => { 93 | await delay(10); 94 | counter++; 95 | assertEquals(counter, 1); 96 | }); 97 | 98 | await context.step("Async step 2", async () => { 99 | await delay(10); 100 | counter++; 101 | assertEquals(counter, 2); 102 | }); 103 | }); 104 | 105 | // Test with callback-based step (testing the new feature) 106 | test("Test with callback-based steps", async (context) => { 107 | let stepCompleted = false; 108 | 109 | await context.step("Callback-based step", (_stepContext, done) => { 110 | setTimeout(() => { 111 | stepCompleted = true; 112 | assertEquals(stepCompleted, true); 113 | done(); 114 | }, 100); 115 | }, { waitForCallback: true }); 116 | 117 | // Verify step completed before moving on 118 | assertEquals(stepCompleted, true); 119 | }); 120 | 121 | // Test with multiple callback-based steps 122 | test("Test with multiple callback-based steps", async (context) => { 123 | const results: number[] = []; 124 | 125 | await context.step("Step 1 with callback", (_stepContext, done) => { 126 | setTimeout(() => { 127 | results.push(1); 128 | assertEquals(results.length, 1); 129 | done(); 130 | }, 50); 131 | }, { waitForCallback: true }); 132 | 133 | await context.step("Step 2 with callback", (_stepContext, done) => { 134 | setTimeout(() => { 135 | results.push(2); 136 | assertEquals(results.length, 2); 137 | done(); 138 | }, 50); 139 | }, { waitForCallback: true }); 140 | 141 | await context.step("Step 3 verify", () => { 142 | assertEquals(results, [1, 2]); 143 | }); 144 | }); 145 | 146 | // Test two-level nesting (steps within steps) 147 | test("Two-level nested steps", async (context) => { 148 | let counter = 0; 149 | 150 | await context.step("Level 1", async (context: import("./mod.ts").TestContext) => { 151 | counter++; 152 | assertEquals(counter, 1); 153 | 154 | await context.step("Level 2 - step 1", () => { 155 | counter++; 156 | assertEquals(counter, 2); 157 | }); 158 | 159 | await context.step("Level 2 - step 2", () => { 160 | counter++; 161 | assertEquals(counter, 3); 162 | }); 163 | }); 164 | 165 | assertEquals(counter, 3); 166 | }); 167 | 168 | // Test three-level nesting 169 | test("Three-level nested steps", async (context) => { 170 | let counter = 0; 171 | 172 | await context.step("Level 1", async (context: import("./mod.ts").TestContext) => { 173 | counter++; 174 | assertEquals(counter, 1); 175 | 176 | await context.step("Level 2", async (context: import("./mod.ts").TestContext) => { 177 | counter++; 178 | assertEquals(counter, 2); 179 | 180 | await context.step("Level 3", () => { 181 | counter++; 182 | assertEquals(counter, 3); 183 | }); 184 | }); 185 | }); 186 | 187 | assertEquals(counter, 3); 188 | }); 189 | 190 | // Test complex nested hierarchy with multiple branches 191 | test("Complex nested hierarchy", async (context) => { 192 | const visited: string[] = []; 193 | 194 | await context.step("Root", async (context: import("./mod.ts").TestContext) => { 195 | visited.push("root"); 196 | 197 | await context.step("Branch A", async (context: import("./mod.ts").TestContext) => { 198 | visited.push("a"); 199 | 200 | await context.step("Branch A1", () => { 201 | visited.push("a1"); 202 | }); 203 | 204 | await context.step("Branch A2", () => { 205 | visited.push("a2"); 206 | }); 207 | }); 208 | 209 | await context.step("Branch B", async (context: import("./mod.ts").TestContext) => { 210 | visited.push("b"); 211 | 212 | await context.step("Branch B1", () => { 213 | visited.push("b1"); 214 | }); 215 | }); 216 | }); 217 | 218 | assertEquals(visited, ["root", "a", "a1", "a2", "b", "b1"]); 219 | }); 220 | 221 | // Test three-level nesting with mixed async/callback operations 222 | test("Three-Level Nesting with Mixed Operations", async (context) => { 223 | let executionCount = 0; 224 | 225 | await context.step("Level 1", async (context: import("./mod.ts").TestContext) => { 226 | executionCount++; 227 | assertEquals(executionCount, 1); 228 | 229 | await context.step("Level 2", async (context: import("./mod.ts").TestContext) => { 230 | executionCount++; 231 | assertEquals(executionCount, 2); 232 | 233 | // Test Level 3 with async operation 234 | await context.step("Level 3 - Async", async () => { 235 | executionCount++; 236 | await new Promise((resolve) => setTimeout(() => resolve(undefined), 5)); 237 | assertEquals(executionCount, 3); 238 | }); 239 | 240 | // Test Level 3 with callback operation 241 | await context.step("Level 3 - Callback", (_context: import("./mod.ts").TestContext, done: (value?: unknown) => void) => { 242 | executionCount++; 243 | setTimeout(() => { 244 | assertEquals(executionCount, 4); 245 | done(); 246 | }, 5); 247 | }, { waitForCallback: true }); 248 | }); 249 | }); 250 | 251 | assertEquals(executionCount, 4); 252 | }); 253 | 254 | // Test timeout with zero value (should be treated as no timeout) 255 | test("Test with timeout = 0 (no timeout)", async () => { 256 | await delay(50); 257 | assertEquals(1, 1); 258 | }, { timeout: 0 }); 259 | 260 | // Test timeout with negative value (should be treated as no timeout) 261 | test("Test with negative timeout (no timeout)", async () => { 262 | await delay(50); 263 | assertEquals(1, 1); 264 | }, { timeout: -100 }); 265 | 266 | // Test that completes well within timeout 267 | test("Test completes within generous timeout", async () => { 268 | await delay(50); 269 | assertEquals(1, 1); 270 | }, { timeout: 5000 }); 271 | 272 | // Test longer execution with sufficient timeout (7 seconds with 10 second timeout) 273 | test("7 second execution with 10 second timeout", async () => { 274 | await delay(7000); 275 | assertEquals(1, 1); 276 | }, { timeout: 10000 }); 277 | -------------------------------------------------------------------------------- /shims/node.ts: -------------------------------------------------------------------------------- 1 | import { test } from "node:test"; // For type safety 2 | import type { ContextStepFunction, SimpleStepFunction, StepOptions, StepSubject, TestContext, WrappedTestOptions } from "../mod.ts"; // Shared options 3 | import type { TestSubject } from "../mod.ts"; 4 | 5 | function transformOptions(options?: WrappedTestOptions) { 6 | const nodeOptions: { skip: boolean; timeout?: number } = { 7 | skip: options?.skip || false, 8 | }; 9 | 10 | // Node.js requires timeout to be > 0 and <= 2147483647 11 | // Only set timeout if it's a valid positive number 12 | if (options?.timeout && options.timeout > 0) { 13 | nodeOptions.timeout = options.timeout; 14 | } 15 | 16 | return nodeOptions; 17 | } 18 | 19 | // Helper function to create a fallback context for older Node versions 20 | // This context has a no-op step function since nested tests aren't supported 21 | function createFallbackContext(): TestContext { 22 | return { 23 | // deno-lint-ignore no-explicit-any 24 | step: (): Promise => { 25 | // No-op: older Node versions don't support nested tests 26 | console.warn("Warning: Nested steps are not fully supported in this Node version. Consider upgrading to Node 18.17.0+"); 27 | return Promise.resolve(); 28 | }, 29 | }; 30 | } 31 | 32 | export function wrappedTest( 33 | name: string, 34 | testFn: TestSubject, 35 | options: WrappedTestOptions, 36 | ): Promise { 37 | // deno-lint-ignore no-explicit-any 38 | test(name, transformOptions(options), async (context: any) => { 39 | // Create wrapped context with step method 40 | const wrappedContext: TestContext = { 41 | // deno-lint-ignore no-explicit-any 42 | step: async (stepName: string, stepFn: SimpleStepFunction | ContextStepFunction | StepSubject, stepOptions?: StepOptions): Promise => { 43 | // Check function arity to determine how to handle it: 44 | // - length 0: Simple function with no parameters 45 | // - length 1: Function with context parameter for nesting 46 | // - length 2: Function with context and done callback 47 | const isSimpleFunction = stepFn.length === 0; 48 | const isContextFunction = stepFn.length === 1 && !stepOptions?.waitForCallback; 49 | const isCallbackFunction = stepOptions?.waitForCallback === true; 50 | 51 | // Node.js supports nested tests via test() within a test callback 52 | // Use context.test() if available (Node 18.17.0+), otherwise use global test() 53 | if (context && typeof context.test === "function") { 54 | // deno-lint-ignore no-explicit-any 55 | return await context.test(stepName, async (nestedContext: any) => { 56 | if (isSimpleFunction && !isCallbackFunction) { 57 | // Simple function without context or callback 58 | await (stepFn as SimpleStepFunction)(); 59 | } else if (isContextFunction) { 60 | // Function with context parameter - create proper nested context 61 | const nestedWrappedContext: TestContext = createNestedContext(nestedContext); 62 | await (stepFn as (context: TestContext) => void | Promise)(nestedWrappedContext); 63 | } else { 64 | // Callback-based function 65 | const nestedWrappedContext: TestContext = createNestedContext(nestedContext); 66 | let stepFnPromise = undefined; 67 | const stepCallbackPromise = new Promise((resolve, reject) => { 68 | stepFnPromise = (stepFn as StepSubject)(nestedWrappedContext, (e) => { 69 | if (e) reject(e); 70 | else resolve(0); 71 | }); 72 | }); 73 | if (stepOptions?.waitForCallback) await stepCallbackPromise; 74 | await stepFnPromise; 75 | } 76 | }); 77 | } else { 78 | // Fallback for older Node versions - run the step directly without nesting 79 | if (isSimpleFunction && !isCallbackFunction) { 80 | // Simple function without context or callback 81 | await (stepFn as SimpleStepFunction)(); 82 | } else if (isContextFunction) { 83 | // Function with context parameter - use fallback context 84 | const nestedWrappedContext = createFallbackContext(); 85 | await (stepFn as (context: TestContext) => void | Promise)(nestedWrappedContext); 86 | } else { 87 | // Callback-based function 88 | const nestedWrappedContext = createFallbackContext(); 89 | let stepFnPromise = undefined; 90 | const stepCallbackPromise = new Promise((resolve, reject) => { 91 | stepFnPromise = (stepFn as StepSubject)(nestedWrappedContext, (e) => { 92 | if (e) reject(e); 93 | else resolve(0); 94 | }); 95 | }); 96 | if (stepOptions?.waitForCallback) await stepCallbackPromise; 97 | await stepFnPromise; 98 | } 99 | } 100 | }, 101 | }; 102 | 103 | // Helper function to create nested context with proper step support 104 | // deno-lint-ignore no-explicit-any 105 | function createNestedContext(nodeContext: any): TestContext { 106 | return { 107 | // deno-lint-ignore no-explicit-any 108 | step: async (nestedStepName: string, nestedStepFn: SimpleStepFunction | ContextStepFunction | StepSubject, nestedStepOptions?: StepOptions): Promise => { 109 | const isNestedSimple = nestedStepFn.length === 0; 110 | const isNestedContext = nestedStepFn.length === 1 && !nestedStepOptions?.waitForCallback; 111 | const isNestedCallback = nestedStepOptions?.waitForCallback === true; 112 | 113 | if (nodeContext && typeof nodeContext.test === "function") { 114 | // deno-lint-ignore no-explicit-any 115 | return await nodeContext.test(nestedStepName, async (deeperContext: any) => { 116 | if (isNestedSimple && !isNestedCallback) { 117 | await (nestedStepFn as SimpleStepFunction)(); 118 | } else if (isNestedContext) { 119 | // Recursive: create another level of nesting 120 | const deeperWrappedContext = createNestedContext(deeperContext); 121 | await (nestedStepFn as (context: TestContext) => void | Promise)(deeperWrappedContext); 122 | } else { 123 | // Callback-based nested step 124 | const deeperWrappedContext = createNestedContext(deeperContext); 125 | let nestedStepFnPromise = undefined; 126 | const nestedCallbackPromise = new Promise((resolve, reject) => { 127 | nestedStepFnPromise = (nestedStepFn as StepSubject)(deeperWrappedContext, (e) => { 128 | if (e) reject(e); 129 | else resolve(0); 130 | }); 131 | }); 132 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 133 | await nestedStepFnPromise; 134 | } 135 | }); 136 | } else { 137 | // Fallback for older Node versions 138 | if (isNestedSimple && !isNestedCallback) { 139 | await (nestedStepFn as SimpleStepFunction)(); 140 | } else if (isNestedContext) { 141 | const fallbackContext = createFallbackContext(); 142 | await (nestedStepFn as (context: TestContext) => void | Promise)(fallbackContext); 143 | } else { 144 | const fallbackContext = createFallbackContext(); 145 | let nestedStepFnPromise = undefined; 146 | const nestedCallbackPromise = new Promise((resolve, reject) => { 147 | nestedStepFnPromise = (nestedStepFn as StepSubject)(fallbackContext, (e) => { 148 | if (e) reject(e); 149 | else resolve(0); 150 | }); 151 | }); 152 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 153 | await nestedStepFnPromise; 154 | } 155 | } 156 | }, 157 | }; 158 | } 159 | 160 | // Adapt the context here 161 | let testFnPromise = undefined; 162 | const callbackPromise = new Promise((resolve, reject) => { 163 | testFnPromise = testFn(wrappedContext, (e) => { 164 | if (e) reject(e); 165 | else resolve(0); 166 | }); 167 | }); 168 | if (options.waitForCallback) await callbackPromise; 169 | await testFnPromise; 170 | }); 171 | return Promise.resolve(); 172 | } 173 | -------------------------------------------------------------------------------- /shims/browser.ts: -------------------------------------------------------------------------------- 1 | import type { ContextStepFunction, SimpleStepFunction, StepOptions, StepSubject, TestContext, TestSubject, WrappedTestOptions } from "../mod.ts"; 2 | 3 | /** 4 | * Browser test runner - a minimal test runner for browser environments. 5 | * Results are logged to the console with styled output where supported. 6 | */ 7 | 8 | // Track test results for summary 9 | const testResults: Array<{ name: string; passed: boolean; error?: Error; duration: number }> = []; 10 | 11 | // Check if console supports styling (most modern browsers do) 12 | const supportsStyles = typeof window !== "undefined" && typeof console !== "undefined"; 13 | 14 | // Console styling for browser DevTools 15 | const styles = { 16 | pass: "color: #22c55e; font-weight: bold", 17 | fail: "color: #ef4444; font-weight: bold", 18 | skip: "color: #f59e0b; font-weight: bold", 19 | step: "color: #6366f1", 20 | info: "color: #64748b", 21 | }; 22 | 23 | function logResult(type: "pass" | "fail" | "skip" | "step" | "info", message: string): void { 24 | if (supportsStyles) { 25 | console.log(`%c${message}`, styles[type]); 26 | } else { 27 | console.log(message); 28 | } 29 | } 30 | 31 | export async function wrappedTest( 32 | name: string, 33 | testFn: TestSubject, 34 | options: WrappedTestOptions, 35 | ): Promise { 36 | // Handle skip option 37 | if (options?.skip) { 38 | logResult("skip", `⊘ SKIP: ${name}`); 39 | testResults.push({ name, passed: true, duration: 0 }); 40 | return; 41 | } 42 | 43 | const startTime = performance.now(); 44 | 45 | // Create wrapped context with step method 46 | const wrappedContext: TestContext = { 47 | // deno-lint-ignore no-explicit-any 48 | step: async (_stepName: string, stepFn: SimpleStepFunction | ContextStepFunction | StepSubject, stepOptions?: StepOptions): Promise => { 49 | // Browser doesn't have native nested test support, so we run steps inline 50 | // Check function arity to determine how to handle it: 51 | // - length 0: Simple function with no parameters 52 | // - length 1: Function with context parameter for nesting 53 | // - length 2: Function with context and done callback 54 | const isSimpleFunction = stepFn.length === 0; 55 | const isContextFunction = stepFn.length === 1 && !stepOptions?.waitForCallback; 56 | const isCallbackFunction = stepOptions?.waitForCallback === true; 57 | 58 | const stepStart = performance.now(); 59 | 60 | try { 61 | if (isSimpleFunction && !isCallbackFunction) { 62 | // Simple function without context or callback 63 | await (stepFn as SimpleStepFunction)(); 64 | } else if (isContextFunction) { 65 | // Function with context parameter - create proper nested context 66 | const nestedWrappedContext: TestContext = createNestedContext(); 67 | await (stepFn as (context: TestContext) => void | Promise)(nestedWrappedContext); 68 | } else { 69 | // Callback-based function 70 | const nestedWrappedContext: TestContext = createNestedContext(); 71 | let stepFnPromise = undefined; 72 | const stepCallbackPromise = new Promise((resolve, reject) => { 73 | stepFnPromise = (stepFn as StepSubject)(nestedWrappedContext, (e) => { 74 | if (e) reject(e); 75 | else resolve(0); 76 | }); 77 | }); 78 | if (stepOptions?.waitForCallback) await stepCallbackPromise; 79 | await stepFnPromise; 80 | } 81 | 82 | const stepDuration = performance.now() - stepStart; 83 | logResult("step", ` ✓ ${_stepName} (${stepDuration.toFixed(0)}ms)`); 84 | } catch (error) { 85 | const stepDuration = performance.now() - stepStart; 86 | logResult("fail", ` ✗ ${_stepName} (${stepDuration.toFixed(0)}ms)`); 87 | throw error; 88 | } 89 | }, 90 | }; 91 | 92 | // Helper function to create nested context with proper step support 93 | function createNestedContext(): TestContext { 94 | return { 95 | // deno-lint-ignore no-explicit-any 96 | step: async (_nestedStepName: string, nestedStepFn: SimpleStepFunction | ContextStepFunction | StepSubject, nestedStepOptions?: StepOptions): Promise => { 97 | const isNestedSimple = nestedStepFn.length === 0; 98 | const isNestedContext = nestedStepFn.length === 1 && !nestedStepOptions?.waitForCallback; 99 | const isNestedCallback = nestedStepOptions?.waitForCallback === true; 100 | 101 | const stepStart = performance.now(); 102 | 103 | try { 104 | if (isNestedSimple && !isNestedCallback) { 105 | await (nestedStepFn as SimpleStepFunction)(); 106 | } else if (isNestedContext) { 107 | // Recursive: create another level of nesting 108 | const deeperWrappedContext = createNestedContext(); 109 | await (nestedStepFn as (context: TestContext) => void | Promise)(deeperWrappedContext); 110 | } else { 111 | // Callback-based nested step 112 | const deeperWrappedContext = createNestedContext(); 113 | let nestedStepFnPromise = undefined; 114 | const nestedCallbackPromise = new Promise((resolve, reject) => { 115 | nestedStepFnPromise = (nestedStepFn as StepSubject)(deeperWrappedContext, (e) => { 116 | if (e) reject(e); 117 | else resolve(0); 118 | }); 119 | }); 120 | if (nestedStepOptions?.waitForCallback) await nestedCallbackPromise; 121 | await nestedStepFnPromise; 122 | } 123 | 124 | const stepDuration = performance.now() - stepStart; 125 | logResult("step", ` ✓ ${_nestedStepName} (${stepDuration.toFixed(0)}ms)`); 126 | } catch (error) { 127 | const stepDuration = performance.now() - stepStart; 128 | logResult("fail", ` ✗ ${_nestedStepName} (${stepDuration.toFixed(0)}ms)`); 129 | throw error; 130 | } 131 | }, 132 | }; 133 | } 134 | 135 | try { 136 | // Adapt the context here 137 | let testFnPromise = undefined; 138 | const callbackPromise = new Promise((resolve, reject) => { 139 | testFnPromise = testFn(wrappedContext, (e) => { 140 | if (e) reject(e); 141 | else resolve(0); 142 | }); 143 | }); 144 | let timeoutId: ReturnType | undefined; 145 | try { 146 | if (options.timeout) { 147 | const timeoutPromise = new Promise((_, reject) => { 148 | timeoutId = setTimeout(() => { 149 | reject(new Error("Test timed out")); 150 | }, options.timeout); 151 | }); 152 | await Promise.race([options.waitForCallback ? callbackPromise : testFnPromise, timeoutPromise]); 153 | } else { 154 | await options.waitForCallback ? callbackPromise : testFnPromise; 155 | } 156 | } finally { 157 | if (timeoutId) clearTimeout(timeoutId); 158 | // Make sure testFnPromise has completed 159 | await testFnPromise; 160 | if (options.waitForCallback) await callbackPromise; 161 | } 162 | 163 | const duration = performance.now() - startTime; 164 | logResult("pass", `✓ PASS: ${name} (${duration.toFixed(0)}ms)`); 165 | testResults.push({ name, passed: true, duration }); 166 | } catch (error) { 167 | const duration = performance.now() - startTime; 168 | logResult("fail", `✗ FAIL: ${name} (${duration.toFixed(0)}ms)`); 169 | if (error instanceof Error) { 170 | console.error(` Error: ${error.message}`); 171 | if (error.stack) { 172 | console.error(` Stack: ${error.stack}`); 173 | } 174 | testResults.push({ name, passed: false, error, duration }); 175 | } else { 176 | console.error(` Error: ${String(error)}`); 177 | testResults.push({ name, passed: false, error: new Error(String(error)), duration }); 178 | } 179 | } 180 | } 181 | 182 | /** 183 | * Get a summary of all test results. 184 | * Useful for integrating with CI systems or custom reporting. 185 | */ 186 | export function getTestResults(): Array<{ name: string; passed: boolean; error?: Error; duration: number }> { 187 | return [...testResults]; 188 | } 189 | 190 | /** 191 | * Print a summary of all test results. 192 | * Call this at the end of your test file to see the overall results. 193 | */ 194 | export function printTestSummary(): void { 195 | const passed = testResults.filter((r) => r.passed).length; 196 | const failed = testResults.filter((r) => !r.passed).length; 197 | const total = testResults.length; 198 | const totalDuration = testResults.reduce((acc, r) => acc + r.duration, 0); 199 | 200 | console.log("\n" + "=".repeat(50)); 201 | logResult("info", `Test Summary: ${passed}/${total} passed, ${failed} failed (${totalDuration.toFixed(0)}ms)`); 202 | 203 | if (failed > 0) { 204 | console.log("\nFailed tests:"); 205 | testResults.filter((r) => !r.passed).forEach((r) => { 206 | logResult("fail", ` ✗ ${r.name}`); 207 | if (r.error) { 208 | console.error(` ${r.error.message}`); 209 | } 210 | }); 211 | } 212 | } 213 | --------------------------------------------------------------------------------