///
import { afterAll, beforeAll, it } from "bun:test";
import path from "node:path";
import { alchemy } from "../alchemy.js";
import { R2RestStateStore } from "../cloudflare/r2-rest-state-store.js";
import { Scope } from "../scope.js";
import type { StateStoreType } from "../state.js";
/**
* Extend the Alchemy interface to include test functionality
*/
declare module "../alchemy" {
interface Alchemy {
test: typeof test;
}
}
/**
* Add test functionality to alchemy instance
*/
alchemy.test = test;
/**
* Options for configuring test behavior
*/
export interface TestOptions {
/**
* Whether to suppress logging output.
* @default false.
*/
quiet?: boolean;
/**
* Password to use for test resources.
* @default "test-password".
*/
password?: string;
/**
* Override the default state store for the test.
*/
stateStore?: StateStoreType;
/**
* Prefix to use for the scope to isolate tests and environments.
*/
prefix?: string;
}
/**
* Test function type definition with overloads
*/
type test = {
/**
* Create a test with default options
* @param name Test name
* @param fn Test function
* @param timeout Optional timeout in milliseconds
*/
(name: string, fn: (scope: Scope) => Promise, timeout?: number): void;
/**
* Create a test with custom options
* @param name Test name
* @param options Test configuration options
* @param fn Test function
* @param timeout Optional timeout in milliseconds
*/
(
name: string,
options: TestOptions,
fn: (scope: Scope) => Promise,
timeout?: number
): void;
/**
* Skip test conditionally
* @param condition If true, test will be skipped
*/
skipIf(condition: boolean): test;
beforeAll(fn: (scope: Scope) => Promise): void;
afterAll(fn: (scope: Scope) => Promise): void;
/**
* Current test scope
*/
scope: Scope;
};
/**
* Creates a test helper function that provides scoped resource management
*
* @param meta Import meta object from the test file
* @param defaultOptions Default options to apply to all tests
* @returns Test function with scope management
*
* @example
* ```typescript
* const test = alchemy.test(import.meta);
*
* describe("My Resource", () => {
* test("create and delete", async (scope) => {
* try {
* const resource = await MyResource("test", { ... });
* expect(resource.id).toBeTruthy();
* } finally {
* await alchemy.destroy(scope);
* }
* });
* });
* ```
*/
export function test(meta: ImportMeta, defaultOptions?: TestOptions): test {
defaultOptions = defaultOptions ?? {};
if (
defaultOptions.stateStore === undefined &&
// process.env.CI &&
process.env.ALCHEMY_STATE_STORE === "cloudflare"
) {
defaultOptions.stateStore = (scope) =>
new R2RestStateStore(scope, {
apiKey: alchemy.secret(process.env.CLOUDFLARE_API_KEY),
email: process.env.CLOUDFLARE_EMAIL,
bucketName: process.env.CLOUDFLARE_BUCKET_NAME!,
});
}
// Add skipIf functionality
test.skipIf = (condition: boolean) => {
if (condition) {
// TODO: proxy through to bun:test.skipIf
return (...args: any[]) => {};
}
return test;
};
// Create local test scope based on filename
const scope = new Scope({
scopeName: `${defaultOptions.prefix ? `${defaultOptions.prefix}-` : ""}${path.basename(meta.filename)}`,
// parent: globalTestScope,
stateStore: defaultOptions?.stateStore,
});
test.beforeAll = (fn: (scope: Scope) => Promise) => {
return beforeAll(() => scope.run(() => fn(scope)));
};
test.afterAll = (fn: (scope: Scope) => Promise) => {
return afterAll(() => scope.run(() => fn(scope)));
};
return test as test;
function test(
...args:
| [
name: string,
options: TestOptions,
fn: (scope: Scope) => Promise,
]
| [name: string, fn: (scope: Scope) => Promise]
) {
const testName = args[0];
const _options = typeof args[1] === "object" ? args[1] : undefined;
const timeout =
typeof args[args.length - 1] === "number"
? (args[args.length - 1] as number)
: 120000;
const spread = (obj: any) =>
obj && typeof obj === "object"
? Object.fromEntries(
Object.entries(obj).flatMap(([k, v]) =>
v !== undefined ? [[k, v]] : []
)
)
: {};
// Merge options with defaults
const options: TestOptions = {
quiet: false,
password: "test-password",
...spread(defaultOptions),
...spread(_options),
};
const fn = typeof args[1] === "function" ? args[1] : args[2]!;
return it(
testName,
async () =>
alchemy.run(
testName,
{
...options,
parent: scope,
},
async (scope) => {
try {
// Enter test scope since bun calls from different scope
await scope.run(() => fn(scope));
} catch (err) {
console.error(err);
throw err;
}
}
),
timeout
);
}
}