1tube 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * The `1tube/test` contract every runner binding implements.
3
3
  *
4
- * Edge function tests import `test`, `describe`, hooks and assertions from
5
- * `1tube/test` instead of a specific runner, so the same files run under
6
- * `bun test` today and under another runtime's runner (node:test, a workerd
7
- * pool) later. Keep this surface to what every runner can express: no test
8
- * context argument, no runner-specific options.
4
+ * Edge function tests import `test`, `describe`, hooks, `expect`, mocks and
5
+ * assertions from `1tube/test` instead of a specific runner, so the same
6
+ * files run under `bun test` today and under another runtime's runner
7
+ * (node:test, a workerd pool) later. Keep this surface to what every runner
8
+ * can express: no test context argument, no runner-specific options.
9
9
  */
10
+ import type { Mock } from "./expect.ts";
10
11
  export type TestFn = () => void | Promise<unknown>;
11
12
  export type HookFn = () => void | Promise<unknown>;
12
13
  export interface TestOptions {
@@ -17,18 +18,59 @@ export interface TestOptions {
17
18
  /** Run only tests marked `only` (where the runner supports focusing). */
18
19
  only?: boolean;
19
20
  }
20
- export interface TestFunction {
21
- (name: string, fn: TestFn, options?: TestOptions): void;
22
- skip(name: string, fn: TestFn, options?: TestOptions): void;
23
- only(name: string, fn: TestFn, options?: TestOptions): void;
21
+ /** Row arguments for `.each`: array rows spread, anything else is one argument. */
22
+ export type EachArgs<Row> = Row extends readonly unknown[] ? Row : [Row];
23
+ export type RegisterTest = (name: string, fn: TestFn, options?: TestOptions) => void;
24
+ export interface TestFunction extends RegisterTest {
25
+ skip: RegisterTest;
26
+ only: RegisterTest;
24
27
  /** A placeholder for a test that is not written yet. */
25
28
  todo(name: string): void;
29
+ /** Register normally when `condition` is truthy, else as skipped. */
30
+ if(condition: unknown): RegisterTest;
31
+ /** Register as skipped when `condition` is truthy. */
32
+ skipIf(condition: unknown): RegisterTest;
33
+ /**
34
+ * One test per row. The name supports printf placeholders (`%s %d %i %f
35
+ * %j %o %p %#`) and `$key` for object rows.
36
+ */
37
+ each<Row>(table: readonly Row[]): (name: string, fn: (...args: EachArgs<Row>) => void | Promise<unknown>, options?: TestOptions) => void;
26
38
  }
27
- export interface DescribeFunction {
28
- (name: string, fn: () => void): void;
29
- skip(name: string, fn: () => void): void;
30
- only(name: string, fn: () => void): void;
39
+ export type RegisterSuite = (name: string, fn: () => void) => void;
40
+ export interface DescribeFunction extends RegisterSuite {
41
+ skip: RegisterSuite;
42
+ only: RegisterSuite;
43
+ if(condition: unknown): RegisterSuite;
44
+ skipIf(condition: unknown): RegisterSuite;
45
+ each<Row>(table: readonly Row[]): (name: string, fn: (...args: EachArgs<Row>) => void) => void;
31
46
  }
32
47
  export type Hook = (fn: HookFn) => void;
48
+ type AnyFn = (...args: any[]) => any;
49
+ export interface MockFunction {
50
+ /** A mock function (records calls; optional implementation). */
51
+ <T extends AnyFn = AnyFn>(impl?: T): Mock<T>;
52
+ /**
53
+ * Replace a module for subsequently loaded importers. The specifier must
54
+ * be absolute — a shared helper can't know which file called it — so
55
+ * pass `import.meta.resolve("./dep.ts")` or a package name. The factory
56
+ * returns the module's exports (`default` for the default export).
57
+ */
58
+ module(specifier: string | URL, factory: () => Record<string, unknown>): void;
59
+ /** Restore every `spyOn` replacement. */
60
+ restore(): void;
61
+ }
33
62
  /** Which runner the active binding drives. */
34
63
  export type TestRuntime = "bun" | "node";
64
+ /** What a runner binding provides; `createTestApi` builds the rest. */
65
+ export interface RunnerBase {
66
+ test(name: string, fn: TestFn, options: TestOptions): void;
67
+ todo(name: string): void;
68
+ describe(name: string, fn: () => void, mode: "run" | "skip" | "only"): void;
69
+ beforeAll: Hook;
70
+ afterAll: Hook;
71
+ beforeEach: Hook;
72
+ afterEach: Hook;
73
+ /** `specifier` is already checked to be absolute (path, file: URL or bare). */
74
+ mockModule(specifier: string, factory: () => Record<string, unknown>): void;
75
+ }
76
+ export {};
package/dist/test/api.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * The `1tube/test` contract every runner binding implements.
3
3
  *
4
- * Edge function tests import `test`, `describe`, hooks and assertions from
5
- * `1tube/test` instead of a specific runner, so the same files run under
6
- * `bun test` today and under another runtime's runner (node:test, a workerd
7
- * pool) later. Keep this surface to what every runner can express: no test
8
- * context argument, no runner-specific options.
4
+ * Edge function tests import `test`, `describe`, hooks, `expect`, mocks and
5
+ * assertions from `1tube/test` instead of a specific runner, so the same
6
+ * files run under `bun test` today and under another runtime's runner
7
+ * (node:test, a workerd pool) later. Keep this surface to what every runner
8
+ * can express: no test context argument, no runner-specific options.
9
9
  */
10
10
  export {};
@@ -15,6 +15,8 @@ type AnyConstructor = abstract new (...args: never[]) => unknown;
15
15
  type ErrorClass<E extends Error = Error> = abstract new (...args: never[]) => E;
16
16
  /** Readable, deterministic rendering of a value for failure messages. */
17
17
  export declare function format(value: unknown): string;
18
+ /** Line-level diff of two formatted values (`-` actual, `+` expected). */
19
+ export declare function formatDiff(actual: unknown, expected: unknown): string;
18
20
  /**
19
21
  * Deep structural equality: same constructor, same own enumerable keys
20
22
  * (strings and symbols), recursively equal values. `NaN` equals `NaN`;
@@ -99,7 +99,7 @@ export function format(value) {
99
99
  return render(value);
100
100
  }
101
101
  /** Line-level diff of two formatted values (`-` actual, `+` expected). */
102
- function diff(actual, expected) {
102
+ export function formatDiff(actual, expected) {
103
103
  const a = format(actual).split("\n");
104
104
  const b = format(expected).split("\n");
105
105
  // LCS over lines: values printed for assertion failures are small.
@@ -245,7 +245,7 @@ export function assertFalse(expr, msg = "") {
245
245
  export function assertEquals(actual, expected, msg) {
246
246
  if (equal(actual, expected))
247
247
  return;
248
- throw new AssertionError(`Values are not equal${suffix(msg)}\n\n [Diff] Actual / Expected\n\n${diff(actual, expected)}\n`);
248
+ throw new AssertionError(`Values are not equal${suffix(msg)}\n\n [Diff] Actual / Expected\n\n${formatDiff(actual, expected)}\n`);
249
249
  }
250
250
  export function assertNotEquals(actual, expected, msg) {
251
251
  if (!equal(actual, expected))
@@ -259,7 +259,7 @@ export function assertStrictEquals(actual, expected, msg) {
259
259
  const sameRendering = format(actual) === format(expected);
260
260
  throw new AssertionError(sameRendering
261
261
  ? `Values have the same structure but are not reference-equal${suffix(msg)}\n\n ${format(actual).replace(/\n/g, "\n ")}\n`
262
- : `Values are not strictly equal${suffix(msg)}\n\n [Diff] Actual / Expected\n\n${diff(actual, expected)}\n`);
262
+ : `Values are not strictly equal${suffix(msg)}\n\n [Diff] Actual / Expected\n\n${formatDiff(actual, expected)}\n`);
263
263
  }
264
264
  export function assertNotStrictEquals(actual, expected, msg) {
265
265
  if (!Object.is(actual, expected))
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Builds the full `1tube/test` surface from a runner's minimal base, so
3
+ * `.each`, `.if`/`.skipIf`, `it`, mocks and title formatting behave the
4
+ * same on every runner.
5
+ */
6
+ import type { DescribeFunction, Hook, MockFunction, RunnerBase, TestFunction } from "./api.ts";
7
+ export interface TestApi {
8
+ test: TestFunction;
9
+ it: TestFunction;
10
+ describe: DescribeFunction;
11
+ beforeAll: Hook;
12
+ afterAll: Hook;
13
+ beforeEach: Hook;
14
+ afterEach: Hook;
15
+ mock: MockFunction;
16
+ }
17
+ /** jest-style `.each` titles: printf placeholders, or `$path` for object rows. */
18
+ export declare function formatTitle(title: string, args: unknown[], index: number): string;
19
+ export declare function createTestApi(base: RunnerBase): TestApi;
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Builds the full `1tube/test` surface from a runner's minimal base, so
3
+ * `.each`, `.if`/`.skipIf`, `it`, mocks and title formatting behave the
4
+ * same on every runner.
5
+ */
6
+ import { format } from "./assert.js";
7
+ import { createMock, restoreAllMocks } from "./expect.js";
8
+ const rowArgs = (row) => (Array.isArray(row) ? row : [row]);
9
+ /** jest-style `.each` titles: printf placeholders, or `$path` for object rows. */
10
+ export function formatTitle(title, args, index) {
11
+ const [first] = args;
12
+ if (args.length === 1 && first && typeof first === "object" && !Array.isArray(first) && /\$[A-Za-z_]/.test(title)) {
13
+ return title.replace(/\$([A-Za-z_$][\w$]*(?:\.[\w$]+)*)/g, (m, path) => {
14
+ let cur = first;
15
+ for (const p of path.split(".")) {
16
+ if (cur === null || cur === undefined)
17
+ return m;
18
+ cur = cur[p];
19
+ }
20
+ return typeof cur === "string" ? cur : format(cur);
21
+ });
22
+ }
23
+ let i = 0;
24
+ return title.replace(/%[sdifjoOp#%]/g, (m) => {
25
+ if (m === "%%")
26
+ return "%";
27
+ if (m === "%#")
28
+ return String(index);
29
+ if (i >= args.length)
30
+ return m;
31
+ const v = args[i++];
32
+ switch (m) {
33
+ case "%s":
34
+ return typeof v === "string" ? v : format(v);
35
+ case "%d":
36
+ case "%i":
37
+ return String(typeof v === "bigint" ? v : Math.trunc(Number(v)));
38
+ case "%f":
39
+ return String(Number(v));
40
+ case "%j":
41
+ return JSON.stringify(v);
42
+ default:
43
+ return format(v);
44
+ }
45
+ });
46
+ }
47
+ export function createTestApi(base) {
48
+ const register = (mode) => (name, fn, options = {}) => base.test(name, fn, {
49
+ ...options,
50
+ skip: mode === "skip" || options.skip,
51
+ only: mode === "only" || options.only,
52
+ });
53
+ const test = Object.assign(register("run"), {
54
+ skip: register("skip"),
55
+ only: register("only"),
56
+ todo: (name) => base.todo(name),
57
+ if: (condition) => register(condition ? "run" : "skip"),
58
+ skipIf: (condition) => register(condition ? "skip" : "run"),
59
+ each: (table) => (name, fn, options) => {
60
+ table.forEach((row, index) => {
61
+ const args = rowArgs(row);
62
+ register("run")(formatTitle(name, args, index), (() => fn(...args)), options);
63
+ });
64
+ },
65
+ });
66
+ const suite = (mode) => (name, fn) => base.describe(name, fn, mode);
67
+ const describe = Object.assign(suite("run"), {
68
+ skip: suite("skip"),
69
+ only: suite("only"),
70
+ if: (condition) => suite(condition ? "run" : "skip"),
71
+ skipIf: (condition) => suite(condition ? "skip" : "run"),
72
+ each: (table) => (name, fn) => {
73
+ table.forEach((row, index) => {
74
+ const args = rowArgs(row);
75
+ suite("run")(formatTitle(name, args, index), () => fn(...args));
76
+ });
77
+ },
78
+ });
79
+ const mock = Object.assign((impl) => createMock(impl), {
80
+ module: (specifier, factory) => {
81
+ const s = specifier instanceof URL ? specifier.href : String(specifier);
82
+ if (s.startsWith("./") || s.startsWith("../") || s === "." || s === "..") {
83
+ throw new Error(`mock.module(${JSON.stringify(s)}): relative specifiers depend on the calling file, which a ` +
84
+ `shared helper can't see. Pass import.meta.resolve(${JSON.stringify(s)}) instead.`);
85
+ }
86
+ base.mockModule(s, factory);
87
+ },
88
+ restore: () => restoreAllMocks(),
89
+ });
90
+ return {
91
+ test,
92
+ it: test,
93
+ describe,
94
+ beforeAll: base.beforeAll,
95
+ afterAll: base.afterAll,
96
+ beforeEach: base.beforeEach,
97
+ afterEach: base.afterEach,
98
+ mock,
99
+ };
100
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Runtime-agnostic `expect`, mock functions and spies for `1tube/test`.
3
+ *
4
+ * Pure TypeScript with no runtime imports — the same semantics under every
5
+ * runner. Matchers follow the jest conventions Bun's `bun:test` also uses
6
+ * (`toEqual` ignores `undefined` properties and object types,
7
+ * `toStrictEqual` does not, `toMatchObject` matches subsets, asymmetric
8
+ * matchers like `expect.objectContaining` nest anywhere), so tests written
9
+ * against `bun:test` port by changing the import line.
10
+ */
11
+ declare const ASYMMETRIC: unique symbol;
12
+ export interface AsymmetricMatcher {
13
+ readonly [ASYMMETRIC]: true;
14
+ asymmetricMatch(other: unknown): boolean;
15
+ toString(): string;
16
+ }
17
+ /**
18
+ * jest-style deep equality. `strict` (toStrictEqual) additionally checks
19
+ * constructors, `undefined`-valued keys and array holes.
20
+ */
21
+ export declare function deepEquals(a: unknown, b: unknown, strict?: boolean): boolean;
22
+ type AnyFn = (...args: any[]) => any;
23
+ export type MockResult<T> = {
24
+ type: "return";
25
+ value: T;
26
+ } | {
27
+ type: "throw";
28
+ value: unknown;
29
+ } | {
30
+ type: "incomplete";
31
+ value: undefined;
32
+ };
33
+ export interface MockState<T extends AnyFn> {
34
+ calls: Parameters<T>[];
35
+ results: MockResult<ReturnType<T>>[];
36
+ instances: unknown[];
37
+ lastCall: Parameters<T> | undefined;
38
+ }
39
+ export type Mock<T extends AnyFn = AnyFn> = T & {
40
+ readonly mock: MockState<T>;
41
+ mockImplementation(fn: T): Mock<T>;
42
+ mockImplementationOnce(fn: T): Mock<T>;
43
+ mockReturnValue(value: ReturnType<T>): Mock<T>;
44
+ mockReturnValueOnce(value: ReturnType<T>): Mock<T>;
45
+ mockResolvedValue(value: Awaited<ReturnType<T>>): Mock<T>;
46
+ mockResolvedValueOnce(value: Awaited<ReturnType<T>>): Mock<T>;
47
+ mockRejectedValue(value: unknown): Mock<T>;
48
+ mockRejectedValueOnce(value: unknown): Mock<T>;
49
+ mockName(name: string): Mock<T>;
50
+ getMockName(): string;
51
+ /** Forget recorded calls and results. */
52
+ mockClear(): Mock<T>;
53
+ /** mockClear() plus drop implementations. */
54
+ mockReset(): Mock<T>;
55
+ /** For spies: put the original method back. */
56
+ mockRestore(): void;
57
+ };
58
+ /** Create a mock function, optionally wrapping an implementation. */
59
+ export declare function createMock<T extends AnyFn = AnyFn>(impl?: T, restore?: () => void): Mock<T>;
60
+ export declare function isMock(v: unknown): v is Mock;
61
+ /** Replace `obj[key]` with a mock that calls through to the original. */
62
+ export declare function spyOn<O extends object, K extends keyof O>(obj: O, key: K): O[K] extends AnyFn ? Mock<O[K]> : never;
63
+ /** Restore every active spy (`mock.restore()`). */
64
+ export declare function restoreAllMocks(): void;
65
+ export interface Matchers<R> {
66
+ not: Matchers<R>;
67
+ toBe(expected: unknown): R;
68
+ toEqual(expected: unknown): R;
69
+ toStrictEqual(expected: unknown): R;
70
+ toMatchObject(expected: object): R;
71
+ toBeNull(): R;
72
+ toBeUndefined(): R;
73
+ toBeDefined(): R;
74
+ toBeTruthy(): R;
75
+ toBeFalsy(): R;
76
+ toBeNaN(): R;
77
+ toBeTypeOf(type: "string" | "number" | "bigint" | "boolean" | "symbol" | "undefined" | "object" | "function"): R;
78
+ toBeInstanceOf(ctor: abstract new (...args: never[]) => unknown): R;
79
+ toBeGreaterThan(n: number | bigint): R;
80
+ toBeGreaterThanOrEqual(n: number | bigint): R;
81
+ toBeLessThan(n: number | bigint): R;
82
+ toBeLessThanOrEqual(n: number | bigint): R;
83
+ toBeCloseTo(n: number, numDigits?: number): R;
84
+ toBeOneOf(values: readonly unknown[]): R;
85
+ toContain(item: unknown): R;
86
+ toContainEqual(item: unknown): R;
87
+ toInclude(substring: string): R;
88
+ toStartWith(prefix: string): R;
89
+ toEndWith(suffix: string): R;
90
+ toMatch(pattern: string | RegExp): R;
91
+ toHaveLength(n: number): R;
92
+ toHaveProperty(path: string | readonly (string | number)[], value?: unknown): R;
93
+ toBeEmpty(): R;
94
+ toThrow(expected?: string | RegExp | Error | (abstract new (...args: never[]) => unknown) | AsymmetricMatcher): R;
95
+ toThrowError(expected?: string | RegExp | Error | (abstract new (...args: never[]) => unknown) | AsymmetricMatcher): R;
96
+ toHaveBeenCalled(): R;
97
+ toHaveBeenCalledTimes(n: number): R;
98
+ toHaveBeenCalledWith(...args: unknown[]): R;
99
+ toHaveBeenLastCalledWith(...args: unknown[]): R;
100
+ toHaveBeenNthCalledWith(n: number, ...args: unknown[]): R;
101
+ toHaveReturned(): R;
102
+ toHaveReturnedTimes(n: number): R;
103
+ toHaveReturnedWith(value: unknown): R;
104
+ }
105
+ export interface Assertion extends Matchers<void> {
106
+ /** Await the promise, then match its value. */
107
+ resolves: Matchers<Promise<void>>;
108
+ /** Await the promise, then match its rejection reason. */
109
+ rejects: Matchers<Promise<void>>;
110
+ }
111
+ export interface Expect {
112
+ (actual: unknown, message?: string): Assertion;
113
+ anything(): AsymmetricMatcher;
114
+ any(ctor: unknown): AsymmetricMatcher;
115
+ objectContaining(obj: object): AsymmetricMatcher;
116
+ arrayContaining(items: readonly unknown[]): AsymmetricMatcher;
117
+ stringContaining(s: string): AsymmetricMatcher;
118
+ stringMatching(p: string | RegExp): AsymmetricMatcher;
119
+ closeTo(n: number, numDigits?: number): AsymmetricMatcher;
120
+ not: {
121
+ objectContaining(obj: object): AsymmetricMatcher;
122
+ arrayContaining(items: readonly unknown[]): AsymmetricMatcher;
123
+ stringContaining(s: string): AsymmetricMatcher;
124
+ stringMatching(p: string | RegExp): AsymmetricMatcher;
125
+ };
126
+ }
127
+ export declare const expect: Expect;
128
+ export {};