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(/