diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c51984c..8e3a577 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,6 +16,10 @@ body: - Base64 - URL encoder/decoder - Invoice generator + - Color converter & contrast checker + - Unix timestamp converter + - Text case converter + - Number base converter - Shell (theme, command palette, header) - Other / not sure validations: diff --git a/.github/dependabot.yml b/.github/dependabot.yml index d98153c..7effae5 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -6,6 +6,21 @@ updates: interval: weekly open-pull-requests-limit: 5 groups: + # Next and the OpenNext adapter have to move together. next 16.4.0 on its own + # (#169) broke every page until @opennextjs/cloudflare 1.20.12 (v0.7.0). + # Dependabot puts a dependency in the first group it matches, so this one + # has to stay above dev-dependencies to take eslint-config-next. + next: + patterns: + - "next" + - "@opennextjs/cloudflare" + - "eslint-config-next" + # react-dom refuses to load next to a react of a different version, so a + # bump of one without the other fails the tests (#224 and #226). + react: + patterns: + - "react" + - "react-dom" dev-dependencies: dependency-type: development ignore: diff --git a/.github/workflows/accepted-label.yml b/.github/workflows/accepted-label.yml new file mode 100644 index 0000000..cb46df7 --- /dev/null +++ b/.github/workflows/accepted-label.yml @@ -0,0 +1,89 @@ +name: Accepted label + +# A merged PR counts on the leaderboard only with the hacktoberfest-accepted +# label, and only for an issue that was assigned to its author (CONTRIBUTING.md). +# Maintainers add the label when they merge. This adds it when that was missed, +# by the same rule as the claim check: every issue the PR closes is assigned to +# the author. It runs once, at the merge, so removing the label later sticks. +# +# It uses pull_request_target so it can label fork PRs. That is safe here: the +# job never checks out or runs the PR's code. +on: + pull_request_target: + branches: [develop] + types: [closed] + +permissions: + issues: read + pull-requests: write + +jobs: + label: + name: Label a merged contributor PR + # Maintainers and bots don't claim issues, and spam never counts. + if: >- + github.event.pull_request.merged == true && + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association) && + !contains(github.event.pull_request.labels.*.name, 'hacktoberfest-accepted') && + !contains(github.event.pull_request.labels.*.name, 'spam') + runs-on: ubuntu-latest + steps: + - name: Add the label if every linked issue is assigned to the author + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + PR: ${{ github.event.pull_request.number }} + run: | + set -euo pipefail + + pr=$(gh api graphql \ + -f owner="${REPO%/*}" -f name="${REPO#*/}" -F pr="$PR" \ + -f query='query($owner: String!, $name: String!, $pr: Int!) { + repository(owner: $owner, name: $name) { + pullRequest(number: $pr) { + author { login } + body + closingIssuesReferences(first: 20) { + nodes { number repository { nameWithOwner } assignees(first: 20) { nodes { login } } } + } + } + } + }' \ + --jq '.data.repository.pullRequest') + author=$(jq -r '.author.login // ""' <<<"$pr") + + # The issues this PR closed ("Closes #12" in the description). + linked=$(jq --arg repo "$REPO" '[ .closingIssuesReferences.nodes[] | { + ref: (if .repository.nameWithOwner == $repo then "#\(.number)" else "\(.repository.nameWithOwner)#\(.number)" end), + assignees: [.assignees.nodes[].login] + } ]' <<<"$pr") + + # Same fallback as the claim check: read "Closes #12" from the description. + if [ "$(jq length <<<"$linked")" -eq 0 ]; then + numbers=$(jq -r '[ (.body // "") | scan("(?i)\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s*:?\\s+#([0-9]+)") | .[0] ] | unique | .[]' <<<"$pr") + for number in $numbers; do + if issue=$(gh api "repos/$REPO/issues/$number" --jq 'select(.pull_request == null) | {ref: "#\(.number)", assignees: [.assignees[].login]}' 2>/dev/null) && [ -n "$issue" ]; then + linked=$(jq --argjson issue "$issue" '. + [$issue]' <<<"$linked") + fi + done + fi + + if [ "$(jq length <<<"$linked")" -eq 0 ]; then + echo "::notice title=Not labeled::This PR isn't linked to an issue, so it doesn't get hacktoberfest-accepted." + exit 0 + fi + + missing=$(jq -r --arg author "$author" ' + [ .[] + | select(all(.assignees[]; ascii_downcase != ($author | ascii_downcase))) + | .ref ] + | join(", ")' <<<"$linked") + + if [ -n "$missing" ]; then + echo "::notice title=Not labeled::$missing isn't assigned to @$author, so this PR doesn't get hacktoberfest-accepted." + exit 0 + fi + + gh api -X POST "repos/$REPO/issues/$PR/labels" -f "labels[]=hacktoberfest-accepted" --silent + echo "Added hacktoberfest-accepted: $(jq -r '[.[].ref] | join(", ")' <<<"$linked") assigned to @$author" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9183990..8b457a7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,6 +11,16 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 + # Some editors save files with a UTF-8 BOM, and a tool that reads UTF-8 with + # the wrong encoding turns a dash or an arrow into two or three other + # characters. Both have reached PRs here, so catch them before review. + - name: No BOM or garbled characters + run: | + if git grep -n -I -F -e $'\xEF\xBB\xBF' -e $'\xCE\x93\xC3\x87' -e $'\xCE\x93\xC3\xA5\xC3\x86' \ + -e $'\xE2\x94\xAC\xE2\x95\x96' -e $'\xC3\xA2\xE2\x82\xAC' -e $'\xC3\x83\xC2\xA9'; then + echo "::error::The lines above start with a UTF-8 BOM or have text garbled by the wrong encoding. Save the file as UTF-8 without a BOM, and copy garbled lines back from the base branch." + exit 1 + fi - uses: actions/setup-node@v6 with: node-version: 22 @@ -18,4 +28,61 @@ jobs: - run: npm ci - run: npm run lint - run: npm run test - - run: npm run build + # Builds the Worker the same way Workers Builds does. This runs `npm run build` + # (next build) first, so there is no separate build step. + - run: npx opennextjs-cloudflare build + # A green next build doesn't mean the Worker runs. v0.7.0 built fine and then + # threw on every request, so start the built Worker in local workerd and + # request every page in the sitemap, the og:image each page links to and a + # missing page. + - name: Request every page from the built Worker + env: + WRANGLER_SEND_METRICS: "false" + run: | + npx wrangler dev --ip 127.0.0.1 --port 8787 > wrangler.log 2>&1 & + base=http://127.0.0.1:8787 + for i in $(seq 90); do + curl -s -o /dev/null "$base/robots.txt" && break + sleep 1 + done + pages=$(curl -s "$base/sitemap.xml" | grep -o '[^<]*' | sed -E 's|https?://[^/]+||') + fail=0 + if [ -z "$pages" ]; then + echo "sitemap.xml has no pages" + fail=1 + fi + check() { + code=$(curl -s -o body.out -w '%{http_code}' "$base$1" || true) + echo "$code $1" + if [ "$code" != "$2" ]; then fail=1; fi + } + for p in $pages; do + check "$p" 200 + img=$(grep -o 'property="og:image" content="[^"]*' body.out | head -1 | sed -E 's|.*content="https?://[^/]+||; s|&|\&|g') + if [ -n "$img" ]; then + check "$img" 200 + else + echo "no og:image on $p" + fail=1 + fi + done + check /sitemap.xml 200 + check /robots.txt 200 + check /no-such-page 404 + if grep -q 'wrangler:error' wrangler.log; then fail=1; fi + if [ "$fail" != 0 ]; then + echo "The built Worker failed. Wrangler output:" + cat wrangler.log + exit 1 + fi + - name: Lighthouse CI performance budgets + run: | + if ! curl -s -o /dev/null http://127.0.0.1:8787/robots.txt; then + npx wrangler dev --ip 127.0.0.1 --port 8787 > wrangler.log 2>&1 & + for i in $(seq 30); do + curl -s -o /dev/null http://127.0.0.1:8787/robots.txt && break + sleep 1 + done + fi + npm run lighthouse + pkill -f "wrangler dev" || true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 32cd16a..42b7811 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,12 +74,25 @@ jobs: needs: release runs-on: ubuntu-latest env: - # The repo variable points at the workers.dev URL until the - # tools.codercops.com cutover, and at the real host after it. + # Set the SMOKE_BASE_URL repo variable to check another host. BASE_URL: ${{ vars.SMOKE_BASE_URL || 'https://tools.codercops.com' }} EXPECTED_SHA: ${{ github.event.pull_request.merge_commit_sha }} steps: + # Bot Fight Mode on codercops.com answers GitHub's runners with a + # Cloudflare challenge (403 with cf-mitigated: challenge) instead of the + # site, and production has no workers.dev address to check instead. Then + # the checks below can't see the deploy, so they're skipped with a notice + # and the live site gets checked by hand. + - name: Check whether Cloudflare challenges this runner + run: | + read -r STATUS MITIGATED < <(curl -s -o /dev/null -w "%{http_code} %header{cf-mitigated}\n" --max-time 15 "$BASE_URL/version.txt") + if [ "$STATUS" = "403" ] && [ "$MITIGATED" = "challenge" ]; then + echo "::notice::Cloudflare challenged this runner on $BASE_URL, so the deploy wasn't checked from here. Check the live site by hand." + echo "CHALLENGED=1" >> "$GITHUB_ENV" + fi + - name: Wait for the Cloudflare deploy of this merge + if: env.CHALLENGED != '1' run: | # Workers Builds deploys production on its own. version.txt carries # the commit it built, so poll until it matches (up to 15 minutes). @@ -95,6 +108,7 @@ jobs: exit 1 - name: Homepage reachable + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/") if [ "$STATUS" != "200" ]; then @@ -104,6 +118,7 @@ jobs: echo "Homepage check passed" - name: Tool page reachable + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/json-formatter") if [ "$STATUS" != "200" ]; then @@ -113,6 +128,7 @@ jobs: echo "Tool page check passed" - name: Sitemap valid + if: env.CHALLENGED != '1' run: | STATUS=$(curl -sL -o /dev/null -w "%{http_code}" --max-time 15 "$BASE_URL/sitemap.xml") if [ "$STATUS" != "200" ]; then diff --git a/.gitignore b/.gitignore index 4328ae7..d68e34b 100644 --- a/.gitignore +++ b/.gitignore @@ -60,3 +60,6 @@ generated/ # local tooling notes CLAUDE.md AGENTS.md + +# lighthouse ci reports +.lighthouseci/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5dbd3ad..99c5c4d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,7 +10,7 @@ We're taking part in Hacktoberfest this year, and first-time contributors are ve 2. Comment on it to ask for it. I'll assign it to you, usually within a day. Please don't start on an issue that's assigned to someone else. 3. Take one issue at a time. Once your PR is merged, grab the next one. After 3 merged PRs with us you can hold two issues at once. 4. If you're assigned and there's no PR or update for 5 days, I'll free the issue up for someone else. Just comment if you need more time. -5. Open your PR against `develop` and put `Closes #` in the description. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty. +5. Open your PR against `develop` here in codercops/toolbelt and put `Closes #` in the description. A PR merged inside your own fork never reaches us, so it doesn't count. A check on the PR ("Claim check") fails if that issue isn't assigned to you. Another one ("Description check") fails while the "How I tested this" or "AI assistance" section of the description is missing or empty. PRs count for Hacktoberfest once they're merged or labeled `hacktoberfest-accepted`. PRs that aren't linked to an issue, only reformat code, or change things nobody asked for will be closed, and spammy ones get the `spam` label. If you have an idea that isn't an issue yet, open an issue first so we can agree on it before you write code. The full rules are under [Ground rules](#ground-rules) below. @@ -80,6 +80,8 @@ Before opening a pull request, make sure this passes: npm run lint && npm run test && npm run build ``` +CI also builds the Cloudflare Worker and requests every page from it in local workerd. If that step fails while `npm run build` passes locally, run `npm run preview` and open the page the CI log names; the error shows up in the terminal. + ## How the code is organized - Pure logic lives in `lib/` (parsing, crypto, PDF, conversions). It has no React and no DOM, so it is easy to test. Put new logic here, not inside components. @@ -90,6 +92,7 @@ npm run lint && npm run test && npm run build ## Guidelines - Add or update a test in `lib/__tests__/` for any behavior change to `lib/`. The bugs this project has fixed were exactly the kind unit tests catch (number precision, money rounding, encoding edge cases). +- Add component tests in `components/` (or the corresponding `app/` route directory) using the happy-dom test project. - Keep the change focused. Small, single-purpose PRs are reviewed faster. - Match the surrounding style. There is no separate formatter config; follow the existing code. - No analytics, trackers, or network calls that send user data anywhere. The privacy claim is the whole point. @@ -114,7 +117,7 @@ toolbelt follows the standard CODERCOPS flow: - `develop` is the default and integration branch; `production` is the deployed branch (tools.codercops.com). Never commit directly to either. - Start every change on a branch off `develop`: `feat/*`, `fix/*`, `chore/*`, or `docs/*`. -- Open a PR into `develop` and **squash and merge** it (one squashed commit per feature). Use conventional commit titles (`feat:`, `fix:`, `chore:`, `docs:`). CI (lint, tests, build) must be green. +- Open a PR into `develop` and **squash and merge** it (one squashed commit per feature). Use conventional commit titles (`feat:`, `fix:`, `chore:`, `docs:`). CI (lint, tests, build, and the page check on the built Worker) must be green. - A release is a PR from `develop` into `production`, merged as a **merge commit** (not squash), so production keeps full history. Bump the `package.json` version in that PR, and add a `release:minor` or `release:major` label if it is not a patch. - Merging the release PR runs the Release workflow: it tags `vX.Y.Z`, publishes a GitHub Release, smoke-tests production, and fast-forwards `develop` back up to `production`. diff --git a/README.md b/README.md index b88092d..e818d70 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,7 @@ A developer's toolbelt: fast, privacy-first browser tools. Everything runs clien - **[JWT decoder and inspector](https://tools.codercops.com/jwt-decoder).** Decode header, payload, and claims; verify HS/RS/PS/ES signatures via WebCrypto; security audit; HS256/384/512 encoder. - **[Base64 encoder and decoder](https://tools.codercops.com/base64).** Text, images, and files; URL-safe variant; data URIs; magic-byte detection; Base32/58/85 and hashes. - **[Invoice generator](https://tools.codercops.com/invoice-generator).** Live preview, vector PDF export with Unicode currency support, a reusable sender profile, all saved locally. +- **[Color converter & contrast checker](https://tools.codercops.com/color-converter).** Convert between HEX, RGB, and HSL with alpha channel support, live swatch, and WCAG AA/AAA contrast ratios for normal and large text. ## Why it exists @@ -114,13 +115,23 @@ The tool set is a single typed registry in [`lib/tools.ts`](lib/tools.ts). The h 2. Create `app/(tools)//page.tsx` (metadata plus your client component), its `Client.tsx`, and an `opengraph-image.tsx` (a few lines that pass the registry entry to the shared renderer). 3. Put pure logic in `lib/` and cover it with a test in `lib/__tests__/`. +## Add a guide + +The guides collection is a single typed registry in [`lib/guides.ts`](lib/guides.ts). The guides index, per-guide metadata, JSON-LD (Article, HowTo, BreadcrumbList), social share images, and sitemap all derive from it. To add a guide: + +1. Add an entry to the `GUIDES` array in `lib/guides.ts`. +2. Create `app/guides//page.tsx` (metadata plus your guide content) and an `opengraph-image.tsx` (a few lines that pass the registry entry to the shared `renderGuideOgImage`). +3. Add a test in `lib/__tests__/guides.test.ts` to ensure metadata, routes, and structured-data invariants hold. + ## Project layout ``` app/ shell (layout, error/not-found boundaries, metadata routes) app/(tools)/ one route per tool, sharing a layout and ToolPageLayout +app/guides/ guides index and walkthroughs components/shared/ header, theme, toasts, command palette, reusable UI lib/ pure per-tool logic (parsing, crypto, PDF, conversions), unit-tested +lib/guides.ts the guides registry (single source of truth for guides) lib/tools.ts the tool registry (single source of truth) lib/jsonLd.ts schema.org builders derived from the registry lib/og.tsx the shared Open Graph card renderer diff --git a/app/(tools)/base64/components/DecodeTab.test.tsx b/app/(tools)/base64/components/DecodeTab.test.tsx new file mode 100644 index 0000000..8a1e533 --- /dev/null +++ b/app/(tools)/base64/components/DecodeTab.test.tsx @@ -0,0 +1,47 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ToastProvider } from "@/components/shared/Toast"; +import { DecodeTab } from "./DecodeTab"; + +function decode(input: string) { + render( + + + , + ); + fireEvent.change(screen.getByLabelText("Base64 to decode"), { target: { value: input } }); +} + +const notice = () => screen.queryByText(/Detected data URI/); +const infoValue = (label: string) => screen.getByText(label).nextElementSibling?.textContent; + +describe("DecodeTab data URIs", () => { + // Vitest globals are off, so Testing Library does not clean up on its own. + afterEach(cleanup); + + it.each([ + ["data:text/plain;base64,SGVsbG8gV29ybGQ=", "text/plain", "Hello World"], + ["data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ=", "text/plain", "Hello World"], + ["data:application/json;charset=UTF-8;base64,eyJhIjoxfQ==", "application/json", '{"a":1}'], + ["data:text/plain;charset=utf-8;format=flowed;base64,SGVsbG8gV29ybGQ=", "text/plain", "Hello World"], + ["DATA:text/plain;BASE64,SGVsbG8gV29ybGQ=", "text/plain", "Hello World"], + ["data:;base64,SGVsbG8gV29ybGQ=", "text/plain", "Hello World"], + [" data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ=\n", "text/plain", "Hello World"], + ])("decodes %j as %s", (uri, mime, text) => { + decode(uri); + expect(notice()).toBeInTheDocument(); + expect(screen.queryByText(/illegal character/)).not.toBeInTheDocument(); + expect(infoValue("MIME")).toBe(mime); + expect(screen.getByText(text)).toBeInTheDocument(); + }); + + it("shows the whole prefix it stripped, parameters included", () => { + decode("data:text/plain;charset=utf-8;base64,SGVsbG8gV29ybGQ="); + expect(screen.getByText("data:text/plain;charset=utf-8;base64,")).toBeInTheDocument(); + }); + + it("does not treat a data URI without ;base64 as base64", () => { + decode("data:text/plain,hello"); + expect(notice()).not.toBeInTheDocument(); + }); +}); diff --git a/app/(tools)/base64/components/DecodeTab.tsx b/app/(tools)/base64/components/DecodeTab.tsx index cb3152b..e921892 100644 --- a/app/(tools)/base64/components/DecodeTab.tsx +++ b/app/(tools)/base64/components/DecodeTab.tsx @@ -6,6 +6,7 @@ import { base64ToBytes, formatBytes, getMimeFromMagicBytes, + parseBase64DataUri, toDataUri, } from "@/lib/base64-utils"; import { downloadBlob } from "@/lib/download"; @@ -20,13 +21,9 @@ export function DecodeTab() { const { push } = useToast(); // Auto-strip `data:...;base64,` prefix - const autoStripped = useMemo(() => { - const m = input.trim().match(/^data:([^;]+);base64,([\s\S]*)$/); - if (m) return { prefix: m[1], rest: m[2] }; - return null; - }, [input]); + const autoStripped = useMemo(() => parseBase64DataUri(input), [input]); - const effectiveInput = autoStripped?.rest ?? input; + const effectiveInput = autoStripped?.payload ?? input; const result = useMemo(() => { if (!effectiveInput.trim()) return null; @@ -36,10 +33,10 @@ export function DecodeTab() { let mime = getMimeFromMagicBytes(bytes); if (autoStripped) { mime = mime ?? { - mime: autoStripped.prefix, - extension: autoStripped.prefix.split("/")[1]?.split("+")[0] ?? "bin", - label: `${autoStripped.prefix} (from data URI)`, - isImage: autoStripped.prefix.startsWith("image/"), + mime: autoStripped.mediaType, + extension: autoStripped.mediaType.split("/")[1]?.split("+")[0] ?? "bin", + label: `${autoStripped.mediaType} (from data URI)`, + isImage: autoStripped.mediaType.startsWith("image/"), }; } return { @@ -82,7 +79,7 @@ export function DecodeTab() {
- Detected data URI — stripped data:{autoStripped.prefix};base64, + Detected data URI — stripped {autoStripped.prefix}
)} diff --git a/app/(tools)/base64/components/FileTab.tsx b/app/(tools)/base64/components/FileTab.tsx index 1e7dbdb..d086bcc 100644 --- a/app/(tools)/base64/components/FileTab.tsx +++ b/app/(tools)/base64/components/FileTab.tsx @@ -186,11 +186,11 @@ export function FileTab() { Base64 · {formatBytes(new Blob([displayed]).size)}
-
- -
diff --git a/app/(tools)/base64/components/TextTab.test.tsx b/app/(tools)/base64/components/TextTab.test.tsx new file mode 100644 index 0000000..1c2460a --- /dev/null +++ b/app/(tools)/base64/components/TextTab.test.tsx @@ -0,0 +1,103 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { ToastProvider } from "@/components/shared/Toast"; +import { TextTab } from "./TextTab"; + +function pasteIntoTextTab(text: string) { + render( + + + , + ); + const input = screen.getAllByRole("textbox")[0]; + fireEvent.paste(input, { clipboardData: { getData: () => text } }); +} + +const switchedNotice = () => screen.queryByText(/Looks like base64/); + +describe("TextTab paste", () => { + // Vitest globals are off, so Testing Library does not clean up on its own. + afterEach(cleanup); + + it.each(["password123", "hello-world", "2024-01-15", "user_id_123"])( + "stays in Encode when %s is pasted", + (text) => { + pasteIntoTextTab(text); + expect(switchedNotice()).not.toBeInTheDocument(); + expect(screen.getByText("Base64 output")).toBeInTheDocument(); + }, + ); + + it.each(["SGVsbG8=", "aGVsbG8gd29ybGQ"])("switches to Decode when base64 of text %s is pasted", (b64) => { + pasteIntoTextTab(b64); + expect(switchedNotice()).toBeInTheDocument(); + expect(screen.getByText("Decoded text")).toBeInTheDocument(); + }); +}); + +describe("TextTab switches", () => { + afterEach(cleanup); + + const renderTab = () => + render( + + + , + ); + + it("names the direction switch and presses only the selected direction", () => { + renderTab(); + expect(screen.getByRole("group", { name: "Direction" })).toBeInTheDocument(); + const encode = screen.getByRole("button", { name: "Text → Base64" }); + const decode = screen.getByRole("button", { name: "Base64 → Text" }); + expect(encode).toHaveAttribute("type", "button"); + expect(encode).toHaveAttribute("aria-pressed", "true"); + expect(decode).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(decode); + expect(encode).toHaveAttribute("aria-pressed", "false"); + expect(decode).toHaveAttribute("aria-pressed", "true"); + }); + + it("names the variant switch and presses only the selected variant", () => { + renderTab(); + expect(screen.getByRole("group", { name: "Base64 variant" })).toBeInTheDocument(); + const standard = screen.getByRole("button", { name: "Standard" }); + const urlSafe = screen.getByRole("button", { name: "URL-safe" }); + expect(standard).toHaveAttribute("aria-pressed", "true"); + expect(urlSafe).toHaveAttribute("aria-pressed", "false"); + fireEvent.click(urlSafe); + expect(standard).toHaveAttribute("aria-pressed", "false"); + expect(urlSafe).toHaveAttribute("aria-pressed", "true"); + // The styling hook stays in step with the state. + expect(urlSafe).toHaveAttribute("data-active", "true"); + }); + + it("disables Swap and preserves invalid input when the shortcut runs", async () => { + renderTab(); + fireEvent.click(screen.getByRole("button", { name: "Base64 → Text" })); + const input = screen.getByRole("textbox", { name: "Base64 to decode" }); + fireEvent.change(input, { target: { value: "Hello world, how are you?" } }); + + expect(await screen.findByText(/Invalid base64/i)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Swap direction" })).toBeDisabled(); + + fireEvent.keyDown(input, { key: "s", ctrlKey: true, shiftKey: true }); + + expect(input).toHaveValue("Hello world, how are you?"); + expect(screen.getByRole("button", { name: "Base64 → Text" })).toHaveAttribute("aria-pressed", "true"); + }); + + it("swaps a valid conversion result", async () => { + renderTab(); + fireEvent.change(screen.getByRole("textbox", { name: "Text to encode" }), { + target: { value: "Hello world" }, + }); + + expect(await screen.findByText("SGVsbG8gd29ybGQ=")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Swap direction" })); + + expect(screen.getByRole("button", { name: "Base64 → Text" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("textbox", { name: "Base64 to decode" })).toHaveValue("SGVsbG8gd29ybGQ="); + await waitFor(() => expect(screen.getByText("Hello world")).toBeInTheDocument()); + }); +}); diff --git a/app/(tools)/base64/components/TextTab.tsx b/app/(tools)/base64/components/TextTab.tsx index 35a6838..39df1d0 100644 --- a/app/(tools)/base64/components/TextTab.tsx +++ b/app/(tools)/base64/components/TextTab.tsx @@ -6,7 +6,7 @@ import { base64ToBytes, bytesToBase64, decodeUtf8Bytes, - detectBase64, + detectBase64Text, formatBytes, gunzipBytes, gzipBytes, @@ -101,7 +101,7 @@ export function TextTab() { function handlePaste(e: React.ClipboardEvent) { if (direction === "encode") { const pasted = e.clipboardData.getData("text"); - if (pasted && detectBase64(pasted)) { + if (pasted && detectBase64Text(pasted, { gzip })) { setAutoDetected(true); setDirection("decode"); setTimeout(() => setAutoDetected(false), 4000); @@ -110,6 +110,7 @@ export function TextTab() { } function swap() { + if (error || !output) return; setDirection((d) => (d === "encode" ? "decode" : "encode")); setInput(output); setOutput(input); @@ -138,7 +139,7 @@ export function TextTab() { window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [direction, input, opts, urlSafe, padding]); + }, [direction, input, output, error, opts, urlSafe, padding, gzip, chunk]); const inputBytes = useMemo(() => new Blob([input]).size, [input]); const outputBytes = useMemo(() => new Blob([output]).size, [output]); @@ -157,19 +158,29 @@ export function TextTab() { )}
-
- -
-
- -
@@ -267,6 +278,7 @@ export function TextTab() {