diff --git a/.dockerignore b/.dockerignore index 26396b0..f84fa0c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -15,6 +15,7 @@ LICENSE # Test artifacts test-screenshots/ comparison/ +.lighthouse/ # Build output dist/ diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index f540897..9a2c0e4 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -3,7 +3,7 @@ name: "Check" on: pull_request: push: - branches: [main] + branches: [ main ] jobs: check: @@ -11,18 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v2 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '18' - - - name: Install PNPM - run: npm i -g pnpm + bun-version: 'latest' - name: Install dependencies - run: pnpm install + run: bun install --frozen-lockfile - - name: Format - run: pnpm run check \ No newline at end of file + - name: ESLint check + run: bun run check \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 01e51c9..d23d2b3 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -3,7 +3,7 @@ name: "Lint" on: pull_request: push: - branches: [main] + branches: [ main ] jobs: lint: @@ -11,18 +11,15 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout code - uses: actions/checkout@v2 + uses: actions/checkout@v4 - - name: Set up Node.js - uses: actions/setup-node@v2 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 with: - node-version: '18' - - - name: Install PNPM - run: npm i -g pnpm + bun-version: 'latest' - name: Install dependencies - run: pnpm install + run: bun install --frozen-lockfile - - name: Format - run: pnpm run lint \ No newline at end of file + - name: Biome lint + run: bun run lint \ No newline at end of file diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..1ba51aa --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,50 @@ +name: "Test" + +on: + pull_request: + push: + branches: [ main ] + +jobs: + tests: + name: "Unit and E2E Tests" + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: 'latest' + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Run unit tests + run: bun test:unit + + - name: Start container + run: docker compose up -d + + - name: Wait for container to be ready + run: | + for i in {1..30}; do + if curl -f http://localhost:3000/v1/health > /dev/null 2>&1; then + echo "Container is ready!" + exit 0 + fi + echo "Waiting for container... ($i/30)" + sleep 1 + done + echo "Container failed to start" + echo "Container logs:" + docker compose logs + exit 1 + + - name: Run e2e tests + run: bun test:e2e + + - name: Print logs + if: failure() + run: docker compose logs \ No newline at end of file diff --git a/.gitignore b/.gitignore index 3237484..afa4638 100644 --- a/.gitignore +++ b/.gitignore @@ -144,3 +144,5 @@ dist # Test artifacts test-screenshots/ comparison/ +.lighthouse/ +lighthouse/ diff --git a/Dockerfile b/Dockerfile index 90616b5..424f370 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,7 @@ RUN apk upgrade --no-cache --available && \ tini && \ apk add --no-cache font-wqy-zenhei --repository=http://dl-cdn.alpinelinux.org/alpine/edge/community && \ # remove unnecessary chromium files to save space - rm -rf /usr/lib/chromium/chrome_crashpad_handler \ - /usr/lib/chromium/chrome_200_percent.pak \ + rm -rf /usr/lib/chromium/chrome_200_percent.pak \ /usr/lib/chromium/chrome_100_percent.pak \ /usr/lib/chromium/xdg-mime \ /usr/lib/chromium/xdg-settings \ @@ -31,11 +30,14 @@ ENV PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \ NODE_ENV=production WORKDIR /app -USER chrome COPY package.json ./ COPY --from=base /app/node_modules ./node_modules COPY src/ ./src/ +RUN chown -R chrome:chrome /app + +USER chrome + ENTRYPOINT ["tini", "--"] CMD ["bun", "run", "src/server.ts"] diff --git a/package.json b/package.json index 3b66e12..e5636f7 100644 --- a/package.json +++ b/package.json @@ -9,15 +9,17 @@ "dev": "bun --watch src/server.ts", "build": "tsc", "type-check": "tsc --noEmit", - "format": "biome check --write ./src", - "lint": "biome check ./src", - "check": "eslint ./src", - "test": "echo \"Error: no test specified\" && exit 1" + "format": "biome check --write ./src ./tests", + "lint": "biome check ./src ./tests", + "check": "eslint ./src ./tests", + "test": "bun test", + "test:e2e": "bun test tests/e2e", + "test:unit": "bun test tests/unit" }, "keywords": [], "author": "", "license": "ISC", - "packageManager": "pnpm@10.20.0", + "packageManager": "bun@1.3.2", "dependencies": { "lighthouse": "^12.2.1", "playwright-core": "^1.52.0", diff --git a/src/config/index.ts b/src/config/index.ts index fb0a7ca..8128a15 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,4 @@ export * from "./browser.js"; export * from "./lighthouse.js"; -export const port = Number(process.env.PORT) || 3000; +export const port = process.env.PORT ? Number(process.env.PORT) : 3000; diff --git a/src/routes/reports.ts b/src/routes/reports.ts index e51eadb..dd8dc01 100755 --- a/src/routes/reports.ts +++ b/src/routes/reports.ts @@ -1,9 +1,11 @@ -import type { BrowserContextOptions } from "playwright-core"; +import type { BrowserContext, BrowserContextOptions } from "playwright-core"; import { playAudit } from "playwright-lighthouse"; import { browser, defaultContext, lighthouseConfigs } from "../config"; import { lighthouseSchema } from "../schemas"; export async function handleReportsRequest(req: Request): Promise { + let context: BrowserContext | undefined; + try { const json = await req.json(); const body = lighthouseSchema.parse(json); @@ -19,7 +21,7 @@ export async function handleReportsRequest(req: Request): Promise { if (body.locale) contextOptions.locale = body.locale; if (body.timezoneId) contextOptions.timezoneId = body.timezoneId; - const context = await browser.newContext(contextOptions); + context = await browser.newContext(contextOptions); // Grant permissions if specified if (body.permissions && body.permissions.length > 0) { @@ -72,7 +74,6 @@ export async function handleReportsRequest(req: Request): Promise { thresholds, }); - await context.close(); const report = Array.isArray(results.report) ? results.report.join("") : results.report; @@ -85,5 +86,7 @@ export async function handleReportsRequest(req: Request): Promise { status: 400, headers: { "Content-Type": "application/json" }, }); + } finally { + await context?.close(); } } diff --git a/src/routes/screenshots.ts b/src/routes/screenshots.ts index 81f5a34..6752612 100755 --- a/src/routes/screenshots.ts +++ b/src/routes/screenshots.ts @@ -1,4 +1,5 @@ import type { + BrowserContext, BrowserContextOptions, PageScreenshotOptions, } from "playwright-core"; @@ -8,6 +9,8 @@ import { screenshotSchema } from "../schemas"; export async function handleScreenshotsRequest( req: Request, ): Promise { + let context: BrowserContext | undefined; + try { const json = await req.json(); const body = screenshotSchema.parse(json); @@ -28,7 +31,7 @@ export async function handleScreenshotsRequest( if (body.timezoneId) contextOptions.timezoneId = body.timezoneId; if (body.geolocation) contextOptions.geolocation = body.geolocation; - const context = await browser.newContext(contextOptions); + context = await browser.newContext(contextOptions); // Grant permissions if specified if (body.permissions && body.permissions.length > 0) { @@ -78,8 +81,6 @@ export async function handleScreenshotsRequest( const screen = await page.screenshot(screenshotOptions); - await context.close(); - return new Response(Buffer.from(screen), { headers: { "Content-Type": `image/${body.format}`, @@ -91,5 +92,7 @@ export async function handleScreenshotsRequest( status: 400, headers: { "Content-Type": "application/json" }, }); + } finally { + await context?.close(); } } diff --git a/src/routes/test.ts b/src/routes/test.ts index 8b1126c..c8c2a1d 100755 --- a/src/routes/test.ts +++ b/src/routes/test.ts @@ -3,17 +3,21 @@ import { generateTestHTML } from "../utils/test-page.js"; export async function handleTestRequest(_req: Request): Promise { const context = await browser.newContext(defaultContext); - const page = await context.newPage(); + try { + const page = await context.newPage(); - await page.goto("about:blank"); + await page.goto("about:blank"); - const html = await page.evaluate(() => { - return new Date().toISOString(); - }); + const html = await page.evaluate(() => { + return new Date().toISOString(); + }); - await context.close(); + await context.close(); - return new Response(generateTestHTML(html), { - headers: { "Content-Type": "text/html" }, - }); + return new Response(generateTestHTML(html), { + headers: { "Content-Type": "text/html" }, + }); + } finally { + await context.close(); + } } diff --git a/tests/e2e/endpoints.test.ts b/tests/e2e/endpoints.test.ts new file mode 100644 index 0000000..06e9aef --- /dev/null +++ b/tests/e2e/endpoints.test.ts @@ -0,0 +1,285 @@ +import { describe, expect, test } from "bun:test"; + +const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; + +describe("E2E Tests - /v1/health", () => { + test("should return ok status when browser is connected", async () => { + const response = await fetch(`${BASE_URL}/v1/health`); + const data = await response.json(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + expect(data).toHaveProperty("status"); + expect(data.status).toBe("pass"); + }); + + test("should reject POST method", async () => { + const response = await fetch(`${BASE_URL}/v1/health`, { + method: "POST", + }); + + expect(response.status).toBe(404); + }); +}); + +describe("E2E Tests - /v1/test", () => { + test("should return HTML test page", async () => { + const response = await fetch(`${BASE_URL}/v1/test`); + const html = await response.text(); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("text/html"); + expect(html).toContain(""); + expect(html).toContain("Browser Configuration Test"); + }); + + test("should include timestamp in response", async () => { + const response = await fetch(`${BASE_URL}/v1/test`); + const html = await response.text(); + + expect(html).toContain("Generated:"); + // Check for ISO 8601 date format + expect(html).toMatch(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/); + }); + + test("should reject POST method", async () => { + const response = await fetch(`${BASE_URL}/v1/test`, { + method: "POST", + }); + + expect(response.status).toBe(404); + }); +}); + +describe("E2E Tests - /v1/screenshots", () => { + test("should capture screenshot with minimal input", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/png"); + + const buffer = await response.arrayBuffer(); + expect(buffer.byteLength).toBeGreaterThan(0); + }, 15000); + + test("should capture screenshot with custom viewport", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + viewport: { + width: 800, + height: 600, + }, + }), + }); + + expect(response.status).toBe(200); + const buffer = await response.arrayBuffer(); + expect(buffer.byteLength).toBeGreaterThan(0); + }, 15000); + + test("should support jpeg format", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + format: "jpeg", + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("image/jpeg"); + }, 15000); + + test("should capture full page screenshot", async () => { + const partialResponse = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://itznotabug.dev", + fullPage: false, + }), + }); + + const partialBuffer = await partialResponse.arrayBuffer(); + + const fullPageResponse = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://itznotabug.dev", + fullPage: true, + }), + }); + + expect(fullPageResponse.status).toBe(200); + const fullPageBuffer = await fullPageResponse.arrayBuffer(); + + // Full page screenshot should be larger than partial + expect(fullPageBuffer.byteLength).toBeGreaterThan(partialBuffer.byteLength); + }, 15000); + + test("should capture clipped region", async () => { + const fullResponse = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + }), + }); + + const fullBuffer = await fullResponse.arrayBuffer(); + + const clippedResponse = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + clip: { + x: 0, + y: 0, + width: 400, + height: 300, + }, + }), + }); + + expect(clippedResponse.status).toBe(200); + const clippedBuffer = await clippedResponse.arrayBuffer(); + + expect(clippedBuffer.byteLength).toBeLessThan(fullBuffer.byteLength); + }, 15000); + + test("should return 400 for malformed JSON", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: "{ invalid json", + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toHaveProperty("error"); + }); + + test("should return 400 for missing URL parameter", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + format: "png", + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toHaveProperty("error"); + }); + + test("should return 400 for invalid image format", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + format: "webp", + url: "https://example.com", + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toHaveProperty("error"); + }); + + test("should reject invalid URL", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "not-a-url", + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toHaveProperty("error"); + }); + + test("should reject GET method", async () => { + const response = await fetch(`${BASE_URL}/v1/screenshots`); + expect(response.status).toBe(404); + }); +}); + +describe("E2E Tests - /v1/reports", () => { + test("should generate lighthouse report", async () => { + const response = await fetch(`${BASE_URL}/v1/reports`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + viewport: "mobile", + }), + }); + + expect(response.status).toBe(200); + expect(response.headers.get("Content-Type")).toBe("application/json"); + + const data = await response.json(); + expect(data).toBeTruthy(); + }, 30000); + + test("should support desktop viewport", async () => { + const response = await fetch(`${BASE_URL}/v1/reports`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + viewport: "desktop", + }), + }); + + expect(response.status).toBe(200); + }, 30000); + + test("should reject invalid viewport", async () => { + const response = await fetch(`${BASE_URL}/v1/reports`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + url: "https://example.com", + viewport: "tablet", + }), + }); + + expect(response.status).toBe(400); + const data = await response.json(); + expect(data).toHaveProperty("error"); + }); + + test("should reject GET method", async () => { + const response = await fetch(`${BASE_URL}/v1/reports`); + expect(response.status).toBe(404); + }); +}); + +describe("E2E Tests - 404 handling", () => { + test("should return 404 for unknown routes", async () => { + const response = await fetch(`${BASE_URL}/unknown`); + expect(response.status).toBe(404); + }); + + test("should return 404 for /v1/unknown", async () => { + const response = await fetch(`${BASE_URL}/v1/unknown`); + expect(response.status).toBe(404); + }); +}); diff --git a/tests/unit/lighthouse.schema.test.ts b/tests/unit/lighthouse.schema.test.ts new file mode 100644 index 0000000..07327e8 --- /dev/null +++ b/tests/unit/lighthouse.schema.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, test } from "bun:test"; +import { lighthouseSchema } from "../../src/schemas"; + +describe("lighthouseSchema", () => { + test("should validate minimal valid input", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should apply default values", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + }; + + const result = lighthouseSchema.parse(input); + expect(result.theme).toBe("light"); + expect(result.html).toBe(false); + expect(result.json).toBe(true); + expect(result.csv).toBe(false); + expect(result.waitUntil).toBe("domcontentloaded"); + expect(result.timeout).toBe(30000); + }); + + test("should validate both viewport options", () => { + const mobileInput = { + url: "https://example.com", + viewport: "mobile", + }; + + const mobileResult = lighthouseSchema.safeParse(mobileInput); + expect(mobileResult.success).toBe(true); + + const desktopInput = { + url: "https://example.com", + viewport: "desktop", + }; + + const desktopResult = lighthouseSchema.safeParse(desktopInput); + expect(desktopResult.success).toBe(true); + }); + + test("should reject invalid viewport", () => { + const input = { + url: "https://example.com", + viewport: "tablet", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should validate report format flags", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + html: true, + json: true, + csv: true, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.html).toBe(true); + expect(result.data.json).toBe(true); + expect(result.data.csv).toBe(true); + } + }); + + test("should validate custom thresholds", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: 90, + accessibility: 80, + "best-practices": 85, + seo: 75, + pwa: 50, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject threshold out of range", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: 101, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should validate permissions array", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + permissions: ["geolocation", "notifications"], + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should validate headers", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + headers: { + "X-Custom-Header": "value", + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject invalid URL", () => { + const input = { + url: "not-a-url", + viewport: "mobile", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Threshold boundary tests + test("should accept thresholds at minimum (0)", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: 0, + accessibility: 0, + "best-practices": 0, + seo: 0, + pwa: 0, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept thresholds at maximum (100)", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: 100, + accessibility: 100, + "best-practices": 100, + seo: 100, + pwa: 100, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject threshold below minimum", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: -1, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should accept partial thresholds", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + performance: 90, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject threshold exceeding maximum for accessibility", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + accessibility: 101, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject threshold exceeding maximum for seo", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + thresholds: { + seo: 150, + }, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Report format combination tests + test("should support json and html formats together", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + json: true, + html: true, + csv: false, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(true); + expect(result.data.html).toBe(true); + expect(result.data.csv).toBe(false); + } + }); + + test("should support json and csv formats together", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + json: true, + html: false, + csv: true, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(true); + expect(result.data.html).toBe(false); + expect(result.data.csv).toBe(true); + } + }); + + test("should support html and csv formats together", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + json: false, + html: true, + csv: true, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(false); + expect(result.data.html).toBe(true); + expect(result.data.csv).toBe(true); + } + }); + + test("should support all formats disabled", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + json: false, + html: false, + csv: false, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.json).toBe(false); + expect(result.data.html).toBe(false); + expect(result.data.csv).toBe(false); + } + }); + + // Timeout boundary tests + test("should accept timeout at minimum (0)", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timeout: 0, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept timeout at maximum (120000)", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timeout: 120000, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject timeout exceeding maximum", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timeout: 120001, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject negative timeout", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timeout: -1, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Timezone validation tests + test("should accept valid IANA timezones", () => { + const timezones = [ + "America/New_York", + "Europe/London", + "Asia/Tokyo", + "UTC/GMT", + ]; + + for (const timezoneId of timezones) { + const input = { + url: "https://example.com", + viewport: "mobile", + timezoneId, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + } + }); + + test("should reject invalid timezone format", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timezoneId: "InvalidTimezone", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject timezone without region", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + timezoneId: "America", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Theme validation tests + test("should accept both theme options", () => { + const lightInput = { + url: "https://example.com", + viewport: "mobile", + theme: "light", + }; + + const lightResult = lighthouseSchema.safeParse(lightInput); + expect(lightResult.success).toBe(true); + + const darkInput = { + url: "https://example.com", + viewport: "mobile", + theme: "dark", + }; + + const darkResult = lighthouseSchema.safeParse(darkInput); + expect(darkResult.success).toBe(true); + }); + + test("should reject invalid theme", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + theme: "blue", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // waitUntil validation tests + test("should accept all valid waitUntil options", () => { + const options = ["load", "domcontentloaded", "networkidle", "commit"]; + + for (const waitUntil of options) { + const input = { + url: "https://example.com", + viewport: "mobile", + waitUntil, + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(true); + } + }); + + test("should reject invalid waitUntil option", () => { + const input = { + url: "https://example.com", + viewport: "mobile", + waitUntil: "complete", + }; + + const result = lighthouseSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/unit/screenshot.schema.test.ts b/tests/unit/screenshot.schema.test.ts new file mode 100644 index 0000000..b6082ac --- /dev/null +++ b/tests/unit/screenshot.schema.test.ts @@ -0,0 +1,489 @@ +import { describe, expect, test } from "bun:test"; +import { screenshotSchema } from "../../src/schemas"; + +describe("screenshotSchema", () => { + test("should validate minimal valid input", () => { + const input = { + url: "https://example.com", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should apply default values", () => { + const input = { + url: "https://example.com", + }; + + const result = screenshotSchema.parse(input); + expect(result.theme).toBe("light"); + expect(result.format).toBe("png"); + expect(result.fullPage).toBe(false); + expect(result.quality).toBe(90); + expect(result.waitUntil).toBe("domcontentloaded"); + expect(result.timeout).toBe(30000); + expect(result.sleep).toBe(3000); + }); + + test("should validate custom viewport", () => { + const input = { + url: "https://example.com", + viewport: { + width: 1920, + height: 1080, + }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.viewport?.width).toBe(1920); + expect(result.data.viewport?.height).toBe(1080); + } + }); + + test("should reject invalid URL", () => { + const input = { + url: "not-a-url", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject invalid theme", () => { + const input = { + url: "https://example.com", + theme: "invalid", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject invalid format", () => { + const input = { + url: "https://example.com", + format: "gif", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should validate all supported formats", () => { + const formats = ["png", "jpeg", "webp"]; + + for (const format of formats) { + const input = { + url: "https://example.com", + format, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + } + }); + + test("should validate quality range for jpeg", () => { + const validInput = { + url: "https://example.com", + format: "jpeg", + quality: 90, + }; + + const result = screenshotSchema.safeParse(validInput); + expect(result.success).toBe(true); + + const invalidInput = { + url: "https://example.com", + format: "jpeg", + quality: 101, + }; + + const invalidResult = screenshotSchema.safeParse(invalidInput); + expect(invalidResult.success).toBe(false); + }); + + test("should validate permissions array", () => { + const input = { + url: "https://example.com", + permissions: ["geolocation", "notifications"], + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should validate geolocation", () => { + const input = { + url: "https://example.com", + geolocation: { + latitude: 37.7749, + longitude: -122.4194, + }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should validate clip region", () => { + const input = { + url: "https://example.com", + clip: { + x: 0, + y: 0, + width: 800, + height: 600, + }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should validate headers", () => { + const input = { + url: "https://example.com", + headers: { + "X-Custom-Header": "value", + Authorization: "Bearer token", + }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + // Viewport boundary tests + test("should accept viewport at minimum (1x1)", () => { + const input = { + url: "https://example.com", + viewport: { width: 1, height: 1 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept viewport at maximum (3840x2160)", () => { + const input = { + url: "https://example.com", + viewport: { width: 3840, height: 2160 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject viewport exceeding maximum width", () => { + const input = { + url: "https://example.com", + viewport: { width: 3841, height: 1080 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject viewport exceeding maximum height", () => { + const input = { + url: "https://example.com", + viewport: { width: 1920, height: 2161 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject viewport with zero dimensions", () => { + const input = { + url: "https://example.com", + viewport: { width: 0, height: 0 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Quality boundary tests + test("should accept quality at minimum (0)", () => { + const input = { + url: "https://example.com", + format: "jpeg", + quality: 0, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept quality at maximum (100)", () => { + const input = { + url: "https://example.com", + format: "jpeg", + quality: 100, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject quality below minimum", () => { + const input = { + url: "https://example.com", + format: "jpeg", + quality: -1, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Sleep boundary tests + test("should accept sleep at minimum (0)", () => { + const input = { + url: "https://example.com", + sleep: 0, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept sleep at maximum (60000)", () => { + const input = { + url: "https://example.com", + sleep: 60000, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject sleep exceeding maximum", () => { + const input = { + url: "https://example.com", + sleep: 60001, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject negative sleep", () => { + const input = { + url: "https://example.com", + sleep: -1, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Timeout boundary tests + test("should accept timeout at minimum (0)", () => { + const input = { + url: "https://example.com", + timeout: 0, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept timeout at maximum (120000)", () => { + const input = { + url: "https://example.com", + timeout: 120000, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject timeout exceeding maximum", () => { + const input = { + url: "https://example.com", + timeout: 120001, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Device scale factor boundary tests + test("should accept device scale factor at minimum (0.1)", () => { + const input = { + url: "https://example.com", + deviceScaleFactor: 0.1, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept device scale factor at maximum (3)", () => { + const input = { + url: "https://example.com", + deviceScaleFactor: 3, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject device scale factor below minimum", () => { + const input = { + url: "https://example.com", + deviceScaleFactor: 0.09, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject device scale factor above maximum", () => { + const input = { + url: "https://example.com", + deviceScaleFactor: 3.1, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Geolocation boundary tests + test("should accept geolocation at latitude minimum (-90)", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: -90, longitude: 0 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept geolocation at latitude maximum (90)", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: 90, longitude: 0 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject geolocation exceeding latitude bounds", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: 91, longitude: 0 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should accept geolocation at longitude minimum (-180)", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: 0, longitude: -180 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should accept geolocation at longitude maximum (180)", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: 0, longitude: 180 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject geolocation exceeding longitude bounds", () => { + const input = { + url: "https://example.com", + geolocation: { latitude: 0, longitude: 181 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Clip region boundary tests + test("should accept clip at minimum dimensions", () => { + const input = { + url: "https://example.com", + clip: { x: 0, y: 0, width: 1, height: 1 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + }); + + test("should reject clip with zero width", () => { + const input = { + url: "https://example.com", + clip: { x: 0, y: 0, width: 0, height: 100 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject clip with zero height", () => { + const input = { + url: "https://example.com", + clip: { x: 0, y: 0, width: 100, height: 0 }, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + // Timezone validation tests + test("should accept valid IANA timezones", () => { + const timezones = [ + "America/New_York", + "Europe/London", + "Asia/Tokyo", + "UTC/GMT", + ]; + + for (const timezoneId of timezones) { + const input = { + url: "https://example.com", + timezoneId, + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(true); + } + }); + + test("should reject invalid timezone format", () => { + const input = { + url: "https://example.com", + timezoneId: "InvalidTimezone", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); + + test("should reject timezone without region", () => { + const input = { + url: "https://example.com", + timezoneId: "America", + }; + + const result = screenshotSchema.safeParse(input); + expect(result.success).toBe(false); + }); +}); diff --git a/tests/unit/test-page.test.ts b/tests/unit/test-page.test.ts new file mode 100644 index 0000000..3dd0f86 --- /dev/null +++ b/tests/unit/test-page.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { generateTestHTML } from "../../src/utils/test-page.js"; + +describe("generateTestHTML", () => { + test("should generate valid HTML", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain(""); + expect(html).toContain(''); + expect(html).toContain(""); + }); + + test("should include the timestamp", () => { + const timestamp = "2024-01-01T12:34:56.789Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain(timestamp); + }); + + test("should include the title", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain("Browser Configuration Test"); + }); + + test("should include viewport section", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain("Viewport Width"); + expect(html).toContain("Viewport Height"); + expect(html).toContain("Device Pixel Ratio"); + }); + + test("should include localization section", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain("Language"); + expect(html).toContain("Timezone"); + }); + + test("should include updateValues function", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain("function updateValues()"); + }); + + test("should include refresh button", () => { + const timestamp = "2024-01-01T00:00:00.000Z"; + const html = generateTestHTML(timestamp); + + expect(html).toContain("refresh-btn"); + expect(html).toContain("🔄 Refresh Values"); + }); +});