Skip to content

Commit 8f197f0

Browse files
CopilotHexagon
andauthored
Add browser mode for running tests in browsers (#12)
Co-authored-by: Hexagon <419737+Hexagon@users.noreply.github.com>
1 parent df7f2e1 commit 8f197f0

4 files changed

Lines changed: 300 additions & 4 deletions

File tree

README.md

Lines changed: 41 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
## Cross-runtime Testing for Deno, Bun, and Node.js
1+
## Cross-runtime Testing for Deno, Bun, Node.js, and Browsers
22

33
[![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)
44

5-
A minimal, focused testing framework for writing tests that run identically across Deno, Bun, and Node.js. Part of the @cross suite - check out our growing collection of cross-runtime tools at
6-
[github.com/cross-org](https://github.com/cross-org).
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).
77

88
### Why @cross/test?
99

@@ -13,6 +13,7 @@ While `node:test` now works across runtimes, @cross/test provides unique advanta
1313
- **JSR-First** - Seamlessly works with JSR packages like `@std/assert` and `@std/expect`
1414
- **Test Steps** - Built-in `context.step()` support for organizing tests into sequential steps with shared state
1515
- **Callback Support** - Native `waitForCallback` option for callback-based async tests
16+
- **Browser Support** - Run the same tests in browser environments with console output
1617
- **Minimal Surface** - Focused API that abstracts runtime differences without bloat
1718

1819
### Installation
@@ -135,6 +136,43 @@ test("calls bar during execution of foo", () => {
135136
- **Node.js (TS):** `npx tsx --test` _Remember `{ "type": "module" }` in package.json_
136137
- **Deno:** `deno test`
137138
- **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+
<!DOCTYPE html>
147+
<html>
148+
<head>
149+
<title>Browser Tests</title>
150+
</head>
151+
<body>
152+
<script type="module">
153+
import { printTestSummary, test } from "https://esm.sh/jsr/@cross/test";
154+
155+
// Your tests run automatically when imported
156+
test("Browser test", () => {
157+
if (1 + 1 !== 2) throw new Error("Math is broken");
158+
});
159+
160+
test("Async browser test", async () => {
161+
await new Promise((resolve) => setTimeout(resolve, 100));
162+
});
163+
164+
// Print summary after all tests complete
165+
setTimeout(() => printTestSummary(), 1000);
166+
</script>
167+
</body>
168+
</html>
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
138176

139177
### Configuring CI
140178

deno.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@cross/test",
3-
"version": "0.0.14",
3+
"version": "0.0.15",
44
"exports": "./mod.ts",
55
"fmt": {
66
"lineWidth": 200

mod.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,27 @@ export interface WrappedTestOptions {
6565
waitForCallback?: boolean; // Whether to wait for the done-callback to be called
6666
}
6767

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+
6886
let wrappedTestToUse: WrappedTest;
87+
let browserHelpers: BrowserTestHelpers | undefined;
88+
6989
if (CurrentRuntime == Runtime.Deno) {
7090
const { wrappedTest } = await import("./shims/deno.ts");
7191
// @ts-ignore js
@@ -78,6 +98,14 @@ if (CurrentRuntime == Runtime.Deno) {
7898
const { wrappedTest } = await import("./shims/bun.ts");
7999
// @ts-ignore js
80100
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+
};
81109
} else {
82110
throw new Error("Unsupported runtime");
83111
}
@@ -90,3 +118,21 @@ if (CurrentRuntime == Runtime.Deno) {
90118
export async function test(name: string, testFn: TestSubject, options: WrappedTestOptions = {}) {
91119
await wrappedTestToUse(name, testFn, options);
92120
}
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+
}

shims/browser.ts

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
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<void> {
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<any> => {
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<void>)(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<any> => {
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<void>)(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<typeof setTimeout> | 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+
}

0 commit comments

Comments
 (0)