Apply the always-on handoff to the failed-run Report issue button too. Un-gate the two buttons in tool-page.tsx from the analytics toggle, and extend the dialog offline handoff to source=failed_job, prefilling the GitHub issue with the tool id and error category so it is actionable even with an empty message. Follows #428.
Claude-Session: https://claude.ai/code/session_01XVrHKXwzZDWBWgkGQdPZ3A
Keep the top-nav feedback button always visible (icon plus label on desktop, icon-only on mobile) instead of hiding it when an instance opts out of analytics. When analytics is off, the dialog keeps the typed message and hands off to a prefilled GitHub issue plus a contact@snapotter.com email, with no fake Thanks. Adds a feedback.yml issue template, URL builders, and feedback strings across all 21 locales.
Claude-Session: https://claude.ai/code/session_01XVrHKXwzZDWBWgkGQdPZ3A
Server stops phoning Sentry home after opt-out (release-health sessions + client reports off); settings saves diff-send only changed keys so a stale tab cannot revert an instance-wide opt-out; disabling analytics hides the feedback UI immediately; optIn resumes PostHog after re-enable; onboarding survey writes time out at 15s; inline tool-feedback prompt arms a shown-cooldown.
Fixes 15 defects found by a max-effort multi-agent review of the last 6
merged PRs (#388, #390, #391, #392, #393, #394), all adversarially
verified before fixing.
Install queue + dispatcher (the serious cluster):
- features.ts: finalize the installer child exactly once. A failed spawn
fires both "error" and "close", and the second event released the file
lock and active slot that pump() had just handed to the next queued
bundle, letting two pip processes write the same venv concurrently.
Outcome recording now happens before pump() so the next bundle's first
progress frame cannot race the previous install's bookkeeping.
- feature-status.ts: keep failed-install errors in a per-bundle map
instead of the single progress slot. With the queue auto-starting the
next install, the slot was overwritten within seconds and a failed
install vanished without ever surfacing to GET /features.
- bridge.ts: scope child lifecycle per process (stopped-children set +
request generation tags) instead of an instance-wide shuttingDown flag
that the next spawn reset. A stale SIGTERMed child's late close event
could record a phantom crash (5 of which permanently disable the
dispatcher), null out the freshly spawned child, and reject the new
child's pending requests. The request-timeout kill path still counts
as a real crash.
- install_feature.py: the pre-write disk re-check measured ai_dir's
filesystem even when budgeting the cross-filesystem copy that lands on
the venv's disk; now each budget is checked against the filesystem the
bytes actually land on, so ENOSPC cannot strike mid-write and leave
site-packages half overwritten.
Behavior regressions:
- embed-subtitles: preserve pre-existing subtitle tracks (0:s?) and MKV
attachments (0:t?) that the -map 0:v:0/0:a? rewrite silently dropped;
data streams stay unmapped on purpose (the actual MPEG remux fix). The
new subtitle maps first so the language tag hits the right stream.
- usage-survey-overlay: fail closed when the settings fetch fails; the
fail-open path rendered the blocking survey against an unhealthy API
and soft-locked admins, the lock-out class #392 fixed.
- features-store: queued bundles poll instead of each holding an SSE
connection (Install All could pin 7 EventSources and exhaust the
browser's 6-per-origin HTTP/1.1 limit, hanging the whole app);
listenToProgress closes any prior stream and stops any poll before
subscribing; installAll skips bundles already installing or queued.
Contracts, tests, i18n:
- openapi.yaml: add "queued" to the features status enum and document
downloadBytes/installedBytes (Schemathesis conformance).
- feature-lifecycle e2e: queue transcription (~0.5 GB) instead of ocr
(~6 GB) and give the test a budget that covers both install drains
(the stacked waits exceeded the old 900s timeout).
- docker-compose.qa.yml: parameterize the host port (QA_APP_PORT) so
QA_PROJECT_NAME concurrent stacks can actually bind.
- compare + watermark-image: restore per-input error attribution
("Invalid first/second image", "Invalid watermark image") lost in the
shared-handler migration.
- ai-features-section: the "{size} on disk" suffix now goes through
i18n; key added to all 21 locales.
- watermark-image + content-aware-resize: migrate to the shared
inputHandlerFor("image") chain like compare/vectorize/compose, fixing
drift in the inline copies (no SVG sanitize, no RAW extension hint,
no AVIF probe).
Verified: typecheck across 9 workspaces, Biome clean on all changed
files, 584 targeted unit tests and 249 integration tests green
(including real-ffmpeg embed-subtitles runs). One unit test updated to
the new poll-while-queued contract with a single-EventSource assertion.
Claude-Session: https://claude.ai/code/session_017mR1HiHaf3a1BmUtrHX4j3
* fix(api): correct format/filename/container handling across tool routes
Found during a comprehensive QA sweep exercising every tool against its
full accepted-format matrix:
- watermark-image, compose: preserve the requested output format and a
matching download filename/extension instead of always emitting the
source format
- compose: crop oversized overlays to the visible base area instead of
crashing Sharp's composite, and reject only overlays fully outside the
base image instead of any oversized one
- compare, vectorize: switch to the shared image input handler so
filenames and formats like .svgz/.tga/RAW survive validation instead
of being rejected pre-processing
- tool-factory, images-to-video: normalize frames through Sharp before
handing them to FFmpeg, fixing GIF/AVIF/RAW image-to-video jobs that
previously failed or hung
- media-tool, replace-audio, embed-subtitles: fix legacy container
MIME/codec handling for MPEG sources and subtitle remux cases
- files: expand download MIME mapping for text/data/document/video/audio
outputs that were falling back to a generic content type
- convert-document/presentation/spreadsheet: same-format conversions now
return the original validated file instead of erroring or producing
corrupt tiny output
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(web): dropzone a11y, stale localStorage getter, dead code
- dropzone: stop making the whole drop-zone section clickable/focusable.
A section acting as an interactive element around a real upload button
is a nested-interactive-element anti-pattern that confuses screen
readers; drag-and-drop doesn't need focus semantics, only the button
fallback does. Keeps that button semantic and keyboard-reachable.
Updates the two e2e call sites that clicked the section directly.
- api, use-auth: read through window.localStorage via the existing API
storage helper instead of the bare global, which resolves to Node's
experimental localStorage getter under Vitest and threw
- find-duplicates-settings, info-settings, login-page: remove dead code
(unused zip-download handler, a stale mount-only effect dependency
that left cached info stuck at reused indices, an unused response
variable)
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(i18n): pt-BR, zh-CN, zh-TW were silently falling back to English
The locale loader looked up dynamic-import exports by the raw locale
code (mod["pt-BR"], mod["zh-CN"], mod["zh-TW"]), but those three modules
export camelCased bindings (ptBR, zhCN, zhTW) since identifiers can't
contain hyphens. The lookup returned undefined and every consumer
silently fell back to English for these three locales. Replaces the
generic lookup with explicit per-locale loaders so the mapping can't
drift out of sync again.
Also updates the dropzone helper copy across all 21 locales to match
the drag-only dropzone wording from the previous commit.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(docs): clear build warnings in the VitePress site
- config.mts: add an onwarn handler for the @vueuse INVALID_ANNOTATION
warnings emitted during the docs build
- deployment.md: the caddyfile code fence language isn't a shiki grammar
VitePress ships with, so it warned on every build; use txt instead
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* test(qa): update QA harness for the drag-only dropzone and regen metadata
- api-sweep, qa-helpers, verify-ai: add JSON-body tools, multi-input
secondary fixtures, async polling for slow valid jobs, 501
FEATURE_NOT_INSTALLED skip handling, and safer per-tool settings
- input-preview, pipeline-ui specs: update upload flow for the
drag-only dropzone surface
- add tests/fixtures/data/valid/chart.json, a valid chart fixture the
updated helpers route to
- regenerate tools-meta.json against current TOOLS[]
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(security): close a login timing side-channel, harden zip-slip tests
Found during a black-box security sweep of the real auth-enabled
production container: a nonexistent username returned 401 in ~3-10ms,
while a wrong password for a real user took ~35-42ms, because scrypt
verification only ran when a user row existed. That timing gap lets an
attacker enumerate valid usernames without ever guessing a password.
Now runs verification against a cached dummy hash on the unknown-user
path too, so both cases cost the same regardless of outcome.
extract-zip already had a relative-traversal regression test
(../evil.txt), but its absolute-path rejection branches
(name.startsWith("/") / startsWith("\\")) had none. Added the three
missing cases: deep relative traversal, absolute Unix path, and
Windows-style absolute path.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* test(qa): add UI-driven AI bundle install scripts
QA_PROMPT.md's Phase 2 requires installing AI models the way a user
does -- through the UI, on demand from HuggingFace -- and treats the
curl-based admin install endpoint as fallback-only. Nothing in the
harness actually drove that flow; tests/qa/seed-ai-models.sh installs
via docker exec + pip, which is further from a real user than even the
API fallback.
install-ai-bundles-ui.mts logs in, opens Settings > AI Features,
screenshots the pre-install state, clicks Install All, and screenshots
progress -- then exits, since installs continue server-side once
triggered. verify-ai-install-complete.mts polls bundle status,
screenshots the completed state, and runs one real tool per installed
bundle to prove the freshly-downloaded model actually executes.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(qa): correct the apiToolPath import in the AI verify script
Dynamic import of the package name failed under tsx's module resolution
from apps/api's node_modules context; use the same relative-path import
api-sweep.mts already uses successfully.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(web): correct AI bundle size estimates shown before install
Measured real downloads during GPU-node QA verification: photo-restoration
pulls ~4.4GB (was advertised as 800MB-1GB, off by 4-5x) and ocr pulls
~5.5GB (was advertised as 3-4GB). Both estimates only accounted for model
weights, not the pip dependencies (torch/paddle) that come down with them.
Updated to reflect actual total download size, since that's what a user
deciding whether they have the disk/bandwidth actually needs to know.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(web): make desktop Settings reachable when auth is disabled
AvatarDropdown (the only desktop entry point to Settings) was gated
behind `!isMobile && authEnabled`. With AUTH_ENABLED=false the synthetic
anonymous admin user should have full Settings access per how auth.ts
documents this mode -- and the mobile bottom nav already worked this way,
showing Settings unconditionally. Desktop just had a stray extra gate the
component doesn't need: AvatarDropdown already resolves its own username
internally (falling back to "admin") and reads authEnabled itself where
it actually matters (hiding the Logout button). Removed the outer gate;
verified end-to-end against a fresh AUTH_ENABLED=false instance -- avatar
now renders, Settings opens, shows the anonymous/Admin identity correctly.
Also documents (not changes) a related finding in install_feature.py:
detect_arch() always resolves amd64 hosts to the GPU-bundled archive
variant regardless of actual GPU presence, since no CPU-only amd64
archive is published to the bundle repo yet. Left as a code comment
rather than a behavior change, since requesting an unpublished archive
key would hard-fail installs entirely -- worse than the current
oversized-but-working download. Full detail in the QA report.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(ai): stop logging expected dispatcher reloads as crashes
After each AI bundle install the Python dispatcher reloads because the
venv changed, and after every app shutdown it's SIGTERMed. Both took the
close handler's `code !== 0` branch (SIGTERM makes the exit code null),
so they were counted as crashes -- producing an alarming "crash" line in
the logs and a pointless ~1s recovery backoff after each of 7 installs.
A `stopping` flag set in shutdown() lets the close handler tell an
intentional stop apart from a real crash. The request-timeout kill path
deliberately does not set it, so a genuinely hung script still records a
crash and the 5-in-60s permanent-disable threshold is untouched.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(api): return a clean message when content-aware resize times out
Carving a very high-resolution image down to a tiny target could exceed
the caire subprocess timeout, and the raw error forwarded to the user was
caire's terminal output -- ANSI color codes and progress-spinner control
characters -- instead of anything actionable. Now: the timeout path
throws a clear "timed out; try a smaller image or larger target" message
(keeping the raw stderr as `cause` for server logs); friendlyError()
strips ANSI/control chars centrally so any subprocess dump surfaced
through the shared sanitizer is plain text; and the content-aware-resize
route (a custom route that bypassed the sanitizer) now routes its error
paths through friendlyError like every other tool.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(ai): stop bundle installs from exhausting host disk
Installing an AI bundle on a tight-disk host could push the root
filesystem to zero bytes free after the preflight check had already
passed. Two root causes:
- move_tree used copytree+rmtree, so during the move the extracted
payload existed in both staging and the venv at once -- a full
transient doubling on disk. Rewrote it to rename entries (a cheap
metadata op on the same filesystem, no copy), falling back to a copy
only across filesystems.
- the preflight budget used the manifest's extractedSize verbatim, which
is 0 for several archives, collapsing the estimate to just the
compressed size. Added a conservative fallback (3x compressed) so a
missing value can't under-reserve.
Also added a real-on-disk re-check immediately before the first
destructive venv write (measuring the actual extracted payload and
whether the move needs extra space for a cross-filesystem copy), which
also now covers the offline-import path that previously skipped the disk
check entirely; wrapped the moves so an out-of-space failure returns a
clean actionable error instead of a traceback; and made the disk check
resolve the nearest existing ancestor so it never throws on a
not-yet-created venv path.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* feat(web): show the real per-arch AI bundle download size
The bundle cards and install prompt showed a hardcoded, architecture-blind
estimatedSize string. That's misleading: amd64 hosts always pull the
CUDA-inclusive archive (there's no CPU-only amd64 variant published), so a
bundle labelled "1-2 GB" can actually download several times that, while
arm64 pulls a much smaller archive for the same label. The manifest
already carries the real per-arch compressedSize (and extractedSize where
measured), so surface those: a new optional downloadBytes/installedBytes
on FeatureBundleState, populated in getFeatureStates() for this host's
arch (resolver mirrors install_feature.py detect_arch), shown by the UI
when present with estimatedSize kept as the fallback label. Also nudged
upscale-enhance's fallback string (4-5 -> 5-6 GB) to match its real
compressed size, consistent with the earlier photo-restoration/ocr fixes.
Fields are optional so demo/mock and existing tests stay compiling; the
manifest's extractedSize is 0 for a few archives, which now surfaces as
null rather than a bogus 0.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(web): move the AI install queue to the server so it survives tab close
Installing multiple bundles could silently lose all but the first. The
server rejected a concurrent install with 409, so the client worked
around it by queueing the rest in browser-local state and only POSTing
each once it saw the previous finish. A single POSTed install is durable
(the installer child is detached from the request), but a queued one had
zero server footprint -- close the tab mid-queue and those installs
vanished with no error, while the UI still showed them "Queued". The
client "mutex" didn't even serialize: the queued bundles' local waits all
resolved at once and raced into concurrent POSTs that 409'd each other.
Now the queue lives on the server (a small in-memory FIFO leaf module).
The install endpoint enqueues instead of 409-ing and returns
202 {jobId, queued}; a pump starts the next bundle when the current one's
child exits (and after an offline import releases the lock), all behind
the existing venv + file locks, which are unchanged. The client just
POSTs every bundle immediately and reflects the server-reported
queued/installing status; Install All fires all POSTs and lets the server
serialize them, keeping the one-shot retry-on-failure. Adds "queued" to
FeatureStatus (the bundle card already rendered that state) and surfaces
it from getFeatureStates. In-memory is deliberate: it matches the
existing contract (survives a tab close, not a server restart, which
already clears the lock on boot).
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* fix(qa): don't log env-derived credentials in the AI-install script
CodeQL flagged clear-text logging of sensitive information: the login
status line interpolated the QA base URL and username (both read from
the process environment) into a console.log. Replaced with a static
message. QA helper only, but it's a real hygiene issue and cleared the
high-severity code-scanning alert on the PR.
Claude-Session: https://claude.ai/code/session_019fpSXhLGLXWwfyZY2tWhLG
* feat: add usage-survey feedback types and gating function
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* feat: add onboarding usage-survey i18n strings to all locales
Relabels three ambiguous feedback.usageTypes values (personal/team_internal/
business_workflow) and adds a new onboarding namespace (4 keys) across the
reference locale and all 20 translations, so the tree compiles at every
commit instead of only after both locale groups land.
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* feat: add UsageSurveyOverlay component
* feat: mount UsageSurveyOverlay inside AuthGuard
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* fix: use text-start instead of text-left for RTL support in UsageSurveyOverlay
* refactor: drop redundant usage-type field from the admin feedback dialog
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* feat: accept onboarding source and survey id in the feedback route
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* test: cover the onboarding source in the feedback route integration test
Claude-Session: https://claude.ai/code/session_01KAC9Lbx8AmebAnj9WQZXHp
* chore: remove orphaned usageTypeLabel i18n key
* refactor: derive feedback source/survey_id enums from a single source of truth
* perf: skip the settings fetch in UsageSurveyOverlay for non-admin users
Adds a prefilled 'Request a tool' affordance to the home search empty state and beneath weak results. Opens the in-app feedback dialog with a new search_miss source and a structured search_query when analytics is on; links to a prefilled GitHub Discussions (Ideas) post when off, so a request is never silently dropped. Reuses the existing feedback pipe, dialog, and analytics gate; no new storage. i18n across all 21 locales.
Draw, type, or upload a signature and place resizable/rotatable copies across PDF pages; output flattened server-side with PyMuPDF. Visual electronic signature, not cryptographic. New interactive-sign display mode (pdf.js + Konva) and a custom docs-pool route.
* feat(analytics): upload web source maps to Sentry + tie release to build
Web crash reports were unusable: the bundle ships minified with no source
maps uploaded, and every build reported as the frozen APP_VERSION, so a
Sentry error showed an unreadable stack under a single release.
- Add @sentry/vite-plugin: emit hidden source maps and upload them by debug
id when SENTRY_AUTH_TOKEN is present (published Docker build only), then
delete the maps so they never ship. No-op for dev and the source archive.
- Set the Sentry release from SENTRY_RELEASE / VITE_SENTRY_RELEASE (the Docker
build passes the release version), falling back to APP_VERSION.
- Relax beforeSend so app bundle frames keep a host-stripped path (Sentry needs
it to match the uploaded map) while the instance hostname, error message, and
PII stay stripped. Filesystem paths still collapse to the basename.
- Wire the Dockerfile (sentry_auth_token build secret + SENTRY_RELEASE arg/env)
and the release docker job.
* fix(analytics): point source map upload at the snapotter org (project node)
Lands five integrated branches: pipeline templates (#355), analytics opt-out (#354), 83 conversion presets bringing the catalog to 240 tools (#356), self-hosted positioning (#353), and e2e modernization (#351).
Integration fixes: aligned stale web analytics tests with the opt-out/allow-list model, closed 3 CodeQL incomplete-sanitization alerts in the i18n generator, resolved settings/index/docs/format-matrix conflicts, and corrected tool counts to 240.
* fix(passport-photo): require the face-detection bundle, not just background-removal
Passport Photo runs face-landmark detection (face_landmarks.py, gated to the
face-detection bundle) before background removal (background-removal bundle),
but it was only declared under and guarded against background-removal. A user
who installed only Background Removal passed every JS-side check, then hit a
late "feature_not_installed" from the Python dispatcher gate when the analyze
step ran face landmarks, and the UI never told them Face Detection was needed.
- shared: add TOOL_EXTRA_BUNDLES + getRequiredBundlesForTool so a tool can
declare more than one required bundle (passport-photo needs background-removal
and face-detection). enablesTools is untouched, so the one-tool-per-bundle
invariant still holds.
- api: isToolInstalled() now checks every required bundle; add
getFirstMissingBundleForTool() so the analyze and base routes, pipeline (both
guards) and batch report the bundle the user actually still needs.
- web: the proactive install prompt (tool-page) and features-store treat a tool
as installed only when all required bundles are present, and point the prompt
at the first missing one (sequential install, no new UI).
Refs #327
* test(passport-photo): deterministic integration coverage for the two-bundle guard
Boots the real API with an isolated DATA_DIR and controls installed.json to
prove the HTTP route behavior end-to-end:
- nothing installed -> 501 naming background-removal
- only background-removal installed -> 501 naming face-detection (issue #327)
- both installed -> guard passes (not 501)
- base route reports face-detection too
Refs #327
Three production crashes from the snapotter/node Sentry project.
feature-status (NODE-12): a valid-JSON-but-wrong-shape installed.json
crashed boot via Object.keys(data.bundles). readInstalled() now
normalizes any unusable shape to { bundles: {} }, and the boot recovery
call is wrapped so cleanup can never fatal startup.
image-viewer (NODE-15/17/18): drag-to-pan read .x off an undefined
use-gesture memo on pointerUp or a pinch-into-pan. A guarded pure helper
(resolvePanStart) now falls back to the live pan offset.
Fastify (NODE-14): raised pluginTimeout to 60s so slow self-hosted boots
do not fatal at @fastify/static.
* feat(web): add pure zoom/pan math module with unit tests
* feat(i18n): add a11y.pan key across all locales (English, matching adjacent zoom labels)
* feat(web): add useZoomPan hook (state + gestures over pure math)
* feat(web): add ZoomToolbar component
* feat(web): zoom & pan in the object eraser canvas
* feat(web): zoom & pan in the split tool preview
* fix(web): synchronous pan-mode refs so drag-pan is race-free under fast input
* test(e2e): zoom & pan acceptance (split always-on, eraser bundle-gated)
Four launch gates for v2.0.0:
1. Catalog integrity (catalog-integrity.test.ts): asserts every TOOLS entry
is fully wired end to end (API route + frontend registry + display mode +
process fn or REGISTRY_EXEMPT). Count checked dynamically against
TOOLS.length. All 157 tools pass.
2. i18n cross-locale parity (i18n-parity.test.ts): asserts every locale in
SUPPORTED_LOCALES has the same key set as en.ts. Found and fixed a real
bug: zh-CN and pt-BR exported only a camelCase named export (zhCN, ptBR)
with no default export, so loadTranslations silently fell back to English
for Chinese Simplified and Brazilian Portuguese users. Fixed by adding
export default to both files. All 20 non-en locales now pass parity.
3. Cross-modality smoke (cross-modality-smoke.test.ts): one fast tool per
modality (rotate/image, mute-video/video, convert-audio/audio,
rotate-pdf/document, csv-json/data) plus an auth gate. Tools needing
ffmpeg or qpdf are gated with skipIf. Ship/no-ship signal.
4. Migration launch gate: extended migrate-from-sqlite.test.ts with a
representative 1.x SQLite database (3 users, 3 teams, 3 settings,
2 roles, 2 sessions, 2 API keys, 2 pipelines, 4 jobs, 4 audit entries,
4 user files) covering boolean/timestamp/JSON/NULL type conversions,
column remapping (input_files->input_refs, progress real->jsonb), and
multi-row round-trip verification. 9 new test cases.
Parity: 13260 passed, 0 dropped.
Fixes a backlog of integration/unit specs that asserted pre-2.0 behavior and
were failing CI (not caused by recent feature work):
- modality-aware empty-input error is 'No file(s) provided', not /no image/i
(rotate, border, crop, resize, smart-crop, edge-cases, adversarial-extended,
api, tool-factory-route)
- input validation rejects pre-enqueue with a clean 400 in 'error' (was a worker
422 in 'details'): create-zip, extract-zip, merge-csvs
- resolveToolPool defaults unknown tools to the system pool (pool-routing)
- /upload and fetch-urls accept non-image content, validated per-tool at process
time (api, fetch-urls)
- color-adjust legacy aliases were consolidated into adjust-colors: drop the
removed-alias tests; retarget the format-preservation tests
- xml-to-csv gracefully converts a single non-repeating record to a 1-row CSV
- dropzone is multimodal; image-only filtering is opt-in via fileFilter
- factory-multi-input: register the synthetic test tools in the catalog so they
route correctly (file modality for concat; image for the validation-prefix test)
Verified locally: unit 4546 passed, integration 8332 passed, typecheck + lint green.
- Remove obsolete fullscreen redirect test (route deleted in Task 3)
- Update dropzone test strings to match new i18n values
- Fix categories count in i18n locale test (12 -> 24)
- Add chart-maker to no-dropzone exempt list in tool-registry test
- Auto-fix import ordering (Biome) in popular tools route and landing config
Closes the "e2e never runs in CI" hole. Adds per-PR e2e smoke gate,
nightly full-suite workflows, parallel vitest forks (per-fork DBs),
Playwright parallel/serial/visual projects against production builds,
metadata-generated test suites (drift guards, hostile inputs, format
matrix, pairwise settings, property-based fuzz), Stryker mutation
testing, Schemathesis API fuzz, coverage ratchet, and fixes for three
session-poisoning bugs that caused 200+ serial-bucket failures.
Bug fix included: favicon/split/bulk-rename could hang clients forever
when ZIP streaming failed after reply.hijack().
- Fix selective metadata stripping (P1): use Sharp's keepExif()/keepIccProfile()
instead of broken withMetadata({}) that preserved everything
- Fix meme font mapping (P1): add ArchivoBlack and ComicNeue fonts, map
arial-black and comic-sans to correct TTF files instead of Anton
- Fix meme contentType (P2): detect actual output format from Sharp metadata
instead of hardcoding image/png
- Fix info/text-overlay/color-palette i18n (P2): wire up existing translation
keys that were imported but never used
- Fix info and color-palette displayMode (P2): change from before-after to
no-comparison since neither tool produces a processed image
- Add missing i18n keys across all 21 locales
- Update displayMode test assertions
* fix: extend SSE result retention from 2 to 10 minutes for mobile recovery
* fix: check server health on tab visibility change for mobile recovery
* fix: recover feature install SSE on tab visibility change
* fix: reconnect SSE on tab visibility change in tool processor
* fix: reconnect SSE on tab visibility change in pipeline processor
* test: add visibility recovery tests for connection monitor
The Open File button in the Files section did nothing due to a race
condition where the home page reset the file store on mount before files
from handleOpenFile could render. Upload on the files page used fetch
with no timeout, progress, or retry, causing silent failures on mobile
and slow connections. SSE connections for job progress had no keepalive
pings, allowing reverse proxies to kill idle streams.
- Fix installAll tests timing out by returning installed status from
refreshBundles mock (prevents infinite retry loop) and increasing
waitFor/test timeouts to accommodate the 2s inter-install delay
- Fix zustand-stores installAll test with same refreshBundles mock fix
- Icon-map test already fixed to derive from shared constants
When AUTH_ENABLED=false, seed an "anonymous" user row in the users
table so API keys, pipelines, and user files don't fail with FK
constraint violations. Previously, the synthetic anonymous user only
existed in memory (attached by the middleware), but any DB operation
referencing userId "anonymous" would violate foreign key constraints.
Also adds 25 new tests covering:
- Integration: ensureAnonymousUser, FK constraints, settings save,
API key and pipeline operations for anonymous mode
- Frontend: useAuth hook anonymous happy path (role, permissions,
hasPermission, session endpoint bypass)
- Frontend: settings dialog nav filtering (authRequired hides
security/people/teams/roles when auth disabled)
- Backend: session endpoint returns admin role when auth disabled
Add complete i18n infrastructure with 21 supported languages:
English, Simplified Chinese, Traditional Chinese, Japanese, Korean,
Spanish, French, Italian, Brazilian Portuguese, German, Dutch, Swedish,
Russian, Polish, Ukrainian, Arabic (RTL), Turkish, Hindi, Vietnamese,
Indonesian, and Thai.
- I18nProvider context with three-tier locale detection
(user preference > navigator.languages > instance default > English)
- ~1500 translation keys per locale with TypeScript-enforced completeness
- Dynamic code-splitting: only the active locale is loaded at runtime
- Language selectors in footer, login page, settings, and mobile sidebar
- Arabic RTL support with CSS logical properties across all components
- Tool names, descriptions, and categories translated via i18n helpers
- Public API endpoint GET /api/v1/config/locale for instance default
- Multi-script font stack (CJK, Arabic, Devanagari, Thai, Cyrillic)
- format() and plural() helpers for interpolation and pluralization
- API error translation mapping (translateApiError)
- 36 Playwright e2e tests verifying all 21 locales load correctly
- 25 unit tests for format, plural, locale detection, and completeness
- Updated translations.md docs and CLAUDE.md conventions
- Replace content-aware-crop with ai-canvas-expand in TOOLS[], AI_TOOL_IDS,
and FEATURE_BUNDLES (matching the already-updated tool-registry.tsx and
feature-manifest.json from commit c6a5d3f)
- Fix trailing syntax error in features.ts (extra closing brace)
- Add ai-canvas-expand-settings mock to tool-registry test files
- Update watermark-image tests to expect 400 (validation rejection) instead
of 422 (processing failure) for corrupted image buffers, matching the
actual route behavior where validateImageBuffer catches them first
Background images, device frames, custom shadows, and watermark text
were not rendering in the right-pane preview. The preview now updates
in real time for all settings: gradient/solid/image backgrounds, macOS/
Windows/Browser frame chrome, iPhone/MacBook/iPad frame indicators,
custom shadow parameters, and watermark text overlay.
Also fixes a React StrictMode effect-ordering race where the parent
tool-page reset cleared preview state set by the child Settings
component on initial mount.
- Refactor use-tool-processor and use-pipeline-processor hooks
- Enhance dropzone component with improved UX
- Improve seam carving with better error handling and tests
- Add JXL format encoding support to format-encoders
- Update tool routes for consistent format handling
- Add dropzone unit tests
- fix WebP export silently producing PNG when background is non-transparent
- fix autosave not converting blob: URLs inside image-type canvas objects
- fix project load not resetting selection/crop/clipboard state
- fix rotateCanvas not updating object rotation attributes
- fix flipCanvas not negating object rotation attributes
- fix line shadow props overridden by effect spread ordering
- fix "outside" stroke position rendering same as "center"
- add missing pencil tool keyboard shortcut (N)
- remove misleading resample dropdown from image resize dialog
- fix E2E autosave tests for production builds (no Vite dynamic imports)
- fix color picker test case sensitivity (CSS uppercase vs DOM text)
- add 4 unit tests for rotation attribute transforms