frogConvert

Contributing

PR workflow, testing, style

Contributing to frogConvert

For developers extending or fixing frogConvert. Audience is humans; AI agents should read ../AGENTS.md instead (same rules, distilled).

This document covers process: how to structure PRs, test, and match the project's style.

For authoring a new format handler, see HANDLERS.md. For system design, see ARCHITECTURE.md. For MCP and REST, see INTEGRATIONS.md.


Reporting bugs and giving feedback

Found a bug, want a new format added, or have feedback on the converter or PDF editor? Email francois.prevot@frog.co. Include:

Same address for security reports. See ../SECURITY.md.


1. Directory structure

The codebase is a vanilla TypeScript Vite project. Full responsibilities are in ARCHITECTURE.md ยง Code Structure at a Glance. Day-to-day, these are the directories you will touch most:

Three parallel subsystems. Before adding code, know which one you are touching:


2. UI and state management principles

frogConvert deliberately does not use React or Vue. Vanilla TS plus direct DOM for performance and small bundle size.

State reactivity

State lives in src/components/store/store.ts as "Value Wrapper" objects:

export const currentFiles: { value: File[] } = { value: [] };

UI components subscribe to or update .value manually.

UI references

Avoid document.querySelector inside components. Use the centralized ui object in store.ts which caches all primary DOM references.

Popups and modals


3. Format mode system (Core / Plus / All)

The format picker exposes three tiers that filter which output formats are visible. Configured in src/components/store/store.ts:

If your new handler's formats do not appear in Core or Plus, add the format's short identifier (e.g. "png", "csv") to the relevant Set in store.ts. The selected mode persists in localStorage.


4. Cache system

frogConvert uses a pre-computed format cache (public/cache.json) to skip calling every handler's init() at startup. Without it, first page load is slow because each WASM handler must load to reveal its supported formats.

How it works

  1. Build time. bun run cache:build launches Puppeteer, loads the built site, waits for handlers to initialize, then calls window.printSupportedFormatCache() to serialize the handlerโ†’formats mapping.
  2. Runtime. The app loads cache.json and builds the TraversionGraph immediately, no init() calls.
  3. On demand. When a conversion is actually requested, only the handlers in the chosen path call init().

When to regenerate

Use bun run cache:refresh, not cache:build. The two write to different places and only one of them lasts:

Script Writes to Survives?
cache:refresh public/cache.json Yes - this is the tracked file that ships
cache:build dist/cache.json No - dist/ is gitignored, and the next build overwrites it with the copy from public/

cache:build exists for desktop:build, which packages dist/ directly and never reads public/. Reaching for it to refresh the shipped cache is a no-op that looks like it worked, which is how the committed cache silently fell three handlers behind: through the whole v3 cycle it carried no Ghostscript, PdfCanvasCompress or imageToPdf entries at all.

Both need a production build first, since they drive the built site:

bun run build && bun run cache:refresh

In dev, the cache is optional; the app falls back to initializing all handlers at startup with a loading screen.


5. Testing

Commands

The corpus suites (opt-in, and the ones that find real bugs)

Six suites in test/e2e/ run real files through the real thing. Four drive the built app in a real browser - corpus-compress, corpus-convert, corpus-pdf, corpus-combined - and two drive the agent surfaces over their real transports: corpus-api (HTTP against a spawned src/api/index.ts) and corpus-mcp (stdio against src/mcp/index.ts). They exist because every serious defect in v3 lived in a seam between mocked units and was invisible to a green unit run - an encrypted PDF emptied and reported as an 83% saving, a truncated PDF returned as a blank page called a 99% win, a .webm not recognised as input at all.

They need ~49 MB of other people's files, so they are opt-in and skip loudly (test/helpers/corpus.ts prints a manifest of exactly what did not run and why - the inverse of optionalDeps.ts, which throws, because CI genuinely does have those dependencies and genuinely does not have this corpus):

bun run scripts/fetch-corpus.ts      # ~31 files from public repos
bun run scripts/make-adversarial.ts  # 12 generated edge cases
bun run build                        # they drive dist/, not the dev server
bun run test:corpus                  # sets FROG_CORPUS=1 for you

Deliberately not part of the default CI run: it needs a production build, a browser, and ~49 MB of downloads. bun run test skips all six suites, and says so. (The two agent suites need no build - they spawn the servers directly - but they share the same corpus gate.)

Shared plumbing lives in two helpers, split by what they drive. Add to the right one rather than starting a third copy.

