diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 67895c1..8e32ed9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,6 +11,17 @@ Thanks for contributing! Any contributions you make are greatly appreciated (and 5. Write tests to cover your changes and ensure they pass by running `npm run test`. 6. Commit your changes with a clear and concise commit message. Follow good Git commit message practices, such as using prefixes like `fix`, `feat`, `chore`, `test`, etc. +## Testing + +- Unit/UI tests: `npm run test` +- E2E tests (Playwright): `npm run test:e2e` +- Optional UI runner: `npm run test:e2e:ui` + +When adding a new utility, prefer: +- One Jest UI smoke test (render + heading). +- One unit test for the conversion utility (if it lives in `components/utils`). +- One Playwright E2E flow using fixtures under `tests/fixtures`. + ## Submitting a Pull Request 1. Push your branch to your forked repository. diff --git a/__tests__/pages/utilities/utilities.smoke.test.tsx b/__tests__/pages/utilities/utilities.smoke.test.tsx new file mode 100644 index 0000000..193d25a --- /dev/null +++ b/__tests__/pages/utilities/utilities.smoke.test.tsx @@ -0,0 +1,91 @@ +import { render, screen } from "@testing-library/react"; + +import Base64Encoder from "../../../pages/utilities/base-64-encoder"; +import Base64ToImage from "../../../pages/utilities/base64-to-image"; +import CameraUtility from "../../../pages/utilities/cam"; +import CssInliner from "../../../pages/utilities/css-inliner-for-email"; +import CssUnitsConverter from "../../../pages/utilities/css-units-converter"; +import CSVtoJSON from "../../../pages/utilities/csv-to-json"; +import CurlToFetch from "../../../pages/utilities/curl-to-javascript-fetch"; +import EnvToToml from "../../../pages/utilities/env-to-netlify-toml"; +import HARFileViewer from "../../../pages/utilities/har-file-viewer"; +import HashGenerator from "../../../pages/utilities/hash-generator"; +import HexToRGB from "../../../pages/utilities/hex-to-rgb"; +import ImageResizer from "../../../pages/utilities/image-resizer"; +import ImageToBase64 from "../../../pages/utilities/image-to-base64"; +import InternetSpeedTest from "../../../pages/utilities/internet-speed-test"; +import JSONFormatter from "../../../pages/utilities/json-formatter"; +import JSONtoCSV from "../../../pages/utilities/json-to-csv"; +import JSONtoYAML from "../../../pages/utilities/json-to-yaml"; +import JWTParser from "../../../pages/utilities/jwt-parser"; +import LoremIpsumGenerator from "../../../pages/utilities/lorem-ipsum-generator"; +import NumberBaseChanger from "../../../pages/utilities/number-base-changer"; +import QueryParamsToJSON from "../../../pages/utilities/query-params-to-json"; +import RandomStringGenerator from "../../../pages/utilities/random-string-generator"; +import RegexTester from "../../../pages/utilities/regex-tester"; +import RGBToHex from "../../../pages/utilities/rgb-to-hex"; +import SQLMinifier from "../../../pages/utilities/sql-minifier"; +import SVGViewer from "../../../pages/utilities/svg-viewer"; +import TimestampToDate from "../../../pages/utilities/timestamp-to-date"; +import URLEncoder from "../../../pages/utilities/url-encoder"; +import UuidGenerator from "../../../pages/utilities/uuid-generator"; +import WcagColorContrastChecker from "../../../pages/utilities/wcag-color-contrast-checker"; +import WebpConverter from "../../../pages/utilities/webp-converter"; +import XMLtoJSON from "../../../pages/utilities/xml-to-json"; +import YAMLtoJSON from "../../../pages/utilities/yaml-to-json"; + +jest.mock("@cloudflare/speedtest", () => { + return class MockSpeedTestEngine { + results = { + getSummary: () => ({}), + }; + play = jest.fn(); + pause = jest.fn(); + onResultsChange = () => {}; + onFinish = () => {}; + onError = () => {}; + }; +}); + +const utilities = [ + Base64Encoder, + Base64ToImage, + CameraUtility, + CssInliner, + CssUnitsConverter, + CSVtoJSON, + CurlToFetch, + EnvToToml, + HARFileViewer, + HashGenerator, + HexToRGB, + ImageResizer, + ImageToBase64, + InternetSpeedTest, + JSONFormatter, + JSONtoCSV, + JSONtoYAML, + JWTParser, + LoremIpsumGenerator, + NumberBaseChanger, + QueryParamsToJSON, + RandomStringGenerator, + RegexTester, + RGBToHex, + SQLMinifier, + SVGViewer, + TimestampToDate, + URLEncoder, + UuidGenerator, + WcagColorContrastChecker, + WebpConverter, + XMLtoJSON, + YAMLtoJSON, +]; + +describe("utilities smoke", () => { + test.each(utilities)("renders utility page", (UtilityPage) => { + render(); + expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument(); + }); +}); diff --git a/jest.config.ts b/jest.config.ts index 8f8da62..412dce6 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -13,6 +13,10 @@ const customJestConfig = { "^.+\\.module\\.(css|sass|scss)$", ], modulePathIgnorePatterns: ["/build"], + testPathIgnorePatterns: ["/tests/e2e/"], + moduleNameMapper: { + "^curlconverter$": "/tests/__mocks__/curlconverter.ts", + }, testMatch: [ "**/__tests__/**/*.+(ts|tsx|js)", "**/?(*.)+(spec|test).+(ts|tsx|js)", diff --git a/jest.setup.ts b/jest.setup.ts index 148b93a..3b33360 100644 --- a/jest.setup.ts +++ b/jest.setup.ts @@ -11,3 +11,17 @@ jest.mock("next/navigation", () => ({ refresh: jest.fn(), }), })); + +if (!globalThis.crypto) { + Object.defineProperty(globalThis, "crypto", { + value: {}, + configurable: true, + }); +} + +if (!globalThis.crypto.randomUUID) { + Object.defineProperty(globalThis.crypto, "randomUUID", { + value: () => "00000000-0000-4000-8000-000000000000", + configurable: true, + }); +} diff --git a/package-lock.json b/package-lock.json index 9879cb3..03e8045 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@playwright/test": "^1.58.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.6.1", @@ -1638,6 +1639,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.0.tgz", + "integrity": "sha512-fWza+Lpbj6SkQKCrU6si4iu+fD2dD3gxNHFhUPxsfXBPhnv3rRSQVd0NtBUT9Z/RhF/boCBcuUaMUSTRTopjZg==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@radix-ui/number": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", @@ -10881,6 +10898,53 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.0.tgz", + "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.0.tgz", + "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", diff --git a/package.json b/package.json index a6b44bd..9f39142 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,8 @@ "start": "next start", "lint": "next lint", "test": "jest", + "test:e2e": "playwright test", + "test:e2e:ui": "playwright test --ui", "format": "prettier --write '**/*.{js,jsx,ts,tsx,json,css,scss,md}'", "format:check": "prettier --check '**/*.{js,jsx,ts,tsx,json,css,scss,md}'" }, @@ -41,6 +43,7 @@ "tailwindcss-animate": "^1.0.7" }, "devDependencies": { + "@playwright/test": "^1.58.0", "@testing-library/jest-dom": "^6.4.8", "@testing-library/react": "^16.0.0", "@testing-library/user-event": "^14.6.1", diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..4f9a049 --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,25 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "tests/e2e", + timeout: 30_000, + expect: { + timeout: 10_000, + }, + use: { + baseURL: "http://localhost:3000", + trace: "retain-on-failure", + }, + webServer: { + command: "npm run dev", + url: "http://localhost:3000", + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], +}); diff --git a/tests/__mocks__/curlconverter.ts b/tests/__mocks__/curlconverter.ts new file mode 100644 index 0000000..8a34836 --- /dev/null +++ b/tests/__mocks__/curlconverter.ts @@ -0,0 +1 @@ +export const toJavaScript = () => "fetch('https://example.com')"; diff --git a/tests/e2e/utilities.happy-path.spec.ts b/tests/e2e/utilities.happy-path.spec.ts new file mode 100644 index 0000000..59bf7b6 --- /dev/null +++ b/tests/e2e/utilities.happy-path.spec.ts @@ -0,0 +1,99 @@ +import { test, expect } from "@playwright/test"; +import { fixturePath, readFixture } from "../helpers/fixtures"; +import { gotoUtility } from "../helpers/playwright"; + +test.describe("utilities happy paths", () => { + test("csv-to-json converts input", async ({ page }) => { + await gotoUtility(page, "csv-to-json"); + const csv = readFixture("csv/sample.csv"); + await page.getByPlaceholder("Paste or drag and drop a CSV file").fill(csv); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(/Name/); + }); + + test("json-formatter formats JSON", async ({ page }) => { + await gotoUtility(page, "json-formatter"); + const json = readFixture("json/sample.json"); + await page.getByPlaceholder("Paste JSON here").fill(json); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(/"name":/); + }); + + test("url-encoder encodes text", async ({ page }) => { + await gotoUtility(page, "url-encoder"); + const raw = readFixture("url/sample.txt"); + await page.getByPlaceholder("Paste here").fill(raw); + const encoded = encodeURIComponent(raw); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(encoded); + }); + + test("yaml-to-json converts YAML", async ({ page }) => { + await gotoUtility(page, "yaml-to-json"); + const yaml = readFixture("yaml/sample.yaml"); + await page.getByPlaceholder("Paste YAML here").fill(yaml); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(/"name":/); + }); + + test("xml-to-json converts XML", async ({ page }) => { + await gotoUtility(page, "xml-to-json"); + const xml = readFixture("xml/sample.xml"); + await page.getByPlaceholder("Paste XML here").fill(xml); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(/"root"/); + }); + + test("env-to-netlify-toml converts env", async ({ page }) => { + await gotoUtility(page, "env-to-netlify-toml"); + const env = readFixture("env/sample.env"); + await page.getByPlaceholder("Paste here").fill(env); + const outputs = page.locator("textarea"); + await expect(outputs.nth(1)).toHaveValue(/\[context\.production\]/); + }); + + test("har-file-viewer accepts har upload", async ({ page }) => { + await gotoUtility(page, "har-file-viewer"); + await page + .getByTestId("input") + .setInputFiles(fixturePath("har/sample.har")); + await expect( + page.getByText("https://example.com/api/test") + ).toBeVisible(); + }); + + test("svg-viewer accepts svg upload", async ({ page }) => { + await gotoUtility(page, "svg-viewer"); + await page.getByRole("tab", { name: "Upload SVG File" }).click(); + await page + .locator('input[type="file"]') + .setInputFiles(fixturePath("svg/sample.svg")); + await expect(page.getByLabel("Uploaded SVG code")).toHaveValue(/ { + await gotoUtility(page, "uuid-generator"); + const uuidField = page.locator("input[readonly]"); + await expect(uuidField).toHaveValue( + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i + ); + const initialValue = await uuidField.inputValue(); + await page.getByRole("button", { name: "Generate New UUID" }).click(); + await expect(uuidField).not.toHaveValue(initialValue); + }); + + test("random-string-generator creates string", async ({ page }) => { + await gotoUtility(page, "random-string-generator"); + await page.getByRole("button", { name: "Generate" }).click(); + const output = page.getByPlaceholder( + "Click 'Generate' to create a cryptographically strong random string." + ); + await expect(output).toHaveValue(/.{4,}/); + }); + + test("lorem-ipsum-generator renders text", async ({ page }) => { + await gotoUtility(page, "lorem-ipsum-generator"); + const output = page.locator("textarea").first(); + await expect(output).toHaveValue(/.{4,}/); + }); +}); diff --git a/tests/e2e/utilities.smoke.spec.ts b/tests/e2e/utilities.smoke.spec.ts new file mode 100644 index 0000000..eec5309 --- /dev/null +++ b/tests/e2e/utilities.smoke.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from "@playwright/test"; +import { gotoUtility } from "../helpers/playwright"; + +const utilities = [ + "base-64-encoder", + "base64-to-image", + "cam", + "css-inliner-for-email", + "css-units-converter", + "csv-to-json", + "curl-to-javascript-fetch", + "env-to-netlify-toml", + "har-file-viewer", + "hash-generator", + "hex-to-rgb", + "image-resizer", + "image-to-base64", + "internet-speed-test", + "json-formatter", + "json-to-csv", + "json-to-yaml", + "jwt-parser", + "lorem-ipsum-generator", + "number-base-changer", + "query-params-to-json", + "random-string-generator", + "regex-tester", + "rgb-to-hex", + "sql-minifier", + "svg-viewer", + "timestamp-to-date", + "url-encoder", + "uuid-generator", + "wcag-color-contrast-checker", + "webp-converter", + "xml-to-json", + "yaml-to-json", +]; + +test.describe("utilities smoke", () => { + for (const slug of utilities) { + test(`renders ${slug}`, async ({ page }) => { + await gotoUtility(page, slug); + await expect(page.locator("text=Application error")).toHaveCount(0); + }); + } +}); diff --git a/tests/fixtures/csv/sample.csv b/tests/fixtures/csv/sample.csv new file mode 100644 index 0000000..de625d6 --- /dev/null +++ b/tests/fixtures/csv/sample.csv @@ -0,0 +1,4 @@ +Name,Age,Country +John,25,USA +Alice,30,Canada +Bob,35,UK diff --git a/tests/fixtures/env/sample.env b/tests/fixtures/env/sample.env new file mode 100644 index 0000000..99cd91b --- /dev/null +++ b/tests/fixtures/env/sample.env @@ -0,0 +1,3 @@ +API_URL=https://api.jam.dev +PUBLIC_KEY=abc123 +FEATURE_FLAG=true diff --git a/tests/fixtures/har/sample.har b/tests/fixtures/har/sample.har new file mode 100644 index 0000000..1f33daa --- /dev/null +++ b/tests/fixtures/har/sample.har @@ -0,0 +1,24 @@ +{ + "log": { + "entries": [ + { + "request": { + "url": "https://example.com/api/test", + "method": "GET", + "headers": [{ "name": "Accept", "value": "application/json" }] + }, + "response": { + "status": 200, + "content": { + "size": 124, + "mimeType": "application/json", + "text": "{\"message\":\"success\"}" + }, + "headers": [{ "name": "Content-Type", "value": "application/json" }] + }, + "time": 150, + "startedDateTime": "2023-01-01T00:00:00" + } + ] + } +} diff --git a/tests/fixtures/json/sample.json b/tests/fixtures/json/sample.json new file mode 100644 index 0000000..578092d --- /dev/null +++ b/tests/fixtures/json/sample.json @@ -0,0 +1,6 @@ +{ + "name": "Jam", + "enabled": true, + "count": 3, + "tags": ["a", "b"] +} diff --git a/tests/fixtures/svg/sample.svg b/tests/fixtures/svg/sample.svg new file mode 100644 index 0000000..2632a48 --- /dev/null +++ b/tests/fixtures/svg/sample.svg @@ -0,0 +1,3 @@ + + + diff --git a/tests/fixtures/url/sample.txt b/tests/fixtures/url/sample.txt new file mode 100644 index 0000000..a71fc03 --- /dev/null +++ b/tests/fixtures/url/sample.txt @@ -0,0 +1 @@ +https://jam.dev/utilities?search=hello world&lang=en diff --git a/tests/fixtures/xml/sample.xml b/tests/fixtures/xml/sample.xml new file mode 100644 index 0000000..bf73901 --- /dev/null +++ b/tests/fixtures/xml/sample.xml @@ -0,0 +1,6 @@ + + + Jam + true + 3 + diff --git a/tests/fixtures/yaml/sample.yaml b/tests/fixtures/yaml/sample.yaml new file mode 100644 index 0000000..e55aaba --- /dev/null +++ b/tests/fixtures/yaml/sample.yaml @@ -0,0 +1,6 @@ +name: Jam +enabled: true +count: 3 +tags: + - a + - b diff --git a/tests/helpers/fixtures.ts b/tests/helpers/fixtures.ts new file mode 100644 index 0000000..fff015f --- /dev/null +++ b/tests/helpers/fixtures.ts @@ -0,0 +1,8 @@ +import path from "path"; +import fs from "fs"; + +export const fixturePath = (relativePath: string) => + path.resolve(__dirname, "..", "fixtures", relativePath); + +export const readFixture = (relativePath: string) => + fs.readFileSync(fixturePath(relativePath), "utf8"); diff --git a/tests/helpers/playwright.ts b/tests/helpers/playwright.ts new file mode 100644 index 0000000..79812a5 --- /dev/null +++ b/tests/helpers/playwright.ts @@ -0,0 +1,20 @@ +import { expect, Page } from "@playwright/test"; + +export const gotoUtility = async (page: Page, slug: string) => { + await page.goto(`/utilities/${slug}`); + await expect(page.locator("h1")).toHaveText(/.+/); +}; + +export const fillTextareaByLabel = async ( + page: Page, + label: string, + value: string +) => { + const textarea = page.getByLabel(label); + await textarea.fill(value); +}; + +export const expectTextareaNotEmpty = async (page: Page, label: string) => { + const textarea = page.getByLabel(label); + await expect(textarea).toHaveValue(/.+/); +};