test: add BaseTest framework with utilities

- Create abstract BaseTest class with lifecycle hooks
  - Add beforeAll/afterAll and beforeEach/afterEach hooks
  - Implement temp directory management (auto cleanup)
  - Add file assertion helpers (exists, contains, matches)
  - Add async utilities (waitFor, waitForFile, sleep)
  - Include example test demonstrating usage
  - Add test tasks to deno.json (test, test:watch)
  - Add @std/assert dependency
This commit is contained in:
Sam Chau
2025-10-21 08:04:46 +10:30
parent 1884e9308f
commit 7e8068f1fb
4 changed files with 433 additions and 40 deletions

View File

@@ -1,42 +1,43 @@
{
"imports": {
"$config": "./src/utils/config/config.ts",
"$stores": "./src/stores",
"$components": "./src/components",
"$static": "./src/static",
"$db/": "./src/db/",
"$logger": "./src/utils/logger/logger.ts",
"$logger/": "./src/utils/logger/",
"$arr/": "./src/utils/arr/",
"$http/": "./src/utils/http/",
"$api": "./src/utils/api/request.ts",
"$utils/": "./src/utils/"
},
"tasks": {
"dev": "APP_BASE_PATH=./temp vite dev",
"build": "vite build && cp -r build/static .",
"compile": "deno task build && deno compile --no-check --allow-all --output profilarr build/mod.ts",
"start": "APP_BASE_PATH=temp PORT=6975 ./profilarr",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "APP_BASE_PATH=./temp deno test --allow-read --allow-write --allow-env",
"test:watch": "APP_BASE_PATH=./temp deno test --allow-read --allow-write --allow-env --watch"
},
"compilerOptions": {
"lib": ["deno.window", "dom"],
"strict": true
},
"exclude": ["build/", ".svelte-kit/", "node_modules/"],
"fmt": {
"exclude": ["build/", ".svelte-kit/", "node_modules/"],
"indentWidth": 2,
"useTabs": false
},
"lint": {
"exclude": ["build/", ".svelte-kit/", "node_modules/"]
}
"imports": {
"$config": "./src/utils/config/config.ts",
"$stores": "./src/stores",
"$components": "./src/components",
"$static": "./src/static",
"$db/": "./src/db/",
"$logger": "./src/utils/logger/logger.ts",
"$logger/": "./src/utils/logger/",
"$arr/": "./src/utils/arr/",
"$http/": "./src/utils/http/",
"$api": "./src/utils/api/request.ts",
"$utils/": "./src/utils/",
"@std/assert": "jsr:@std/assert@^1.0.0"
},
"tasks": {
"dev": "APP_BASE_PATH=./temp vite dev",
"build": "vite build && cp -r build/static .",
"compile": "deno task build && deno compile --no-check --allow-all --output profilarr build/mod.ts",
"start": "APP_BASE_PATH=temp PORT=6975 ./profilarr",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"format": "prettier --write .",
"lint": "prettier --check . && eslint .",
"test": "APP_BASE_PATH=./temp deno test --allow-read --allow-write --allow-env",
"test:watch": "APP_BASE_PATH=./temp deno test --allow-read --allow-write --allow-env --watch"
},
"compilerOptions": {
"lib": ["deno.window", "dom"],
"strict": true
},
"exclude": ["build/", ".svelte-kit/", "node_modules/"],
"fmt": {
"exclude": ["build/", ".svelte-kit/", "node_modules/"],
"indentWidth": 2,
"useTabs": false
},
"lint": {
"exclude": ["build/", ".svelte-kit/", "node_modules/"]
}
}

17
deno.lock generated
View File