test/helpers/corpusBrowser.ts - static server, browser, downloads, and re-opening PDF output with pdf-lib and pdfjs. Two things it encodes that cost a debugging round each: .mjs must be in the server's MIME map (Ghostscript ships gs.mjs, and a module script with the wrong type is refused, which looks exactly like a compression failure), and every suite waits for the handler registry rather than a fixed delay.

test/helpers/corpusAgents.ts - spawning the API and MCP servers, and the byte-level assertions both agent suites share. It encodes three constraints of its own:

The two agent suites also check the surfaces against each other: both are thin wrappers over compressForAgents, and each was previously only ever compared against itself, so one drifting to different options would have gone unnoticed.

The stale-shell suite (opt-in)

test/e2e/stale-shell-recovery.test.ts builds the app twice - a second deploy derived from the first, with different asset hashes - serves both, and drives Chromium across them to confirm a returning user on a stale shell recovers instead of landing on a dead UI. It is the empirical counterpart to the regression fixed in 3.0.0.

bun run test:shell                   # sets FROG_E2E_SHELL=1 for you

Opt-in for the same reason the corpus suites are, plus one of its own: it runs a full production build and a browser (~62s) inside a worker parallel with every other test file, and on a two-core CI runner that contention was enough to push the MCP integration suite past the SDK's 60-second request timeout.

What is not gated is the invariant itself. Every script named by a precached HTML file must itself be precached, and that is asserted at build time by the manifestTransforms hook in vite.config.js - so bun run build fails in CI whether or not this suite runs. What test:shell adds is the browser-level confirmation.

It also shares the optionalDeps.ts gate, so it skips where xlsx or the image-to-txt submodule are missing: it runs a real production build, and that build cannot resolve them. As of 2026-08-29 both halves are satisfied on a restricted network - the submodule moved off git.sr.ht onto the author's GitHub mirror, and xlsx moved off cdn.sheetjs.com onto the @e965/xlsx republish on npm - so bun run test:shell now runs anywhere bun install does. It had never run outside CI before that, which is how a service worker that could not install reached a release. The skip message names whichever dependency is actually absent.

Writing a handler test

Handler tests are colocated with the handler under src/handlers/ (e.g. src/handlers/myHandler.test.ts). test/ is reserved for e2e, fixtures, and shared mocks. Minimal:

import { expect, test } from 'vitest';
import CommonFormats from '../core/CommonFormats/CommonFormats.ts';
import myHandler from './myHandler.ts';

const encoder = new TextEncoder();

test('myHandler converts X to Y', async () => {
  const handler = new myHandler();
  await handler.init();

  const inputFormat = CommonFormats.PNG.supported('png', true, true, true);
  const outputFormat = CommonFormats.JPEG.supported('jpeg', true, true);

  const [output] = await handler.doConvert(
    [{ name: 'test.png', bytes: encoder.encode('...') }],
    inputFormat,
    outputFormat,
  );

  expect(output.name).toBe('test.jpeg');
});

Test infrastructure


6. PR workflow

  1. Fork and branch. One topic per branch. Branch names are descriptive (add-webp-handler, fix-safari-pdf-fallback).
  2. Commit style. Imperative, specific. Don't bundle unrelated changes.
  3. Run locally. bun run test must be green. Run bun run build once before opening the PR.
  4. Docs. If you change a handler, update public/cache.json via bun run build && bun run cache:refresh (see Cache system for why cache:build is the wrong one). If you change user-visible behaviour, touch the relevant doc (CONVERTER.md, PDF_EDITOR.md, INTEGRATIONS.md) and add a CHANGELOG.md bullet.
  5. Declare what you import. A package that happens to be installed as somebody else's transitive dependency will import fine on your machine and keep working until that somebody bumps a version and drops it. If you import it, it belongs in package.json, and both package.json and bun.lock go in the commit - CI runs bun i --frozen-lockfile and fails if they disagree.
  6. Dead-file check. bun x knip --include files,unlisted is enforced in CI: a file nothing imports fails the build. Run the full bun x knip too - the other categories are advisory but real. If it flags a file that is used, the reference is probably invisible to it (a path inside a string, say), and the fix is to declare it in knip.jsonc as an entry rather than to ignore the finding. Read the warning at the top of that file before removing any ignore.

7. Agent workflow

The full rules for AI pair-programming agents (Claude Code, Cursor, Aider, Cline, etc.) live in ../AGENTS.md. That file is the single source of truth for agent behavior; this document covers the human contributor side only. The rules there apply equally to human contributors.


See also

All documentation