diff --git a/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts b/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts index 8439fd7..810e908 100644 --- a/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts +++ b/skills/dev-browser/src/snapshot/__tests__/snapshot.test.ts @@ -1,60 +1,79 @@ -import { chromium } from "playwright"; -import type { Browser, BrowserContext, Page } from "playwright"; -import { beforeAll, afterAll, beforeEach, afterEach, describe, test, expect } from "vitest"; +import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "bun:test"; import { getSnapshotScript, clearSnapshotScriptCache } from "../browser-script"; -let browser: Browser; -let context: BrowserContext; -let page: Page; +let chromium: any = null; +let playwrightAvailable = true; -beforeAll(async () => { - browser = await chromium.launch(); -}); +try { + // @ts-ignore - optional dependency that may not be installed in all environments + const playwright = await import("playwright"); + chromium = playwright.chromium; +} catch (error) { + playwrightAvailable = false; +} -afterAll(async () => { - await browser.close(); -}); +const runSnapshotSuite = (suiteName: string, cb: () => void) => { + if (playwrightAvailable) { + describe(suiteName, cb); + } else { + describe.skip(suiteName, cb); + } +}; + +runSnapshotSuite("ARIA Snapshot", () => { + if (!playwrightAvailable) { + return; + } + + let browser: any; + let context: any; + let page: any; + + beforeAll(async () => { + browser = await chromium.launch(); + }); -beforeEach(async () => { - context = await browser.newContext(); - page = await context.newPage(); - clearSnapshotScriptCache(); // Start fresh for each test -}); + afterAll(async () => { + await browser.close(); + }); -afterEach(async () => { - await context.close(); -}); + beforeEach(async () => { + context = await browser.newContext(); + page = await context.newPage(); + clearSnapshotScriptCache(); + }); -async function setContent(html: string): Promise { - await page.setContent(html, { waitUntil: "domcontentloaded" }); -} + afterEach(async () => { + await context.close(); + }); -async function getSnapshot(): Promise { - const script = getSnapshotScript(); - return await page.evaluate((s: string) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const w = globalThis as any; - if (!w.__devBrowser_getAISnapshot) { - // eslint-disable-next-line no-eval - eval(s); - } - return w.__devBrowser_getAISnapshot(); - }, script); -} + async function setContent(html: string): Promise { + await page.setContent(html, { waitUntil: "domcontentloaded" }); + } -async function selectRef(ref: string): Promise { - return await page.evaluate((refId: string) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const w = globalThis as any; - const element = w.__devBrowser_selectSnapshotRef(refId); - return { - tagName: element.tagName, - textContent: element.textContent?.trim(), - }; - }, ref); -} + async function getSnapshot(): Promise { + const script = getSnapshotScript(); + return await page.evaluate((s: string) => { + const w = globalThis as any; + if (!w.__devBrowser_getAISnapshot) { + // eslint-disable-next-line no-eval + eval(s); + } + return w.__devBrowser_getAISnapshot(); + }, script); + } + + async function selectRef(ref: string): Promise { + return await page.evaluate((refId: string) => { + const w = globalThis as any; + const element = w.__devBrowser_selectSnapshotRef(refId); + return { + tagName: element.tagName, + textContent: element.textContent?.trim(), + }; + }, ref); + } -describe("ARIA Snapshot", () => { test("generates snapshot for simple page", async () => { await setContent(` @@ -85,7 +104,6 @@ describe("ARIA Snapshot", () => { const snapshot = await getSnapshot(); - // Should have refs expect(snapshot).toMatch(/\[ref=e\d+\]/); }); @@ -100,9 +118,7 @@ describe("ARIA Snapshot", () => { await getSnapshot(); - // Check that refs are stored const hasRefs = await page.evaluate(() => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any const w = globalThis as any; return typeof w.__devBrowserRefs === "object" && Object.keys(w.__devBrowserRefs).length > 0; }); @@ -121,13 +137,11 @@ describe("ARIA Snapshot", () => { const snapshot = await getSnapshot(); - // Extract a ref from the snapshot const refMatch = snapshot.match(/\[ref=(e\d+)\]/); expect(refMatch).toBeTruthy(); expect(refMatch![1]).toBeDefined(); const ref = refMatch![1] as string; - // Select the element by ref const result = (await selectRef(ref)) as { tagName: string; textContent: string }; expect(result.tagName).toBe("BUTTON"); expect(result.textContent).toBe("My Button"); @@ -146,7 +160,6 @@ describe("ARIA Snapshot", () => { expect(snapshot).toContain("link"); expect(snapshot).toContain("Example Link"); - // URL should be included as a prop expect(snapshot).toContain("/url:"); }); @@ -221,3 +234,4 @@ describe("ARIA Snapshot", () => { expect(snapshot).toContain("[checked]"); }); }); + diff --git a/src/utils/__tests__/errors.test.ts b/src/utils/__tests__/errors.test.ts new file mode 100644 index 0000000..0888394 --- /dev/null +++ b/src/utils/__tests__/errors.test.ts @@ -0,0 +1,86 @@ +import { describe, it, expect } from "bun:test"; +import { + categorizeError, + createTypedError, + isRecoverable, + ErrorCategory, + ErrorCode, +} from "../errors"; + +describe("errors utility", () => { + describe("categorizeError", () => { + it("detects validation errors", () => { + const error = new Error("Validation failed due to invalid input"); + expect(categorizeError(error)).toBe(ErrorCategory.VALIDATION); + }); + + it("detects permission errors", () => { + const error = new Error("Permission denied: EACCES"); + expect(categorizeError(error)).toBe(ErrorCategory.PERMISSION); + }); + + it("detects filesystem errors", () => { + const error = new Error("ENOENT: file not found"); + expect(categorizeError(error)).toBe(ErrorCategory.FILESYSTEM); + }); + + it("falls back to unknown", () => { + expect(categorizeError(new Error("unexpected"))).toBe(ErrorCategory.UNKNOWN); + }); + }); + + describe("createTypedError", () => { + it("builds typed error with metadata", () => { + const error = createTypedError( + "network failure", + ErrorCategory.NETWORK, + ErrorCode.NETWORK_ERROR, + { + recoverable: true, + suggestions: ["check connection"], + } + ); + + expect(error.message).toBe("network failure"); + expect(error.category).toBe(ErrorCategory.NETWORK); + expect(error.code).toBe(ErrorCode.NETWORK_ERROR); + expect(error.recoverable).toBe(true); + expect(error.suggestions).toEqual(["check connection"]); + }); + + it("defaults recoverable to false", () => { + const error = createTypedError( + "invalid input", + ErrorCategory.VALIDATION, + ErrorCode.INVALID_INPUT + ); + expect(error.recoverable).toBe(false); + }); + }); + + describe("isRecoverable", () => { + it("returns true for typed recoverable errors", () => { + const error = createTypedError( + "temporary network glitch", + ErrorCategory.NETWORK, + ErrorCode.NETWORK_ERROR, + { recoverable: true } + ); + expect(isRecoverable(error)).toBe(true); + }); + + it("returns true for inferred transient categories", () => { + const error = new Error("ETIMEDOUT waiting for response"); + expect(isRecoverable(error)).toBe(true); + }); + + it("returns false for non-recoverable errors", () => { + const error = createTypedError( + "bad input", + ErrorCategory.VALIDATION, + ErrorCode.INVALID_INPUT + ); + expect(isRecoverable(error)).toBe(false); + }); + }); +}); diff --git a/src/utils/__tests__/logger.test.ts b/src/utils/__tests__/logger.test.ts new file mode 100644 index 0000000..c04735d --- /dev/null +++ b/src/utils/__tests__/logger.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect, beforeEach, beforeAll, afterAll } from "bun:test"; +import { logger } from "../logger"; + +const noop = () => {}; +let originalConsoleLog: typeof console.log; +let originalConsoleWarn: typeof console.warn; +let originalConsoleError: typeof console.error; +let originalConsoleDebug: typeof console.debug; + +describe("logger", () => { + beforeAll(() => { + originalConsoleLog = console.log; + originalConsoleWarn = console.warn; + originalConsoleError = console.error; + originalConsoleDebug = console.debug; + console.log = noop; + console.warn = noop; + console.error = noop; + console.debug = noop; + }); + + afterAll(() => { + console.log = originalConsoleLog; + console.warn = originalConsoleWarn; + console.error = originalConsoleError; + console.debug = originalConsoleDebug; + }); + + beforeEach(() => { + logger.clearLogs(); + logger.setLogLevel("info"); + logger.setModule("test-module"); + }); + + it("respects log level thresholds", () => { + logger.debug("debug message"); + logger.info("info message"); + logger.warn("warn message"); + logger.error("error message"); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(3); + expect(logs[0]!.level).toBe("info"); + expect(logs[1]!.level).toBe("warn"); + expect(logs[2]!.level).toBe("error"); + }); + + it("captures metadata and errors", () => { + const testError = new Error("boom"); + logger.info("info", { key: "value" }); + logger.error("fail", undefined, testError); + + const logs = logger.getLogs(); + expect(logs).toHaveLength(2); + expect(logs[0]!.metadata).toEqual({ key: "value" }); + expect(logs[1]!.error).toBeDefined(); + expect(logs[1]!.error?.message).toBe("boom"); + }); + + it("honors max log retention", () => { + for (let i = 0; i < 1100; i++) { + logger.info(`entry-${i}`); + } + + expect(logger.getLogs()).toHaveLength(1000); + }); + + it("exports and clears logs", () => { + logger.warn("warning"); + const exportPayload = logger.exportLogs(); + const parsed = JSON.parse(exportPayload); + + expect(Array.isArray(parsed)).toBe(true); + expect(parsed).toHaveLength(1); + + logger.clearLogs(); + expect(logger.getLogs()).toHaveLength(0); + }); +}); diff --git a/src/utils/__tests__/retry.test.ts b/src/utils/__tests__/retry.test.ts new file mode 100644 index 0000000..b753aa7 --- /dev/null +++ b/src/utils/__tests__/retry.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect } from "bun:test"; +import { withRetry, createExponentialBackoff, isTransientError } from "../retry"; + +describe("retry utility", () => { + describe("withRetry", () => { + it("succeeds after transient failures", async () => { + let attempts = 0; + const result = await withRetry( + async () => { + attempts += 1; + if (attempts < 3) { + throw new Error("network timeout"); + } + return "ok"; + }, + { + maxRetries: 4, + initialDelayMs: 5, + maxDelayMs: 5, + retryOn: isTransientError, + } + ); + + expect(result).toBe("ok"); + expect(attempts).toBe(3); + }); + + it("stops retrying when predicate returns false", async () => { + let attempts = 0; + await expect( + withRetry( + async () => { + attempts += 1; + throw new Error("fatal"); + }, + { + maxRetries: 5, + retryOn: () => false, + } + ) + ).rejects.toThrow("fatal"); + + expect(attempts).toBe(1); + }); + }); + + describe("createExponentialBackoff", () => { + it("caps the delay at maxDelay", () => { + const backoff = createExponentialBackoff(10, 2, 40); + expect(backoff(0)).toBe(10); + expect(backoff(1)).toBe(20); + expect(backoff(2)).toBe(40); + expect(backoff(3)).toBe(40); + }); + }); + + describe("isTransientError", () => { + it("detects known transient patterns", () => { + expect(isTransientError(new Error("ECONNRESET"))).toBe(true); + expect(isTransientError(new Error("ECONNREFUSED"))).toBe(true); + expect(isTransientError(new Error("ETIMEDOUT"))).toBe(true); + expect(isTransientError(new Error("socket hang up"))).toBe(true); + expect(isTransientError(new Error("network error"))).toBe(true); + }); + + it("returns false for other errors and primitives", () => { + expect(isTransientError(new Error("file not found"))).toBe(false); + expect(isTransientError("plain string" as unknown)).toBe(false); + }); + }); +});