From 7e8068f1fbc94bccc78b8c7b1e768a5b8689b8b0 Mon Sep 17 00:00:00 2001 From: Sam Chau Date: Tue, 21 Oct 2025 08:04:46 +1030 Subject: [PATCH] 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 --- deno.json | 81 ++++++------- deno.lock | 17 +++ tests/base/BaseTest.ts | 251 +++++++++++++++++++++++++++++++++++++++++ tests/example.test.ts | 124 ++++++++++++++++++++ 4 files changed, 433 insertions(+), 40 deletions(-) create mode 100644 tests/base/BaseTest.ts create mode 100644 tests/example.test.ts diff --git a/deno.json b/deno.json index 9b4bfb1..62995fd 100644 --- a/deno.json +++ b/deno.json @@ -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/"] + } } diff --git a/deno.lock b/deno.lock index 726e181..b454cd8 100644 --- a/deno.lock +++ b/deno.lock @@ -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", diff --git a/tests/base/BaseTest.ts b/tests/base/BaseTest.ts new file mode 100644 index 0000000..5eb5596 --- /dev/null +++ b/tests/base/BaseTest.ts @@ -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 { + // 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 { + // 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 { + // 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 { + // Override in subclass if needed + } + + /** + * Create a temporary directory for a test + * Automatically cleaned up after test completes + */ + protected async createTempDir(testName: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, + timeoutMs = 5000, + checkIntervalMs = 100, + ): Promise { + 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 { + 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 { + 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 { + 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 { + // 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 + } +} diff --git a/tests/example.test.ts b/tests/example.test.ts new file mode 100644 index 0000000..6bf1880 --- /dev/null +++ b/tests/example.test.ts @@ -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();