@@ -1,6 +1,9 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@*": "1.0.15",
"jsr:@std/assert@1": "1.0.15",
"jsr:@std/internal@^1.0.12": "1.0.12",
"npm:@eslint/compat@^1.4.0": "1.4.0_eslint@9.37.0",
"npm:@eslint/js@^9.36.0": "9.37.0",
"npm:@jsr/db__sqlite@0.12": "0.12.0",
@@ -25,6 +28,17 @@
"npm:typescript@^5.9.2": "5.9.3",
"npm:vite@^7.1.7": "7.1.10_@types+node@22.18.11_picomatch@4.0.3"
},
"jsr": {
"@std/assert@1.0.15": {
"integrity": "d64018e951dbdfab9777335ecdb000c0b4e3df036984083be219ce5941e4703b",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
}
},
"npm": {
"@esbuild/aix-ppc64@0.24.2": {
"integrity": "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA==",
@@ -1875,6 +1889,9 @@
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1"
],
"packageJson": {
"dependencies": [
"npm:@eslint/compat@^1.4.0",

251
tests/base/BaseTest.ts Normal file
View File

@@ -0,0 +1,251 @@
/**
* Base test class providing common test utilities and lifecycle hooks
* Extend this class to create test suites with automatic setup/teardown
*/
import { assertExists } from "@std/assert";
export interface TestContext {
/** Unique temporary directory for this test */
tempDir: string;
/** Test name */
name: string;
}
/**
* Abstract base class for tests
* Provides lifecycle hooks and common utilities
*/
export abstract class BaseTest {
private static testCounter = 0;
protected context: TestContext | null = null;
/**
* Run before all tests in this suite (optional)
* Override this in your test class if needed
*/
protected beforeAll(): void | Promise<void> {
// Override in subclass if needed
}
/**
* Run after all tests in this suite (optional)
* Override this in your test class if needed
*/
protected afterAll(): void | Promise<void> {
// Override in subclass if needed
}
/**
* Run before each individual test (optional)
* Override this in your test class if needed
*/
protected beforeEach(_context: TestContext): void | Promise<void> {
// Override in subclass if needed
}
/**
* Run after each individual test (optional)
* Override this in your test class if needed
*/
protected afterEach(_context: TestContext): void | Promise<void> {
// Override in subclass if needed
}
/**
* Create a temporary directory for a test
* Automatically cleaned up after test completes
*/
protected async createTempDir(testName: string): Promise<string> {
const counter = BaseTest.testCounter++;
const timestamp = Date.now();
const sanitizedName = testName.replace(/[^a-zA-Z0-9]/g, "_");
const tempDir =
`/tmp/profilarr-tests/${sanitizedName}_${timestamp}_${counter}`;
await Deno.mkdir(tempDir, { recursive: true });
return tempDir;
}
/**
* Clean up a temporary directory and all its contents
*/
protected async cleanupTempDir(tempDir: string): Promise<void> {
try {
await Deno.remove(tempDir, { recursive: true });
} catch (error) {
// Ignore errors during cleanup
console.warn(`Failed to cleanup temp dir ${tempDir}:`, error);
}
}
/**
* Assert that a file exists at the given path
*/
protected async assertFileExists(
filePath: string,
message?: string,
): Promise<void> {
try {
const stat = await Deno.stat(filePath);
assertExists(stat, message ?? `File should exist: ${filePath}`);
} catch (error) {
throw new Error(
message ??
`Expected file to exist: ${filePath}, but got error: ${error}`,
);
}
}
/**
* Assert that a file does NOT exist at the given path
*/
protected async assertFileNotExists(
filePath: string,
message?: string,
): Promise<void> {
try {
await Deno.stat(filePath);
throw new Error(message ?? `Expected file to NOT exist: ${filePath}`);
} catch (error) {
if (!(error instanceof Deno.errors.NotFound)) {
throw error;
}
// File doesn't exist - this is what we want
}
}
/**
* Assert that a file contains specific text
*/
protected async assertFileContains(
filePath: string,
expectedText: string,
message?: string,
): Promise<void> {
const content = await Deno.readTextFile(filePath);
if (!content.includes(expectedText)) {
throw new Error(
message ??
`Expected file ${filePath} to contain "${expectedText}", but it didn't.\nFile content: ${content}`,
);
}
}
/**
* Assert that a file matches a regex pattern
*/
protected async assertFileMatches(
filePath: string,
pattern: RegExp,
message?: string,
): Promise<void> {
const content = await Deno.readTextFile(filePath);
if (!pattern.test(content)) {
throw new Error(
message ??
`Expected file ${filePath} to match pattern ${pattern}, but it didn't.\nFile content: ${content}`,
);
}
}
/**
* Read and parse JSON log file
*/
protected async readJsonLines(filePath: string): Promise<unknown[]> {
const content = await Deno.readTextFile(filePath);
const lines = content.split("\n").filter((line) => line.trim());
return lines.map((line) => JSON.parse(line));
}
/**
* Wait for a condition to become true with timeout
*/
protected async waitFor(
condition: () => boolean | Promise<boolean>,
timeoutMs = 5000,
checkIntervalMs = 100,
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeoutMs) {
if (await condition()) {
return;
}
await this.sleep(checkIntervalMs);
}
throw new Error(`Timeout waiting for condition after ${timeoutMs}ms`);
}
/**
* Wait for a file to exist
*/
protected async waitForFile(
filePath: string,
timeoutMs = 5000,
): Promise<void> {
await this.waitFor(async () => {
try {
await Deno.stat(filePath);
return true;
} catch {
return false;
}
}, timeoutMs);
}
/**
* Sleep for specified milliseconds
*/
protected async sleep(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Register and run a test with automatic setup/teardown
*/
protected test(
name: string,
fn: (context: TestContext) => Promise<void> | void,
): void {
Deno.test({
name: `${this.constructor.name}: ${name}`,
fn: async () => {
// Create temp directory for this test
const tempDir = await this.createTempDir(name);
const context: TestContext = { tempDir, name };
this.context = context;
try {
// Run beforeEach hook
await this.beforeEach(context);
// Run the actual test
await fn(context);
} finally {
// Run afterEach hook
await this.afterEach(context);
// Cleanup temp directory
await this.cleanupTempDir(tempDir);
this.context = null;
}
},
});
}
/**
* Run the test suite
* Call this method to execute all tests defined in your subclass
*/
async run(): Promise<void> {
// Run beforeAll hook
await this.beforeAll();
// Tests are registered via this.test() calls in the subclass constructor or setup method
// Deno.test will handle running them
// Note: afterAll will be called by the Deno test runner after all tests complete
// We can't easily hook into this without using the unstable API
// For now, tests should clean up in afterEach
}
}

124
tests/example.test.ts Normal file
View File

@@ -0,0 +1,124 @@
/**
* Example test demonstrating BaseTest usage
* Shows how to extend BaseTest and use its utilities
*/
import { BaseTest, TestContext } from "./base/BaseTest.ts";
import { assertEquals } from "@std/assert";
class ExampleTest extends BaseTest {
// Optional: Run once before all tests
protected override beforeAll(): void {
console.log("ExampleTest: beforeAll - runs once before all tests");
}
// Optional: Run once after all tests
protected override afterAll(): void {
console.log("ExampleTest: afterAll - runs once after all tests");
}
// Optional: Run before each test
protected override beforeEach(context: TestContext): void {
console.log(
`ExampleTest: beforeEach - test: ${context.name}, tempDir: ${context.tempDir}`,
);
}
// Optional: Run after each test
protected override afterEach(context: TestContext): void {
console.log(`ExampleTest: afterEach - test: ${context.name}`);
}
// Define your tests
runTests(): void {
// Basic test
this.test("basic assertion", () => {
assertEquals(1 + 1, 2);
});
// Test with temp directory
this.test("temp directory is created", async (context) => {
// Each test gets its own temp directory
await this.assertFileExists(context.tempDir);
});
// Test file operations
this.test("can write and read files", async (context) => {
const testFile = `${context.tempDir}/test.txt`;
// Write to file
await Deno.writeTextFile(testFile, "Hello, World!");
// Assert file exists
await this.assertFileExists(testFile);
// Assert file contains expected text
await this.assertFileContains(testFile, "Hello, World!");
// Assert file matches pattern
await this.assertFileMatches(testFile, /Hello.*World/);
});
// Test JSON operations
this.test("can write and read JSON lines", async (context) => {
const jsonFile = `${context.tempDir}/data.jsonl`;
// Write JSON lines
await Deno.writeTextFile(
jsonFile,
JSON.stringify({ id: 1, name: "test" }) + "\n" +
JSON.stringify({ id: 2, name: "example" }) + "\n",
);
// Read JSON lines
const data = await this.readJsonLines(jsonFile);
assertEquals(data.length, 2);
assertEquals((data[0] as { id: number }).id, 1);
assertEquals((data[1] as { id: number }).id, 2);
});
// Test async operations
this.test("waitFor utility works", async () => {
let counter = 0;
// Start async operation
setTimeout(() => {
counter = 5;
}, 100);
// Wait for condition
await this.waitFor(() => counter === 5, 1000);
assertEquals(counter, 5);
});
// Test waitForFile utility
this.test("waitForFile utility works", async (context) => {
const testFile = `${context.tempDir}/delayed.txt`;
// Create file after delay
setTimeout(async () => {
await Deno.writeTextFile(testFile, "delayed content");
}, 100);
// Wait for file to exist
await this.waitForFile(testFile, 1000);
// File should exist now
await this.assertFileExists(testFile);
});
// Test file NOT exists assertion
this.test("assertFileNotExists works", async (context) => {
const nonExistentFile = `${context.tempDir}/does-not-exist.txt`;
// Should pass - file doesn't exist
await this.assertFileNotExists(nonExistentFile);
});
}
}
// Create instance and run tests
const exampleTest = new ExampleTest();
await exampleTest.runTests();