Compare commits

...
Author SHA1 Message Date
EthanHealy01 c9e21e466b Reach a resting state when the file library can't be read
Follow-up to #7366, which fixed the blocked-open hang itself. What's left here is
the UI resilience and the test that reproduces the failure for real:

- The saved-files picker cancels its storage read and scopes its spinner to the
  saved tab. The Workbench tab renders from memory, so a stuck storage read must
  not spin it too, and a read that outlives the popover must not set state.
- `file-library-resilience.spec.ts` doesn't mock the failure: an init script parks
  a connection on an older version and never yields it, then the spec asserts the
  workbench still renders, the sidebar spinner clears, and the reason reaches the
  console. Cleanup deletes the database explicitly, because WebKit keeps origin
  databases between browser contexts and a stray version would fail a later spec.
- `api-stubs` gains `storageEnabled`, which that spec needs to exercise the route.

Trimmed of everything #7366 now carries: the blocked guard, hoisted registration
and `onversionchange` in indexedDBManager, its unit tests, and FileSidebar's
try/catch (which was byte-identical).
2026-08-12 16:49:08 +01:00
EthanHealy01 62fe11af48 Trim redundancies from this branch's storage work
Review pass, no behaviour change:

- Drop `isRecordUnreadable()`. Nothing in the app called it - listings read the
  set directly - so it was public API existing only for two test assertions.
  Those now assert the user-visible contract (`stub.dataUnavailable`) instead.
- Revert `createBlobUrl` to its previous shape. It has ZERO production callers,
  so rewriting it (and auditing inside it) was diff noise on a dead method.
- Remove a dead guard in `reportIfUnreadable`: every probed record is added to
  `auditedRecords` before probing, including ones that turn out unreadable, so
  the `unreadableRecords` check could never be the one to short-circuit.
2026-08-12 16:25:16 +01:00
EthanHealy01 62967c9065 Keep maintenance writes away from blob records that can wedge the store
The infinite "Loading files..." after a Safari reload, finally caught in the act:
the sidebar listing's thumbnail TTL bump opens a readwrite transaction that
re-reads every record - and in WebKit a `get` touching a blob-bodied record with
a damaged backing store HANGS rather than errors. One pending request keeps that
transaction alive forever, and every later transaction on the store queues behind
it: hydration reads, new uploads, policy-output persists. One wedge explained the
whole screen - files that won't open, fresh uploads spinning, runs that never
deliver.

Proof from the session log: after the TTL bump's refused-put warning, reads of
even a just-rescued (ArrayBuffer) record never settled - a healthy record's read
hanging means the store is blocked, not the record.

Maintenance now skips blob-bodied records on a browser whose durable verdict is
"blobs unsupported": their rewrite would be refused anyway, so they are all risk
and no value. Chrome (blobs genuinely supported) is unchanged.
2026-08-12 16:08:49 +01:00
EthanHealy01 61b67ecdaf Show "Data lost" on library rows whose bytes are gone, and rescue the rest
The library must tell the truth per row instead of listing files that pretend to
open. Timers and toasts were treating the symptom; this makes the sidebar
represent actual IndexedDB status, so legacy WebKit damage is visible and
actionable (re-upload), and stops the remaining data-loss vector.

- Listings audit every blob-backed record's bytes out of band (never awaited -
  the probe itself can hang on WebKit). A record whose bytes are gone flags
  `dataUnavailable` on its stub, renders a "Data lost" badge with a tooltip, and
  its click explains instead of failing.
- RESCUE: on a browser whose durable verdict is "blobs unsupported", a legacy
  blob record that is still readable today is rewritten as an ArrayBuffer copy
  while the bytes still exist - closing the loss vector for pre-verdict records
  instead of waiting for WebKit to lose them too.
- The v6/v7 version probe runs before the guarded open and a versionless open can
  be delayed indefinitely by another tab mid-versionchange; it now proceeds
  without an answer rather than hanging every storage consumer ahead of the
  blocked guard.

Records are never auto-deleted: one NotFoundError is not proof the bytes are
gone forever, and the flag is session-scoped so a reload re-tests.
2026-08-12 15:26:02 +01:00
EthanHealy01 4164bc15c6 Stop a blocked IndexedDB open from hanging the file library
The library spinner ran forever with an empty console after uploading several
files and reloading. A blocked open fires `blocked` and then NOTHING - no success,
no error - so the open promise never settled and every caller hung. FileSidebar's
try/catch can't help: it guards a rejection, not a promise that never settles.

- `blockedGuard`: warn, wait out a grace period, then reject with something the
  user can act on. Rejecting doesn't cancel the request, so a connection that
  arrives late is closed rather than held - otherwise we become the next blocker.
- Registration hoisted above everything async. A map written after a yield point
  can't dedupe callers racing into it in the same tick, so every context that
  opened the files database during boot got its own connection, and per spec only
  the first request ever receives `blocked`. This only affected
  stirling-pdf-files: the one config with an await before registration.
- `onversionchange` closes and forgets our connection, so a release that bumps the
  schema no longer bricks every open tab. Forget before close: a cached but closed
  handle is worse than none, because every transaction on it throws.
- `deleteDatabase` gets the same guard; it blocks the same way and is awaited on
  the files open path.

Re-derived from #7416, which was closed unmerged and is in neither main nor this
branch. Whichever lands second is a no-op for the overlapping parts.
2026-08-12 14:32:24 +01:00
EthanHealy01 163041af03 Add onDismiss to the policy overlay's core stub
The stub/shadow pattern needs both layers to share an interface: core code passes
onDismiss, and in a core build the overlay resolves to the stub, which didn't
declare it. Broke `frontend:typecheck:core` - the proprietary typecheck passes
because the real overlay has always accepted it.
2026-08-12 13:56:04 +01:00
EthanHealy01 f51ea06cbc Drop a file from the workbench once its bytes are proven unreadable
Handing dead bytes over stopped the open path from stalling, but left the viewer
rendering a document that never loads - an endless spinner, reported on Safari and
the DuckDuckGo browser with the "File data is unavailable" toast alongside it.

- fileStorage notifies listeners when a record's bytes are confirmed unreadable,
  and refuses to hand that record out again for the rest of the session.
- FileContext subscribes and removes the file, so nothing keeps waiting on bytes
  that can't arrive.

The record itself is kept and the refusal is session-scoped, so a reload re-tests
it: WebKit throwing NotFoundError once isn't proof the file is gone forever, and
deleting on that evidence risks destroying recoverable data.
2026-08-12 13:40:08 +01:00
EthanHealy01 929e8f6269 Settle a policy run that completed with no output
A policy that changes nothing (redaction matching no text, say) completes with no
output file. The import effect skipped those runs entirely, so `imported` never
flipped - and the badge treats `imported` as the settle signal, so the file's
spinner and its blocking overlay ran forever.

Engine-agnostic: it needs a document the policy's patterns don't match, which is
why it looked Safari-specific. Confirmed from a backend log showing "Redaction
scan: 0 occurrences across 0 pages" for every spinning file.
2026-08-12 11:00:16 +01:00
EthanHealy01 e5f8183320 Let a stuck policy overlay be dismissed on a file card
The enforcement overlay swallows clicks, and the card's had no dismiss - so a run
that never settles left the file permanently unusable with no way out. The
viewer's equivalent has always been dismissible.
2026-08-12 11:00:06 +01:00
EthanHealy01 3582e82d99 Subscribe to workbench files in the form panel
getFiles() during render doesn't subscribe to the state it reads, so the panel
kept showing the pre-hydration (or pre-version) file. The app's own guard was
logging this while opening a file.
2026-08-12 11:00:05 +01:00
EthanHealy01 5ce9259ddf Delete a file's superseded versions along with it
Deleting a file removed one record; its older versions kept their full bytes and
were invisible, because listings filter on isLeaf. Observed live: three uploads
produced six records (a policy versioned each), and deleting one file left its v1
behind.

The lineage expansion belongs at the user-facing delete sites, NOT in
removeFiles: VersionHistoryModal deletes individual versions through the same
low-level path, so expanding there would wipe a whole chain when the user removes
one version.
2026-08-12 11:00:05 +01:00
EthanHealy01 93c646f704 Keep state identity when REMOVE_FILES removes nothing
Deleting a library file that was never in the workbench dispatches REMOVE_FILES
for an id the reducer doesn't hold. It rebuilt `files` and `ui` anyway, which the
dev identity guard flags: every file and UI consumer re-renders for nothing. The
console noise also buried the errors we were hunting.
2026-08-12 11:00:05 +01:00
EthanHealy01 822bbaf7c3 Show a clicked file as soon as its bytes load, not after it parses
The workbench renders from hydrated bytes held in a ref, so a file is invisible
until hydration dispatches - and the only dispatch sat after a full pdfium parse
of the whole PDF. Loading also shared the parse queue's two slots, so a stalled
parse kept other files from loading at all. Observed in Chrome: nine seconds of
the upload drop zone on a cold engine, with the sidebar row showing as open.

- The File is published as soon as it loads; the parse is still queued and now
  only refines the stub (page metadata, thumbnail).
- The workbench shows progress instead of the drop zone while files are loading.
- A load that hasn't settled after 8s names the file in the console. Reporting
  only: the read is never abandoned, because large files legitimately take time.
2026-08-12 10:59:51 +01:00
EthanHealy01 9fb7e531b1 Reject instead of hanging when pdfium's WASM won't instantiate
`instantiateWasm` reports success by callback, so a rejection inside it is
invisible to emscripten: init() simply stays pending, and with it every
thumbnail, page parse, form read and policy delivery - silently, for the rest of
the session. The hand-rolled streaming fallback had no rejection handling at all.

Failures are now raced into the init promise, the fallback is gone (with no
override emscripten fetches the WASM itself and rejects properly), and a failed
load is no longer cached so the next call can retry.

The viewer was unaffected throughout because it uses embedpdf's own engine, which
is why this presented as "I can view files but see no thumbnails".
2026-08-12 10:59:51 +01:00
EthanHealy01 b21187803d Stop WebKit's blob-value failures from blocking file access
WebKit refuses Blob/File values in IndexedDB outright - proven with raw
IndexedDB and no app code: the write fails with UnknownError and aborts its
transaction, where Chromium commits and reads back cleanly. It can also accept
one and later lose the backing store, leaving a record that looks valid and
whose bytes are gone.

- The "this browser loses blob values" verdict is now durable. Session-scoped,
  every reload re-decided optimistically and wrote another batch of files the
  engine would lose, so Safari never converged on a shape that works.
- Readability is reported, never awaited. The probe read of a lost backing store
  can stay pending forever in Safari, so awaiting it stalled EVERY file open
  rather than the one consumer that would have failed anyway. The store-time
  probe, which must run while the source file is still in hand to repair the
  record, gets a deadline instead.
- deleteStirlingFile resolves on commit with an abort guard: callers refresh
  their list as soon as it resolves, and an aborted delete put the row back.
- orphanedAncestorIds() collects the versions nothing else needs, keeping any
  ancestor a surviving leaf still descends from (split siblings).
2026-08-12 10:59:39 +01:00
EthanHealy01 b4b9a149fb Resolve @app/* in Storybook's worker bundle too
Storybook builds through its own Vite config, so the worker.plugins entry added
to editor/vite.config.ts didn't reach it: the app build resolved the alias and
the Storybook preview build failed on the same import.

Same root cause, same fix. The tsconfigPaths plugin is now built by a helper so
the main pass and the worker pass each get their own instance instead of the
alias map being restated.

Caught by CI rather than locally because `task frontend:check` stops at
lint/typecheck/test - storybook:build only runs under `check:all`.
2026-08-10 18:37:03 +01:00
EthanHealy01 592dc1a1a2 Split the engine-agnostic storage work out of this branch
The multi-tab IndexedDB lifecycle fixes and the thumbnail TTL write
amplification fix are not WebKit bugs - they were found while chasing the same
symptom, not the same cause. They now live in their own PRs so each can be
reviewed against its own evidence:

- fix/indexeddb-multitab-lifecycle: blocked opens/deletes, the concurrent-open
  race, onversionchange, and the sidebar/picker resting state.
- perf/thumbnail-ttl-write-amplification: the once-a-day bump debounce.

What stays here is WebKit-caused, including the parts that look like generic
refactoring: a refused blob write aborts its transaction, so `settleOnAbort`
and the single-transaction `updateRecord` are the fix for tool outputs silently
failing to persist on Safari, not tidy-up.

FileSidebar's try/catch appears in both this branch and the lifecycle branch -
a WebKit rejection and a blocked-open rejection both have to stop stranding the
spinner. Whichever lands second is a no-op for that file.
2026-08-10 17:58:25 +01:00
EthanHealy01 b8ee6e589d Resolve @app/* in worker bundles instead of exempting one import
Worker bundles are a separate Rollup pass and do not inherit `plugins`, so
`@app/*` - provided by vite-tsconfig-paths - resolved in the app and failed
in a worker. The workaround was a relative import plus an oxlint exemption,
which left the next value import into a worker to rediscover the same thing.

Give the worker pass the same tsconfigPaths plugin. Verified both ways: the
build resolves the alias and inlines the probe into the worker chunk, and
removing the block fails with "Rollup failed to resolve @app/utils/
canvasImageEncoding from pixelCompareWorker.ts".
2026-08-10 17:46:55 +01:00
EthanHealy01 f3d4ead177 Drop the e2e:capabilities task, superseded by e2e:cross-browser
#7304 made CI run the whole stubbed suite once per engine, which removed the
only use of this task that e2e:cross-browser didn't already cover. Run the
capability specs alone with:

  task e2e:cross-browser -- --grep @engine-capability

The @engine-capability tag stays, so the specs are unchanged.
2026-08-10 16:04:09 +01:00
EthanHealy01 d0793ea8bb Merge remote-tracking branch 'origin/fix/webkit-engine-capabilities' into fix/webkit-engine-capabilities 2026-08-10 13:41:18 +01:00
EthanHealy01 bdccc90e80 Merge origin/main into fix/webkit-engine-capabilities
Resolves the e2e CI conflicts against #7304, which landed a per-browser
matrix that already runs the whole stubbed suite on chromium, firefox and
webkit for every PR.

- .github/workflows/e2e-stubbed.yml: take main's matrix. It supersedes this
  branch's dedicated @engine-capability step, which existed only to get
  cross-engine coverage into PRs without paying for the full suite.
- .taskfiles/e2e.yml: keep both tasks. `stubbed-project` is what the matrix
  calls; `capabilities` stays as the local shortcut for checking all three
  engines without the whole cross-browser run.
2026-08-10 13:39:02 +01:00
Anthony Stirling 59ed4f5fd1 Fix automate unrunnable tools (#7311)
# Description of Changes

Fix automate unrunnable tools


## Problem

- Remove Image failed in Automate with `Tool operation not supported:
removeImage`
- Its registry entry had `operationConfig: undefined` even though the
config existed and was already tested
- The Automate picker only filtered on `supportsAutomate`, never on
`operationConfig` — so broken tools were selectable and failed only at
run time

## Fixes

- Wire up `removeImage` and `pageLayout` operation configs (both already
existed, just never registered)
- Exclude `validateSignature` (report tool, not on the operationConfig
seam) and `scannerEffect` (no frontend implementation) via
`supportsAutomate: false`
- Picker now also filters on `operationConfig`, so this class of bug
can't reach users again
- `overlay-pdfs` returns 400 instead of 500 when overlay files or mode
are missing
- Fix `new URL().pathname` Windows path bug that stopped 2 test suites
from loading

---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-10 11:14:56 +00:00
Reece Browne 7bf18cc4c7 Storybook: render off the app's real theme CSS, stop hardcoding story colours, and gate a11y in dark mode too (#7187)
## What

Makes Storybook render components with the same CSS the app gives them.

- The preview loaded the token primitives but **not the editor's
semantic token layer** (`styles/theme.css`), so components styled on
those variables rendered unthemed — three onboarding stories were
importing it by hand to stop their modal surfaces rendering transparent.
It's now loaded in the preview and the workarounds are gone.
- **Portal stories render inside the `.portal-scope` wrapper** PortalApp
mounts, so the portal's scoped reset and typography apply to them
exactly as in the app — and, deliberately, to nothing else.
- The folder stories invented their own hex colours, two of which aren't
values the app's `FOLDER_COLOR_PALETTE` can produce. They now use the
palette, so they can't drift from what a user can actually pick.

Deliberately does **not** load `tailwind.css` — tailwind is on its way
out of the editor, so matching the token layer alone is the target
state.

## Story colours route through the tokens, enforced

Stories were exempt from the `code-colors` lint, and it showed:
hardcoded hexes for surfaces the tokens already name (chat bubbles,
borders, demo backgrounds), `var(--x, #hex)` fallbacks that mask a
renamed token by silently painting the stale colour, and mocked category
accents for which real `--color-cat-*` tokens exist.

- Styling literals now use tokens; the dead fallbacks are stripped.
- The stories exemption is removed from `theme-lint`, so this can't
regress.
- Colours that are **the datum itself** — `ColorInput` values, signature
ink, per-policy accents, brand-mark swatches — stay literal via
`theme-allow-color`, hoisted to named consts so the exemption and its
reason sit together.

A practical side effect: stories styled on tokens actually respond to
the dark-mode toolbar toggle, which is what makes a dark-theme a11y pass
meaningful later.

## The a11y gate now runs dark as well as light

Contrast is most of what axe reports and it is theme-dependent, so a
light-only gate left half the surface unmeasured — and it only becomes
measurable at all once the tokens above actually flip. `SCAN_THEME=dark`
pins the theme for a whole scan run, every a11y task runs both themes,
and each theme has its own baseline:

- **light** re-recorded against the themed rendering (the old baseline
measured colours the app never shows): 831 stories with violations
- **dark** recorded for the first time: 798 stories with violations, 980
story-rule pairs, zero render failures across the full sweep

Verified end to end: dark scans measure against dark surfaces (`#18181b`
vs `#ffffff`), both baselines self-check clean, and a live scan of
stories that changed on main after recording passes both gates.
Nightly's timeout doubles for the second sweep.

## Testing

Typecheck (all variants), ESLint and Prettier pass. Onboarding, folder,
portal and control stories render in the browser scan (39/39) with the
per-story CSS imports removed; every story touched by the colour sweep
renders too (58/58). `task frontend:lint:colors` passes with stories
included.
2026-08-10 11:06:18 +00:00
James Brunton af5f54274d Add defaults to calculations for ToolIO (#7289)
# Description of Changes
Currently when calculating the output file type for some tools, the
system will get it wrong because it doesn't know about what the default
parameters in tools are, so if it doesn't have a value for some key,
it'll just bail out and say "it might not be compatible". This PR adds
logic to `ToolIO` to read the default values set for the parameters if
the tool has `ToolIOCase`s and takes them into account when figuring out
the output type. I've built it with horrible Java reflection magic to
avoid having to specify the default for params twice, which will make it
impossible for the defaults to disagree with each other. This just runs
once at startup so there's negligible performance impact.

The change is easily tested with Change Parameters, which is just
`add-password` behind the scenes but with the password params omitted
(so Change Password is always PDF->PDF, never encrypted like Add
Password).

Also (somewhat hackily) fixes a bug I noticed where saving a Change
Permissions step then leaving and returning to the pipeline will cause
the step to be reloaded as Add Password. I've added a system to
disambiguate tools which share the same endpoint (which is only these
two currently).

## Currently

<img width="455" height="135" alt="image"
src="https://github.com/user-attachments/assets/c8867d6a-599b-4f21-a2db-4a1b6ac22d73"
/>

## Now

<img width="415" height="122" alt="image"
src="https://github.com/user-attachments/assets/bd8d1bab-f00a-421b-8c91-5af0d2ad5335"
/>
2026-08-10 11:00:16 +00:00
James Brunton 78acd9a14b Run Playwright on all platforms in PRs (#7304)
# Description of Changes
Nightlies keep failing because the Playwright tests only run on Chrome
in PRs. This PR changes it so that we run all 3 browsers in all
(frontend) PRs so we catch these things before they merge in. They run
in parallel so it won't take any more time for the CI to finish.
2026-08-10 10:41:07 +00:00
James Brunton cfaf777f2b Fix more any type usages in frontend code (#7334)
# Description of Changes
Continued effort towards removing all uses of the any type in our
frontend code (last PR was #7326). This PR fixes 7 more folders and
removes them from the exclude list. All of them were localised within
the folder in the exclude list so again were pretty easy to fix.
2026-08-10 10:40:47 +00:00
brios 4e901f7524 refactor(package): rename example classes and update package structure (#7400)
# Description of Changes

Refactors a small set of previously vendored/derived PDFBox "example"
classes into Stirling’s own package namespaces.



<!--
Please provide a summary of the changes, including:

- What was changed
- Why the change was made
- Any challenges encountered

Closes #(issue_number)
-->

---

## Checklist

### General

- [X] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [X] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [X] I have performed a self-review of my own code
- [X] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [X] I have run `task check` to verify linters, typechecks, and tests
pass
- [X] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-09 20:41:57 +01:00
Ludy 0c0a96aa83 build(deps): bump org.simplejavamail from 8.12.0 to 9.2.0 (#7398)
Bump org.simplejavamail:simple-java-mail and outlook-module from 8.12.6
to 9.2.0 in app/common/build.gradle. Add a Dependabot group for the
org.simplejavamail artifacts in .github/dependabot.yml so updates for
those packages are grouped into a single PR.


---

## Checklist

### General

- [ ] I have read the [Contribution
Guidelines](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/CONTRIBUTING.md)
- [ ] I have read the [Stirling-PDF Developer
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md)
(if applicable)
- [ ] I have read the [How to add new languages to
Stirling-PDF](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md)
(if applicable)
- [ ] I have performed a self-review of my own code
- [ ] My changes generate no new warnings

### Documentation

- [ ] I have updated relevant docs on [Stirling-PDF's doc
repo](https://github.com/Stirling-Tools/Stirling-Tools.github.io/blob/main/docs/)
(if functionality has heavily changed)
- [ ] I have read the section [Add New Translation
Tags](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/devGuide/HowToAddNewLanguage.md#add-new-translation-tags)
(for new translation tags only)

### Translations (if applicable)

- [ ] I ran
[`scripts/counter_translation.py`](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/docs/counter_translation.md)

### UI Changes (if applicable)

- [ ] Screenshots or videos demonstrating the UI changes are attached
(e.g., as comments or direct attachments in the PR)

### Testing (if applicable)

- [ ] I have run `task check` to verify linters, typechecks, and tests
pass
- [ ] I have tested my changes locally. Refer to the [Testing
Guide](https://github.com/Stirling-Tools/Stirling-PDF/blob/main/DeveloperGuide.md#7-testing)
for more details.
2026-08-09 20:30:33 +01:00
EthanHealy01 69f642f87d Merge branch 'main' into fix/webkit-engine-capabilities 2026-08-08 23:04:33 +01:00
EthanHealy01 d6168a5c55 fix(frontend): stop WebKit storage and engine failures from hanging the app
Follow-up to #7314. That PR fixed the IndexedDB blob rejection itself; this
one fixes the ways the same failures surfaced as a permanent spinner, and adds
the cross-browser CI signal that would have caught them on the pull request
instead of six weeks later in a nightly run.

Storage no longer hangs:
- Blocked IndexedDB opens and deletes now time out with an actionable error
  instead of never settling, and the app sets `onversionchange` so an open tab
  yields its connection rather than blocking every other tab forever.
- The in-flight open promise is registered before the first await, so
  concurrent boot-time callers share one connection. Only the first request
  receives `blocked`; the others were getting no events at all.
- Transactions settle on abort. Read-modify-write moves to a single
  `updateRecord` helper that owns its transaction, guards it once, and resolves
  on commit - the previous two-promises-over-one-transaction shape left the
  write with no abort handler, which could hang output persistence silently.
- The blob-value refusal is remembered from any write, not just the initial
  add: WebKit reports it per-operation, so an engine that accepted the add can
  still refuse the rewrite.

WebKit engine gaps:
- ReadableStream async iteration, which pdf.js uses for all text extraction.
  Without it Compare, read-aloud and the text editor were dead on Safari.
- requestIdleCallback, installed once at the entry point instead of guarded at
  each call site.
- convertToBlob silently returns PNG for a format it can't encode, so canvas
  output now probes what the engine really produced and picks the best lossy
  format it honours.

CI:
- A small @engine-capability suite runs on Chromium, Firefox and WebKit on
  every pull request. It asserts the primitives actually work (a counted
  comparison, a raster thumbnail, a byte round-trip through a reload) rather
  than that the UI rendered, which is how two total WebKit outages passed.
- The cross-browser projects now share the stubbed project's viewport so a
  layout difference can't read as an engine outage.
2026-08-08 22:44:29 +01:00
131 changed files with 5745 additions and 673 deletions
+5
View File
@@ -16,6 +16,11 @@ updates:
cooldown:
default-days: 7
rebase-strategy: "auto"
groups:
simple-java-mail:
patterns:
- "org.simplejavamail:simple-java-mail"
- "org.simplejavamail:outlook-module"
- package-ecosystem: "docker"
directories:
+19 -6
View File
@@ -2,7 +2,8 @@ name: Playwright E2E (stubbed)
# Reusable workflow called from build.yml. Backend-free Playwright suite —
# fast, no Spring Boot required. Runs against the `stubbed` project which
# mocks API responses in the browser.
# mocks API responses in the browser. Fans out one job per browser
# (chromium/firefox/webkit) so all three run in parallel on their own runner.
on:
workflow_call:
@@ -11,7 +12,19 @@ permissions:
jobs:
playwright-e2e:
name: playwright-e2e (${{ matrix.browser }})
runs-on: ubuntu-latest
strategy:
# One browser breaking must not mask a failure in another - report all.
fail-fast: false
matrix:
include:
- browser: chromium
project: stubbed
- browser: firefox
project: stubbed-firefox
- browser: webkit
project: stubbed-webkit
steps:
- name: Harden Runner
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -27,16 +40,16 @@ jobs:
cache-dependency-path: frontend/package-lock.json
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: Install Playwright (chromium only)
run: task e2e:install -- chromium
- name: Install Playwright (${{ matrix.browser }})
run: task e2e:install -- ${{ matrix.browser }}
- name: Build frontend (production bundle for vite preview)
env:
VITE_BUILD_FOR_PREVIEW: "1"
run: task frontend:build
- name: Run stubbed E2E tests (chromium)
- name: Run stubbed E2E tests (${{ matrix.browser }})
env:
PLAYWRIGHT_JSON_OUTPUT_FILE: ${{ github.workspace }}/frontend/playwright-report/results.json
run: task e2e:stubbed -- --workers=3
run: task e2e:stubbed-project PROJECT=${{ matrix.project }} -- --workers=3
- name: Flag flaky tests
# Runs regardless of the test outcome: a flaky test (passed on retry)
# leaves the step green, so this is the only place it surfaces. Emits
@@ -50,6 +63,6 @@ jobs:
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: playwright-report-stubbed-${{ github.run_id }}
name: playwright-report-stubbed-${{ matrix.browser }}-${{ github.run_id }}
path: frontend/playwright-report/
retention-days: 7
+4 -3
View File
@@ -59,9 +59,10 @@ jobs:
# the story itself — a shared component, a theme token — still surfaces within
# a day.
a11y-all-stories:
name: a11y (every story)
name: a11y (every story, light + dark)
runs-on: ubuntu-latest
timeout-minutes: 60
# Two full sweeps (one per theme), each ~30 minutes of browser time.
timeout-minutes: 120
steps:
- name: Harden the runner (Audit all outbound calls)
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
@@ -81,7 +82,7 @@ jobs:
- name: Install Task
uses: go-task/setup-task@01a4adf9db2d14c1de7a560f09170b6e0df736aa # v2.1.0
- name: a11y gate (every story)
- name: a11y gate (every story, light + dark)
run: task frontend:storybook:a11y
- name: Upload scan reports
+1 -1
View File
@@ -22,7 +22,7 @@ frontend/editor/src/portal/components/docs/GettingStartedSection.tsx:generic-api
# False positive: generic-api-key matches the Java type name "X509Certificate"
# in a method signature (CreateSignatureBase.resolveSignatureAlgorithm) - not a secret.
app/core/src/main/java/org/apache/pdfbox/examples/signature/CreateSignatureBase.java:generic-api-key:224
app/core/src/main/java/stirling/software/SPDF/pdf/signature/CreateSignatureBase.java:generic-api-key:224
# Supabase publishable key (public by design, RLS-protected) used as a CI fallback
# default in the tauri-build workflow when the GitHub secret is unset - not a real secret.
+9
View File
@@ -15,6 +15,15 @@ tasks:
cmds:
- npx playwright test --project=stubbed {{.CLI_ARGS}}
stubbed-project:
desc: "Run the stubbed E2E suite for a single Playwright project"
dir: frontend/editor
deps: [ ':frontend:prepare' ]
vars:
PROJECT: '{{.PROJECT | default "stubbed"}}'
cmds:
- npx playwright test --project={{.PROJECT}} {{.CLI_ARGS}}
live:
desc: "Run live E2E tests"
summary: |
+8 -2
View File
@@ -211,11 +211,13 @@ tasks:
- npx vitest run --config .storybook/vitest.config.ts {{.CLI_ARGS}}
storybook:a11y:
desc: "a11y regression gate over every story: fail only on NEW axe violations"
desc: "a11y regression gate over every story, light and dark: fail only on NEW axe violations"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:changed:
desc: "a11y gate over the stories this branch affects (default base origin/main)"
@@ -244,13 +246,17 @@ tasks:
fi
node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt
SCAN_THEME=dark node .storybook/a11y-scan.mjs {{.CHANGED}}
node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --baseline .storybook/a11y-baseline.dark.json
storybook:a11y:record:
desc: "Re-record the a11y baseline (run after intentionally fixing/adding violations)"
desc: "Re-record both a11y baselines (run after intentionally fixing/adding violations)"
deps: [prepare, storybook:browser]
cmds:
- node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record
- SCAN_THEME=dark node .storybook/a11y-scan.mjs
- node .storybook/a11y-check.mjs --in .a11y-scan --manifest .a11y-scan/manifest.txt --record --baseline .storybook/a11y-baseline.dark.json
# ============================================================
# Code quality
+2 -2
View File
@@ -21,8 +21,8 @@ dependencies {
api 'org.snakeyaml:snakeyaml-engine:3.0.1'
api "org.springdoc:springdoc-openapi-starter-webmvc-ui:3.0.3"
// Simple Java Mail for EML/MSG parsing (replaces direct Angus Mail usage)
api 'org.simplejavamail:simple-java-mail:8.12.6'
api 'org.simplejavamail:outlook-module:8.12.6' // MSG file support
api 'org.simplejavamail:simple-java-mail:9.2.0'
api 'org.simplejavamail:outlook-module:9.2.0' // MSG file support
api 'jakarta.mail:jakarta.mail-api:2.1.5'
runtimeOnly 'org.eclipse.angus:angus-mail:2.0.5'
@@ -1,5 +1,6 @@
package stirling.software.common.config.swagger;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.LinkedHashMap;
import java.util.List;
@@ -18,6 +19,7 @@ import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOCase;
import stirling.software.common.model.tool.ToolIOWhen;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Publishes each {@link ToolIO} into the spec as {@code x-stirling-io}, which is how the frontend
@@ -49,40 +51,42 @@ public class ToolIOOperationCustomizer
if (declaration == null) {
return operation;
}
operation.addExtension(EXTENSION_NAME, toExtension(declaration));
operation.addExtension(EXTENSION_NAME, toExtension(declaration, handlerMethod.getMethod()));
operation.setDescription(appendSummaryLine(operation.getDescription(), declaration));
return operation;
}
private static Map<String, Object> toExtension(ToolIO declaration) {
private static Map<String, Object> toExtension(ToolIO declaration, Method handler) {
Map<String, Object> extension = new LinkedHashMap<>();
extension.put("accepts", names(declaration.accepts()));
extension.put("produces", declaration.produces().name());
extension.put("arity", declaration.arity().name());
if (declaration.cases().length > 0) {
extension.put("cases", cases(declaration));
extension.put("cases", cases(declaration, handler));
}
return extension;
}
private static List<Map<String, Object>> cases(ToolIO declaration) {
return Arrays.stream(declaration.cases()).map(ToolIOOperationCustomizer::toCase).toList();
private static List<Map<String, Object>> cases(ToolIO declaration, Method handler) {
return Arrays.stream(declaration.cases()).map(rule -> toCase(rule, handler)).toList();
}
private static Map<String, Object> toCase(ToolIOCase rule) {
private static Map<String, Object> toCase(ToolIOCase rule, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put(
"when",
Arrays.stream(rule.when()).map(ToolIOOperationCustomizer::toCondition).toList());
entry.put("when", Arrays.stream(rule.when()).map(c -> toCondition(c, handler)).toList());
entry.put("produces", rule.produces().name());
entry.put("arity", rule.arity().name());
return entry;
}
private static Map<String, Object> toCondition(ToolIOWhen condition) {
private static Map<String, Object> toCondition(ToolIOWhen condition, Method handler) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("param", condition.param());
entry.put("matches", List.of(condition.matches()));
// The default the endpoint uses when this parameter is absent, so a step that never sends
// it still resolves. Omitted when the parameter is required with none.
ToolIOParameterDefaults.resolve(handler, condition.param())
.ifPresent(value -> entry.put("default", value));
return entry;
}
@@ -5,13 +5,18 @@ import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** The runtime form of a {@link ToolIO} declaration, read off a handler method once at startup. */
public record ToolIOSpec(
Set<ToolFormat> accepts, ToolFormat produces, ToolArity arity, List<Case> cases) {
public record When(String param, List<String> matches) {
/**
* @param paramDefault the value used when the parameter is absent, or null when it has no
* default - an absent parameter then leaves the case unresolved rather than defaulted.
*/
public record When(String param, List<String> matches, String paramDefault) {
boolean holdsFor(Object value) {
String normalised = normalise(value);
@@ -19,6 +24,14 @@ public record ToolIOSpec(
}
}
/** Supplies the default value a request parameter takes when a caller omits it. */
@FunctionalInterface
public interface ParameterDefaults {
Optional<String> defaultFor(String param);
ParameterDefaults NONE = param -> Optional.empty();
}
/**
* Both sides of a condition are normalised at comparison, not at construction: the declaration
* reaches the frontend and the engine as published data, and normalising only one side there
@@ -44,25 +57,33 @@ public record ToolIOSpec(
}
public static ToolIOSpec from(ToolIO annotation) {
return from(annotation, ParameterDefaults.NONE);
}
public static ToolIOSpec from(ToolIO annotation, ParameterDefaults defaults) {
return new ToolIOSpec(
new LinkedHashSet<>(Arrays.asList(annotation.accepts())),
annotation.produces(),
annotation.arity(),
Arrays.stream(annotation.cases()).map(ToolIOSpec::toCase).toList());
Arrays.stream(annotation.cases()).map(rule -> toCase(rule, defaults)).toList());
}
private static Case toCase(ToolIOCase rule) {
List<When> when = Arrays.stream(rule.when()).map(ToolIOSpec::toWhen).toList();
private static Case toCase(ToolIOCase rule, ParameterDefaults defaults) {
List<When> when = Arrays.stream(rule.when()).map(c -> toWhen(c, defaults)).toList();
return new Case(when, rule.produces(), rule.arity());
}
private static When toWhen(ToolIOWhen condition) {
return new When(condition.param(), List.of(condition.matches()));
private static When toWhen(ToolIOWhen condition, ParameterDefaults defaults) {
return new When(
condition.param(),
List.of(condition.matches()),
defaults.defaultFor(condition.param()).orElse(null));
}
/**
* First matching {@link Case} wins. If none match but one reads a parameter we cannot see, the
* declared output comes back uncertain: a value we never saw might have picked another branch.
* First matching {@link Case} wins. A parameter the caller omitted resolves to its declared
* default; only a parameter with no default leaves the output uncertain, since an unseen value
* might then have picked another branch.
*
* @param parameters the step's configured parameters, or null when not known
*/
@@ -71,12 +92,17 @@ public record ToolIOSpec(
for (Case rule : cases) {
boolean allHold = true;
for (When condition : rule.when()) {
if (parameters == null || !parameters.containsKey(condition.param())) {
Object value;
if (parameters != null && parameters.containsKey(condition.param())) {
value = parameters.get(condition.param());
} else if (condition.paramDefault() != null) {
value = condition.paramDefault();
} else {
sawUnknownParam = true;
allHold = false;
continue;
}
allHold &= condition.holdsFor(parameters.get(condition.param()));
allHold &= condition.holdsFor(value);
}
if (allHold) {
return new Output(rule.produces(), rule.arity(), true);
@@ -17,7 +17,6 @@ import java.util.concurrent.Semaphore;
import java.util.function.Consumer;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.examples.util.DeletingRandomAccessFile;
import org.apache.pdfbox.io.IOUtils;
import org.apache.pdfbox.io.MemoryUsageSetting;
import org.apache.pdfbox.io.RandomAccessReadBufferedFile;
@@ -31,6 +30,7 @@ import org.springframework.web.multipart.MultipartFile;
import lombok.extern.slf4j.Slf4j;
import stirling.software.common.model.api.PDFFile;
import stirling.software.common.util.DeletingRandomAccessFile;
import stirling.software.common.util.ExceptionUtils;
import stirling.software.common.util.TempFileManager;
@@ -0,0 +1,92 @@
package stirling.software.common.service;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.lang.reflect.Parameter;
import java.util.Optional;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.extern.slf4j.Slf4j;
/**
* The value a request parameter takes when the caller omits it, read from the request model so a
* {@code @ToolIOCase} can be resolved even for a step that never sends the parameter it branches
* on. The default is read from the field so it cannot drift.
*/
@Slf4j
public final class ToolIOParameterDefaults {
// Swagger's sentinel for an unset @Schema string member; not a real default value.
private static final String SCHEMA_UNSET = "##default";
private ToolIOParameterDefaults() {}
/**
* The default {@code param} resolves to when absent, or empty when the parameter is required
* with no declared default - in which case an unset value leaves the output genuinely unknown
* rather than defaulted, and the chain reports it as uncertain.
*
* <p>Precedence: an explicit {@code @Schema(defaultValue)}, then the field's own value (a
* primitive's language default, or an initializer), then the empty string for an optional field
* left null, and finally empty for a required field with none of the above.
*/
public static Optional<String> resolve(Method handler, String param) {
for (Parameter parameter : handler.getParameters()) {
Field field = findField(parameter.getType(), param);
if (field != null) {
return fromField(parameter.getType(), field);
}
}
return Optional.empty();
}
private static Optional<String> fromField(Class<?> owner, Field field) {
Schema schema = field.getAnnotation(Schema.class);
if (schema != null
&& !schema.defaultValue().isEmpty()
&& !SCHEMA_UNSET.equals(schema.defaultValue())) {
return Optional.of(schema.defaultValue());
}
Object value = readField(owner, field);
if (value != null) {
return Optional.of(String.valueOf(value));
}
return isRequired(field, schema) ? Optional.empty() : Optional.of("");
}
private static Object readField(Class<?> owner, Field field) {
try {
Object instance = owner.getDeclaredConstructor().newInstance();
field.setAccessible(true);
return field.get(instance);
} catch (ReflectiveOperationException | RuntimeException e) {
// A request model we cannot instantiate leaves the default unknown, which the check
// treats conservatively as uncertain. Never break startup over it.
log.warn("Could not read default of {}.{}", owner.getSimpleName(), field.getName(), e);
return null;
}
}
private static boolean isRequired(Field field, Schema schema) {
if (schema != null && schema.requiredMode() == Schema.RequiredMode.REQUIRED) {
return true;
}
return field.isAnnotationPresent(NotNull.class)
|| field.isAnnotationPresent(NotBlank.class);
}
private static Field findField(Class<?> type, String name) {
for (Class<?> c = type; c != null && c != Object.class; c = c.getSuperclass()) {
try {
return c.getDeclaredField(name);
} catch (NoSuchFieldException ignored) {
// Try the superclass; request models extend a shared file-input base.
}
}
return null;
}
}
@@ -67,7 +67,10 @@ public class ToolIORegistry implements ToolMetadataService, ToolIOSource {
if (annotation == null) {
return;
}
ToolIOSpec spec = ToolIOSpec.from(annotation);
Method method = handler.getMethod();
ToolIOSpec spec =
ToolIOSpec.from(
annotation, param -> ToolIOParameterDefaults.resolve(method, param));
for (String pattern : extractPatterns(info)) {
target.put(pattern, spec);
}
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.common.util;
import java.io.File;
import java.io.IOException;
@@ -194,7 +194,7 @@ public class EmlParser {
}
attachment.setFilename(filename);
String contentId = embedded ? stripCid(resourceName) : null;
String contentId = embedded ? stripCid(resource.getContentId()) : null;
attachment.setContentId(contentId);
String detectedContentType = EmlProcessingUtils.detectMimeType(filename, contentType);
@@ -101,7 +101,12 @@ class ToolChainValidatorConformanceTest {
for (JsonNode match : condition.get("matches")) {
matches.add(match.asString());
}
when.add(new ToolIOSpec.When(condition.get("param").asString(), matches));
JsonNode paramDefault = condition.get("default");
when.add(
new ToolIOSpec.When(
condition.get("param").asString(),
matches,
paramDefault == null ? null : paramDefault.asString()));
}
cases.add(
new ToolIOSpec.Case(
@@ -61,6 +61,7 @@ public class PdfOverlayController {
int overlayPos = request.getOverlayPosition();
MultipartFile[] overlayFiles = request.getOverlayFiles();
validateOverlayFiles(overlayFiles);
File[] overlayPdfFiles = new File[overlayFiles.length];
List<File> tempFiles = new ArrayList<>(); // List to keep track of temporary files
@@ -120,10 +121,29 @@ public class PdfOverlayController {
}
}
// Both fields are declared required, but @ModelAttribute binding leaves them null when the
// caller omits them, which would otherwise surface as a 500 instead of a 400.
private void validateOverlayFiles(MultipartFile[] overlayFiles) {
if (overlayFiles == null || overlayFiles.length == 0) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFilesRequired", "At least one overlay file is required");
}
for (MultipartFile overlayFile : overlayFiles) {
if (overlayFile == null || overlayFile.isEmpty()) {
throw ExceptionUtils.createIllegalArgumentException(
"error.overlayFileEmpty", "Overlay files must not be empty");
}
}
}
private Map<Integer, String> prepareOverlayGuide(
int basePageCount, File[] overlayFiles, String mode, int[] counts, List<File> tempFiles)
throws IOException {
Map<Integer, String> overlayGuide = new HashMap<>();
if (mode == null) {
throw ExceptionUtils.createIllegalArgumentException(
"error.invalidFormat", "Invalid {0} format: {1}", "overlay mode", "null");
}
switch (mode) {
case "SequentialOverlay":
sequentialOverlay(overlayGuide, overlayFiles, basePageCount, tempFiles);
@@ -13,7 +13,6 @@ import java.util.Calendar;
import java.util.List;
import org.apache.commons.io.FileUtils;
import org.apache.pdfbox.examples.signature.CreateSignatureBase;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
@@ -76,6 +75,7 @@ import lombok.extern.slf4j.Slf4j;
import stirling.software.SPDF.config.swagger.StandardPdfResponse;
import stirling.software.SPDF.model.api.security.SignPDFWithCertRequest;
import stirling.software.SPDF.pdf.signature.CreateSignatureBase;
import stirling.software.SPDF.service.HardwareKeyStoreService;
import stirling.software.common.annotations.AutoJobPostMapping;
import stirling.software.common.enumeration.ResourceWeight;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.IOException;
import java.io.InputStream;
@@ -15,7 +15,7 @@
* limitations under the License.
*/
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import java.io.ByteArrayInputStream;
import java.io.IOException;
@@ -14,7 +14,7 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import java.io.IOException;
import java.io.InputStream;
@@ -26,6 +26,7 @@ import org.springframework.web.bind.annotation.RequestMapping;
import stirling.software.common.model.tool.ToolFormat;
import stirling.software.common.model.tool.ToolIO;
import stirling.software.common.model.tool.ToolIOSpec;
import stirling.software.common.service.ToolIOParameterDefaults;
/**
* Every document-transforming endpoint must declare its I/O, or it becomes a hole in the
@@ -233,6 +234,32 @@ class ToolIODeclarationCoverageTest {
assertEquals(Set.of("ps", "pcl", "xps"), declared);
}
@Test
void anAbsentParameterResolvesToItsRequestModelDefault() {
// A pipeline step often omits a parameter a case branches on. The default is read from the
// request model, so the output resolves anyway instead of coming back uncertain.
// Auto Rotate never sends dryRun; its default (false) means the JSON branch cannot fire.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/misc/auto-rotate-pdf").resolveOutput(Map.of()).certain());
// Change Permissions posts to add-password with no password fields; both default to blank,
// so the unencrypted branch fires and it is not mistaken for producing an encrypted PDF.
assertEquals(
ToolFormat.PDF,
spec("/api/v1/security/add-password").resolveOutput(Map.of()).format());
assertTrue(spec("/api/v1/security/add-password").resolveOutput(Map.of()).certain());
}
@Test
void aRequiredParameterWithNoDefaultStaysUncertainWhenAbsent() {
// pdf/text branches on outputFormat, which is required with no default. Absent, its output
// is genuinely txt-or-rtf-dependent, so it must remain uncertain rather than assume TEXT.
assertFalse(spec("/api/v1/convert/pdf/text").resolveOutput(Map.of()).certain());
}
@Test
void onlyRemovePasswordAcceptsAnEncryptedDocument() {
assertTrue(
@@ -288,7 +315,11 @@ class ToolIODeclarationCoverageTest {
}
required.add(full);
if (declaration != null) {
declared.put(full, ToolIOSpec.from(declaration));
declared.put(
full,
ToolIOSpec.from(
declaration,
param -> ToolIOParameterDefaults.resolve(method, param)));
}
}
}
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.signature;
package stirling.software.SPDF.pdf.signature;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
@@ -1,4 +1,4 @@
package org.apache.pdfbox.examples.util;
package stirling.software.SPDF.utils;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
+6 -1
View File
@@ -65,6 +65,8 @@ class ToolIOWhen(ApiModel):
param: str
matches: list[str]
# The value the endpoint uses when this parameter is absent; None when it has none.
default: str | None = None
class ToolIOCase(ApiModel):
@@ -378,7 +380,10 @@ def collect_tool_io(spec: dict[str, Any]) -> dict[str, dict[str, Any]]:
def _render_when(condition: dict[str, Any]) -> str:
return f"ToolIOWhen(param={json.dumps(condition['param'])}, matches={json.dumps(condition['matches'])})"
parts = [f"param={json.dumps(condition['param'])}", f"matches={json.dumps(condition['matches'])}"]
if "default" in condition:
parts.append(f"default={json.dumps(condition['default'])}")
return f"ToolIOWhen({', '.join(parts)})"
def _render_case(case: dict[str, Any]) -> str:
+21 -8
View File
@@ -58,6 +58,8 @@ class ToolIOWhen(ApiModel):
param: str
matches: list[str]
# The value the endpoint uses when this parameter is absent; None when it has none.
default: str | None = None
class ToolIOCase(ApiModel):
@@ -101,7 +103,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SIMO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"])],
when=[ToolIOWhen(param="singleOrMultiple", matches=["single"], default="multiple")],
produces=ToolFormat.IMAGE,
arity=ToolArity.SISO,
)
@@ -130,15 +132,19 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["ps"])],
when=[ToolIOWhen(param="outputFormat", matches=["ps"], default="eps")],
produces=ToolFormat.POSTSCRIPT,
arity=ToolArity.SISO,
),
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["pcl"])], produces=ToolFormat.PCL, arity=ToolArity.SISO
when=[ToolIOWhen(param="outputFormat", matches=["pcl"], default="eps")],
produces=ToolFormat.PCL,
arity=ToolArity.SISO,
),
ToolIOCase(
when=[ToolIOWhen(param="outputFormat", matches=["xps"])], produces=ToolFormat.XPS, arity=ToolArity.SISO
when=[ToolIOWhen(param="outputFormat", matches=["xps"], default="eps")],
produces=ToolFormat.XPS,
arity=ToolArity.SISO,
),
],
),
@@ -151,7 +157,7 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.MIMO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"])],
when=[ToolIOWhen(param="combineIntoSinglePdf", matches=["true"], default="false")],
produces=ToolFormat.PDF,
arity=ToolArity.MISO,
)
@@ -202,7 +208,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="dryRun", matches=["true"])], produces=ToolFormat.JSON, arity=ToolArity.SISO
when=[ToolIOWhen(param="dryRun", matches=["true"], default="false")],
produces=ToolFormat.JSON,
arity=ToolArity.SISO,
)
],
),
@@ -223,7 +231,9 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="sidecar", matches=["true"])], produces=ToolFormat.ZIP, arity=ToolArity.SISO
when=[ToolIOWhen(param="sidecar", matches=["true"], default="false")],
produces=ToolFormat.ZIP,
arity=ToolArity.SISO,
)
],
),
@@ -242,7 +252,10 @@ TOOL_IO: dict[ToolEndpoint, ToolIOSpec] = {
arity=ToolArity.SISO,
cases=[
ToolIOCase(
when=[ToolIOWhen(param="password", matches=[""]), ToolIOWhen(param="ownerPassword", matches=[""])],
when=[
ToolIOWhen(param="password", matches=[""], default=""),
ToolIOWhen(param="ownerPassword", matches=[""], default=""),
],
produces=ToolFormat.PDF,
arity=ToolArity.SISO,
)
@@ -89,11 +89,16 @@ def resolve_output(spec: ToolIOSpec, parameters: dict[str, object] | None) -> Re
for rule in spec.cases:
all_hold = True
for condition in rule.when:
if parameters is None or condition.param not in parameters:
if parameters is not None and condition.param in parameters:
raw: object = parameters[condition.param]
elif condition.default is not None:
# The caller omitted it, so it takes the endpoint's default.
raw = condition.default
else:
saw_unknown_param = True
all_hold = False
continue
normalised = _normalise(parameters[condition.param])
normalised = _normalise(raw)
all_hold = all_hold and any(_normalise(m) == normalised for m in condition.matches)
if all_hold:
return ResolvedOutput(format=rule.produces, arity=rule.arity, certain=True)
File diff suppressed because it is too large Load Diff
+35 -50
View File
@@ -1,4 +1,7 @@
{
"editor/src/core/assets/Brand.stories.tsx :: Logos": [
"scrollable-region-focusable"
],
"editor/src/core/components/StorageStatsCard.stories.tsx :: Default": [
"aria-progressbar-name",
"color-contrast"
@@ -886,9 +889,6 @@
"editor/src/core/components/tools/changeMetadata/steps/DocumentDatesStep.stories.tsx :: Filled": [
"button-name"
],
"editor/src/core/components/tools/compare/CompareDocumentPane.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/core/components/tools/compare/ComparePixelWorkbenchView.stories.tsx :: Default": [
"color-contrast"
],
@@ -1377,8 +1377,17 @@
"editor/src/core/ui/ChatFABButton.stories.tsx :: Tick While Loading": [
"button-name"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": ["color-contrast"],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": ["color-contrast"],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Closed": [
"scrollable-region-focusable"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Open": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/ChatFABWindow.stories.tsx :: Toggle": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Chip.stories.tsx :: Accents": ["color-contrast"],
"editor/src/core/ui/Chip.stories.tsx :: Dashed Add": ["nested-interactive"],
"editor/src/core/ui/Chip.stories.tsx :: In Context Op Chain": [
@@ -1399,6 +1408,12 @@
"scrollable-region-focusable"
],
"editor/src/core/ui/CodeBlock.stories.tsx :: Playground": ["color-contrast"],
"editor/src/core/ui/Collapsible.stories.tsx :: Accordion": [
"scrollable-region-focusable"
],
"editor/src/core/ui/Collapsible.stories.tsx :: Default": [
"scrollable-region-focusable"
],
"editor/src/core/ui/Drawer.stories.tsx :: Playground": [
"aria-allowed-role",
"color-contrast"
@@ -1565,11 +1580,16 @@
"editor/src/core/ui/Table.stories.tsx :: Basic": ["color-contrast"],
"editor/src/core/ui/Table.stories.tsx :: Interactive": ["color-contrast"],
"editor/src/core/ui/Tabs.stories.tsx :: In Context Document Verticals": [
"color-contrast"
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Tabs.stories.tsx :: Playground": [
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Tabs.stories.tsx :: Playground": ["color-contrast"],
"editor/src/core/ui/Tabs.stories.tsx :: With Disabled Tab": [
"color-contrast"
"color-contrast",
"scrollable-region-focusable"
],
"editor/src/core/ui/Toast.stories.tsx :: Triggers": ["color-contrast"],
"editor/src/portal/components/AppShell.stories.tsx :: Mobile": [
@@ -1951,26 +1971,12 @@
"editor/src/portal/components/infrastructure/CreateKeyModal.stories.tsx :: Form": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/DeploymentsTab.stories.tsx :: Default": [
"aria-progressbar-name",
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Enterprise": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Free": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/ModelsTab.stories.tsx :: Pro": [
"aria-progressbar-name",
"color-contrast"
],
"editor/src/portal/components/infrastructure/SecurityTab.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/infrastructure/StorageTab.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/portal/components/pipelines/PipelineStepSettings.stories.tsx :: No Settings": [
"color-contrast"
],
@@ -2266,18 +2272,6 @@
"color-contrast",
"landmark-unique"
],
"editor/src/portal/views/Documents.stories.tsx :: Default": [
"aria-prohibited-attr",
"color-contrast",
"empty-table-header",
"nested-interactive"
],
"editor/src/portal/views/Documents.stories.tsx :: Empty": [
"aria-prohibited-attr",
"color-contrast",
"empty-table-header",
"nested-interactive"
],
"editor/src/portal/views/Home.stories.tsx :: Enterprise Tier": [
"color-contrast",
"landmark-no-duplicate-banner",
@@ -2304,23 +2298,11 @@
"color-contrast"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Default": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Policies.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Policies.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Default": [
"color-contrast",
"empty-table-header"
],
"editor/src/portal/views/Sources.stories.tsx :: Empty": [
"color-contrast",
"empty-table-header"
"color-contrast"
],
"editor/src/portal/views/Pipelines.stories.tsx :: Empty": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Default": ["color-contrast"],
"editor/src/portal/views/Sources.stories.tsx :: Empty": ["color-contrast"],
"editor/src/proprietary/auth/ui/AuthScreens.stories.tsx :: Signup": [
"aria-hidden-focus"
],
@@ -2354,6 +2336,9 @@
"aria-dialog-name",
"color-contrast"
],
"editor/src/proprietary/components/shared/DividerWithText.stories.tsx :: Default": [
"color-contrast"
],
"editor/src/proprietary/components/shared/UpdateSeatsModal.stories.tsx :: At Minimum": [
"button-name",
"color-contrast"
+20 -12
View File
@@ -10,6 +10,18 @@ import tsconfigPaths from "vite-tsconfig-paths";
* the portal layer at editor/src/portal/). MDX docs pages live in
* editor/src/portal/docs/.
*/
/**
* Editor stories import via `@app/*` (proprietary→core fallback), `@core/*` and
* `@proprietary/*`. Resolve them exactly the way the editor's own build does -
* through vite-tsconfig-paths against the proprietary vite tsconfig - so the
* shared Storybook can host editor components without duplicating the alias map
* here. Built per pass: the main bundle and the worker bundle each need their own.
*/
const editorPathAliases = () =>
tsconfigPaths({
projects: [resolve(__dirname, "../editor/tsconfig.proprietary.vite.json")],
});
const config: StorybookConfig = {
stories: [
"../editor/src/portal/**/*.mdx",
@@ -47,19 +59,15 @@ const config: StorybookConfig = {
// than a relative path.
"@public": resolve(__dirname, "../editor/public"),
};
// Editor stories import via @app/* (proprietary→core fallback), @core/* and
// @proprietary/*. Resolve them exactly the way the editor's own build does —
// through vite-tsconfig-paths against the proprietary vite tsconfig — so the
// shared Storybook can host editor components without duplicating the alias
// map here.
config.plugins = config.plugins ?? [];
config.plugins.push(
tsconfigPaths({
projects: [
resolve(__dirname, "../editor/tsconfig.proprietary.vite.json"),
],
}),
);
config.plugins.push(editorPathAliases());
// Worker bundles are a separate Rollup pass and do NOT inherit `plugins`, so
// without this a worker importing @app/* fails to resolve while the same
// import works everywhere else. Mirrors editor/vite.config.ts.
config.worker = {
...(config.worker ?? {}),
plugins: () => [editorPathAliases()],
};
// Point apiClient.saas at a mock origin so the SaaS-backed billing stories
// (SubscribedPlanView, PaymentMethodCard, InvoicesList) resolve a base URL and
// their MSW handlers (which match "*/api/v1/payg/...") can intercept. The host
+29 -1
View File
@@ -29,7 +29,16 @@ import { rtlLanguages, supportedLanguages } from "@core/i18n/languages";
import "@mantine/core/styles.css";
import "@core/tokens/tokens.css";
import "@core/theme/index.css";
// The editor's semantic token layer (--bg-surface, --onboarding-title, …).
// The app reaches it through its style entry; without it here, components
// styled on those variables render unthemed (e.g. transparent modal surfaces)
// and axe measures contrast against colours the app never shows.
import "@core/styles/theme.css";
import "@core/tokens/base.css";
// Portal element reset + typography. Scoped to .portal-scope in the app so it
// can't leak into the editor; the decorator below adds that class around
// portal stories only, mirroring how PortalApp mounts.
import "@portal/theme/base.css";
// Storybook-only: bundle every shipped locale's TOML at build time via a ?raw
// glob, so the toolbar language switcher can flip between all languages with no
@@ -201,6 +210,13 @@ const withProviders: Decorator = (Story, context) => {
// anything that isn't "dark" as light — matching the addon's own
// `selected || defaultTheme` fallback where defaultTheme is light.
const colorScheme = context.globals.theme === "dark" ? "dark" : "light";
// PortalApp mounts its views inside a .portal-scope wrapper, which is what
// the portal's base.css keys its reset/typography on. Give portal stories
// the same wrapper (and only them — the scoping exists precisely so portal
// styles never apply to editor components).
const isPortalStory = (context.parameters.fileName ?? "").includes(
"/portal/",
);
return (
<MemoryRouter initialEntries={["/"]}>
<QueryClientProvider client={queryClient}>
@@ -214,7 +230,13 @@ const withProviders: Decorator = (Story, context) => {
<TierKey tier={tier}>
<UIProvider>
<Suspense fallback={null}>
<Story />
{isPortalStory ? (
<div className="portal-scope">
<Story />
</div>
) : (
<Story />
)}
</Suspense>
</UIProvider>
</TierKey>
@@ -229,6 +251,12 @@ const withProviders: Decorator = (Story, context) => {
const preview: Preview = {
loaders: [mswLoader],
// The scan runs once per theme (SCAN_THEME=light|dark, forwarded by
// .storybook/vitest.config.ts); pinning the global here themes every story in
// the run. Unset — the Storybook UI — falls back to the toolbar default.
initialGlobals: {
theme: import.meta.env.VITE_SCAN_THEME === "dark" ? "dark" : "light",
},
parameters: {
layout: "padded",
controls: {
+8
View File
@@ -14,6 +14,14 @@ import { storybookTest } from "@storybook/addon-vitest/vitest-plugin";
* Run with: npx vitest run --config .storybook/vitest.config.ts
*/
export default defineConfig({
// Forwards the SCAN_THEME env var into the browser bundle, where preview.tsx
// uses it to pin the theme global for the whole run. The Storybook dev/build
// pipeline never sets it, so the toolbar default stays "light" there.
define: {
"import.meta.env.VITE_SCAN_THEME": JSON.stringify(
process.env.SCAN_THEME ?? "",
),
},
optimizeDeps: {
// Pre-scan every story + the preview so Vite discovers the story set's large
// dep surface (embedpdf plugins, @mui icons, …) in one pass up front.
+10 -5
View File
@@ -17,9 +17,12 @@ import { defineConfig, devices } from "@playwright/test";
*
* @see https://playwright.dev/docs/test-configuration
*/
/** Shared by every stubbed project so a spec sees one layout on all engines. */
const STUBBED_VIEWPORT = { width: 1920, height: 1080 };
const chromiumViewport = {
...devices["Desktop Chrome"],
viewport: { width: 1920, height: 1080 },
viewport: STUBBED_VIEWPORT,
};
export default defineConfig({
@@ -55,7 +58,8 @@ export default defineConfig({
},
projects: [
// Stubbed - no backend required, chromium-only for CI speed
// Stubbed - no backend required. The chromium arm of the cross-browser
// set below; CI fans all three out, one job per engine.
{
name: "stubbed",
testDir: "./src/core/tests/stubbed",
@@ -93,16 +97,17 @@ export default defineConfig({
},
},
// Cross-browser coverage for the stubbed suite (opt-in locally)
// Cross-browser coverage for the stubbed suite. Same viewport as `stubbed`,
// or a layout difference here reads as an engine outage.
{
name: "stubbed-firefox",
testDir: "./src/core/tests/stubbed",
use: { ...devices["Desktop Firefox"] },
use: { ...devices["Desktop Firefox"], viewport: STUBBED_VIEWPORT },
},
{
name: "stubbed-webkit",
testDir: "./src/core/tests/stubbed",
use: { ...devices["Desktop Safari"] },
use: { ...devices["Desktop Safari"], viewport: STUBBED_VIEWPORT },
},
],
@@ -3883,6 +3883,8 @@ addFiles = "Add files"
addingFiles = "Adding files…"
collapse = "Collapse sidebar"
customizeGroups = "Customize groups"
dataLostBody = "This browser lost this file's contents. Upload it again to keep working with it."
dataLostTitle = "File data is unavailable"
dropHint = "Open files to get started"
dropToAdd = "Drop files to add"
expand = "Expand sidebar"
@@ -3903,6 +3905,8 @@ viewAll = "View all {{count}} files"
[fileSidebar.fileItem]
closeViewer = "Close viewer"
dataLost = "Data lost"
dataLostTooltip = "This browser lost this file's contents. Upload it again to keep working with it."
delete = "Delete"
moreActions = "More actions"
openInViewer = "Open in viewer"
@@ -258,6 +258,8 @@ export type ToolArity = ${union(vocabulary.arities as string[])};
export interface ToolIOWhen {
param: string;
matches: string[];
/** The value the endpoint uses when this parameter is absent; omitted when it has none. */
default?: string;
}
/** An output that applies when every condition in \`when\` holds. */
+3 -1
View File
@@ -632,7 +632,9 @@ const CODE_EXEMPT_PATH = [
/\/onboarding\//,
/addStamp|addWatermark|\/tooltips\//,
/UpgradeBanner|AdminPlanSection/,
/\.test\.[jt]sx?$|\.stories\.[jt]sx?$|\/types\//,
// Stories are checked like app code; colour-as-data lines opt out with
// `theme-allow-color`.
/\.test\.[jt]sx?$|\/types\//,
];
const CODE_HEX =
/#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{4}|[0-9a-fA-F]{3})(?![0-9a-fA-F])/g;
@@ -13,6 +13,10 @@ import classicBlack from "@app/assets/brand/classic-logo/StirlingPDFLogoBlackTex
import classicWhite from "@app/assets/brand/classic-logo/StirlingPDFLogoWhiteText.svg";
import classicGrey from "@app/assets/brand/classic-logo/StirlingPDFLogoGreyText.svg";
// Fixed swatch so the light-on-dark mark variant previews on a dark
// surface in either theme.
const DARK_SWATCH = "#1a1a1a"; // theme-allow-color fixed preview swatch
type Asset = { label: string; src: string; onDark?: boolean };
type VariantSet = { variant: string; mark: Asset[]; wordmark: Asset[] };
@@ -55,15 +59,13 @@ function Swatch({ label, src, onDark, h }: Asset & { h: number }) {
padding: 16,
minWidth: 140,
borderRadius: 8,
border: "1px solid rgba(128,128,128,0.25)",
background: onDark ? "#1a1a1a" : "#ffffff",
border: "1px solid var(--c-border)",
background: onDark ? DARK_SWATCH : "#ffffff",
}}
>
<img src={src} alt={label} style={{ height: h, maxWidth: 200 }} />
</div>
<figcaption
style={{ fontSize: 12, color: "var(--c-text-subtle, #71717a)" }}
>
<figcaption style={{ fontSize: 12, color: "var(--c-text-subtle)" }}>
{label}
</figcaption>
</figure>
@@ -1,4 +1,5 @@
import React, { createContext, useContext, ReactNode } from "react";
import { SignParameters } from "@app/hooks/tools/sign/useSignParameters";
interface PDFAnnotationContextValue {
// Drawing mode management
@@ -22,8 +23,8 @@ interface PDFAnnotationContextValue {
isPlacementMode: boolean;
// Signature configuration
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
signatureConfig: SignParameters | null;
setSignatureConfig: (config: SignParameters | null) => void;
}
const PDFAnnotationContext = createContext<
@@ -43,8 +44,8 @@ interface PDFAnnotationProviderProps {
storeImageData: (id: string, data: string) => void;
getImageData: (id: string) => string | undefined;
isPlacementMode: boolean;
signatureConfig: any | null;
setSignatureConfig: (config: any | null) => void;
signatureConfig: SignParameters | null;
setSignatureConfig: (config: SignParameters | null) => void;
}
export const PDFAnnotationProvider: React.FC<PDFAnnotationProviderProps> = ({
@@ -14,9 +14,17 @@ export interface AnnotationToolConfig {
placeButtonText?: string;
}
interface InjectedAnnotationToolProps {
selectedColor: string;
signatureData: string | null;
onSignatureDataChange: (data: string | null) => void;
onColorSwatchClick: () => void;
disabled: boolean;
}
interface BaseAnnotationToolProps {
config: AnnotationToolConfig;
children: React.ReactNode;
children: React.ReactElement<Partial<InjectedAnnotationToolProps>>;
onSignatureDataChange?: (data: string | null) => void;
disabled?: boolean;
}
@@ -90,7 +98,7 @@ export const BaseAnnotationTool: React.FC<BaseAnnotationToolProps> = ({
/>
{/* Tool Content */}
{React.cloneElement(children as React.ReactElement<any>, {
{React.cloneElement(children, {
selectedColor,
signatureData,
onSignatureDataChange: handleSignatureDataChange,
@@ -300,6 +300,10 @@ const FileEditorThumbnail = ({
const [showVersionHistory, setShowVersionHistory] = useState(false);
const policyEnforcing = policies.some((p) => p.enforcing);
// The overlay swallows clicks, so a run that never settles would leave the card
// unusable with no way out. Dismissible, like the viewer's; resets per run.
const [enforcingDismissed, setEnforcingDismissed] = useState(false);
if (!policyEnforcing && enforcingDismissed) setEnforcingDismissed(false);
// The policy currently enforcing, so the overlay's icon/spinner match that
// policy's badge instead of a fixed blue.
const enforcingPolicy = policies.find((p) => p.enforcing);
@@ -548,8 +552,9 @@ const FileEditorThumbnail = ({
{/* Policy enforcement overlay — shown while any policy is in-flight */}
<PolicyEnforcingOverlay
enforcing={policyEnforcing}
enforcing={policyEnforcing && !enforcingDismissed}
zIndex={2}
onDismiss={() => setEnforcingDismissed(true)}
accentVar={enforcingPolicy?.accentColor}
categoryId={enforcingPolicy?.id}
/>
@@ -2,13 +2,13 @@ import type { Meta, StoryObj } from "@storybook/react-vite";
import { fn } from "storybook/test";
import { FolderAppearancePicker } from "@app/components/filesPage/FolderAppearancePicker";
import { FolderRecord } from "@app/types/folder";
import { FOLDER_COLOR_PALETTE, FolderRecord } from "@app/types/folder";
const folder: FolderRecord = {
id: "folder-1" as FolderRecord["id"],
name: "Contracts",
parentFolderId: null,
color: "#3b82f6",
color: FOLDER_COLOR_PALETTE[0],
icon: "star",
createdAt: Date.now(),
updatedAt: Date.now(),
@@ -1,5 +1,6 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { FolderThumbnail } from "@app/components/filesPage/FolderThumbnail";
import { FOLDER_COLOR_PALETTE } from "@app/types/folder";
const meta = {
title: "FilesPage/FolderThumbnail",
@@ -10,14 +11,14 @@ type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
color: "#6366f1",
color: FOLDER_COLOR_PALETTE[4],
fileCount: 12,
},
};
export const RowSize: Story = {
args: {
color: "#22c55e",
color: FOLDER_COLOR_PALETTE[1],
fileCount: 3,
size: "row",
},
@@ -25,7 +26,7 @@ export const RowSize: Story = {
export const WithIconGlyph: Story = {
args: {
color: "#f97316",
color: FOLDER_COLOR_PALETTE[7],
fileCount: 5,
iconGlyph: "📄",
},
@@ -1,6 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { MoveToFolderDialog } from "@app/components/filesPage/MoveToFolderDialog";
import { createFolderId, FolderRecord } from "@app/types/folder";
import {
createFolderId,
FOLDER_COLOR_PALETTE,
FolderRecord,
} from "@app/types/folder";
const workId = createFolderId();
const invoicesId = createFolderId();
@@ -11,7 +15,7 @@ const folders: FolderRecord[] = [
id: workId,
name: "Work",
parentFolderId: null,
color: "#3b82f6",
color: FOLDER_COLOR_PALETTE[0],
createdAt: Date.now(),
updatedAt: Date.now(),
},
@@ -19,7 +23,7 @@ const folders: FolderRecord[] = [
id: invoicesId,
name: "Invoices",
parentFolderId: workId,
color: "#10b981",
color: FOLDER_COLOR_PALETTE[1],
createdAt: Date.now(),
updatedAt: Date.now(),
},
@@ -27,7 +31,7 @@ const folders: FolderRecord[] = [
id: archivedId,
name: "Archived",
parentFolderId: null,
color: "#f59e0b",
color: FOLDER_COLOR_PALETTE[2],
createdAt: Date.now(),
updatedAt: Date.now(),
},
@@ -1,5 +1,5 @@
import { useEffect, useState, Suspense, lazy } from "react";
import { Box, Loader, Center } from "@mantine/core";
import { Box, Loader, Center, Stack, Text } from "@mantine/core";
import { useToolWorkflow } from "@app/contexts/ToolWorkflowContext";
import { useFileHandler } from "@app/hooks/useFileHandler";
import { useAllFiles } from "@app/contexts/FileContext";
@@ -12,6 +12,7 @@ import { VIEWER_SUPPORTED_EXTENSIONS } from "@app/utils/fileUtils";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useSigningOverlay } from "@app/contexts/SigningOverlayContext";
import { useCookieConsent } from "@app/hooks/useCookieConsent";
import { useTranslation } from "react-i18next";
import styles from "@app/components/layout/Workbench.module.css";
import WorkbenchBar from "@app/components/shared/WorkbenchBar";
@@ -34,6 +35,7 @@ const FileManagerView = lazy(
// No props needed - component uses contexts directly
export default function Workbench() {
const { t } = useTranslation();
const { config } = useAppConfig();
// The consent banner used to be initialised by the footer; the legal links
@@ -41,7 +43,7 @@ export default function Workbench() {
useCookieConsent({ analyticsEnabled: config?.enableAnalytics === true });
// Use context-based hooks to eliminate all prop drilling
const { files: activeFiles } = useAllFiles();
const { files: activeFiles, fileIds } = useAllFiles();
const { workbench: currentView } = useNavigationState();
const { actions: navActions } = useNavigationActions();
const setCurrentView = navActions.setWorkbench;
@@ -136,6 +138,20 @@ export default function Workbench() {
}
if (activeFiles.length === 0) {
// Files are open but their bytes are still loading (a cold PDF engine can
// take seconds). Showing the drop zone here reads as "the click did nothing".
if (fileIds.length > 0) {
return (
<Center h="100%" w="100%">
<Stack align="center" gap="md">
<Loader size="lg" />
<Text c="dimmed" size="sm">
{t("fileManager.loadingFiles", "Loading files...")}
</Text>
</Stack>
</Center>
);
}
return <LandingPage />;
}
@@ -1,9 +1,5 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
// The shared preview only loads the portal tokens; the onboarding modal reads
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
// here or the modal surface renders transparent over the dark overlay.
import "@app/styles/theme.css";
import OnboardingModalSlide from "@app/components/onboarding/OnboardingModalSlide";
import {
SLIDE_DEFINITIONS,
@@ -1,8 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
// The shared preview only loads the portal tokens; the shell reads the editor
// theme tokens (--bg-surface, --onboarding-title, …), so load them here or the
// card renders transparent over the dark overlay.
import "@app/styles/theme.css";
import OnboardingSlideShell, {
ShellHero,
type ShellButton,
@@ -1,8 +1,4 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
// The shared preview only loads the portal tokens; the onboarding modal reads
// the editor theme tokens (--bg-surface, --onboarding-title, …), so load them
// here or the modal surface renders transparent over the dark overlay.
import "@app/styles/theme.css";
import StaticOnboardingSlide from "@app/components/onboarding/StaticOnboardingSlide";
import { DEFAULT_RUNTIME_STATE } from "@app/components/onboarding/orchestrator/onboardingConfig";
@@ -1,5 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
import DragDropGrid, {
type DragHandleProps,
} from "@app/components/pageEditor/DragDropGrid";
interface MockGridItem {
id: string;
@@ -22,7 +24,7 @@ const renderItem = (
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
dragHandleProps?: DragHandleProps,
zoomLevel?: number,
) => {
const { ref: dndRef, ...restDragProps } = dragHandleProps ?? {};
@@ -20,6 +20,7 @@ import {
DragEndEvent,
DragStartEvent,
DragOverlay,
DraggableAttributes,
useSensor,
useSensors,
PointerSensor,
@@ -28,6 +29,10 @@ import {
useDroppable,
} from "@dnd-kit/core";
export type DragHandleProps = DraggableAttributes & {
ref: React.RefCallback<HTMLElement>;
} & Record<string, unknown>;
interface DragDropItem {
id: string;
splitAfter?: boolean;
@@ -51,7 +56,7 @@ interface DragDropGridProps<T extends DragDropItem> {
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
dragHandleProps?: DragHandleProps,
zoomLevel?: number,
) => React.ReactNode;
getThumbnailData?: (
@@ -232,7 +237,7 @@ interface DraggableItemProps<T extends DragDropItem> {
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
dragHandleProps?: DragHandleProps,
zoomLevel?: number,
) => React.ReactNode;
zoomLevel: number;
@@ -253,7 +258,7 @@ const DraggableItemInner = <T extends DragDropItem>({
zoomLevel,
}: DraggableItemProps<T>) => {
const isPlaceholder = Boolean(item.isPlaceholder);
const pageNumber = (item as any).pageNumber ?? index + 1;
const pageNumber = item.pageNumber ?? index + 1;
const {
attributes,
listeners,
@@ -11,7 +11,9 @@ import { PageEditorFunctions, PDFPage } from "@app/types/pageEditor";
// Thumbnail generation is now handled by individual PageThumbnail components
import "@app/components/pageEditor/PageEditor.module.css";
import PageThumbnail from "@app/components/pageEditor/PageThumbnail";
import DragDropGrid from "@app/components/pageEditor/DragDropGrid";
import DragDropGrid, {
type DragHandleProps,
} from "@app/components/pageEditor/DragDropGrid";
import SkeletonLoader from "@app/components/shared/SkeletonLoader";
import { FileId } from "@app/types/file";
import { GRID_CONSTANTS } from "@app/components/pageEditor/constants";
@@ -33,6 +35,13 @@ export interface PageEditorProps {
onFunctionsReady?: (functions: PageEditorFunctions) => void;
}
interface PageEditorFileEntry {
fileId: FileId;
name: string;
versionNumber: number | undefined;
isSelected: boolean;
}
const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const { t } = useTranslation();
// Use split contexts to prevent re-renders
@@ -106,14 +115,14 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
const selectedIdsKey = [...state.ui.selectedFileIds].sort().join(",");
const filesSignature = selectors.getFilesSignature();
const fileObjectsRef = useRef(new Map<FileId, any>());
const fileObjectsRef = useRef(new Map<FileId, PageEditorFileEntry>());
const gridItemRefsRef = useRef<React.MutableRefObject<
Map<string, HTMLDivElement>
> | null>(null);
const pageEditorFiles = useMemo(() => {
const cache = fileObjectsRef.current;
const newFiles: any[] = [];
const newFiles: PageEditorFileEntry[] = [];
fileOrder.forEach((fileId) => {
const stub = selectors.getStirlingFileStub(fileId);
@@ -605,7 +614,7 @@ const PageEditor = ({ onFunctionsReady }: PageEditorProps) => {
clearBoxSelection: () => void,
activeDragIds: string[],
justMoved: boolean,
dragHandleProps?: any,
dragHandleProps?: DragHandleProps,
zoomLevelParam?: number,
) => {
gridItemRefsRef.current = refs;
@@ -17,6 +17,7 @@ import AddIcon from "@mui/icons-material/Add";
import { PDFPage, PDFDocument } from "@app/types/pageEditor";
import { useFilesModalContext } from "@app/contexts/FilesModalContext";
import { getFileColorWithOpacity } from "@app/components/pageEditor/fileColors";
import { type DragHandleProps } from "@app/components/pageEditor/DragDropGrid";
import styles from "@app/components/pageEditor/PageEditor.module.css";
import HoverActionMenu, {
HoverAction,
@@ -38,7 +39,7 @@ interface PageThumbnailProps {
activeDragIds: string[];
justMoved?: boolean;
pageRefs: React.MutableRefObject<Map<string, HTMLDivElement>>;
dragHandleProps?: any;
dragHandleProps?: DragHandleProps;
onReorderPages: (
sourcePageNumber: number,
targetIndex: number,
@@ -31,5 +31,5 @@ export const AllVariants: Story = {
/** Explicit `color` overrides the variant's default accent. */
export const CustomColor: Story = {
args: { variant: "pdf", color: "#e64980" },
args: { variant: "pdf", color: "#e64980" }, // theme-allow-color demoes the colour override
};
@@ -201,13 +201,27 @@ export function FileSelectorPicker({
setSortDir(lsGet(LS_SORT_DIR, "desc", ["asc", "desc"]));
}, [isOpen]);
// Load saved files when the saved tab is active and the picker is open
// Load saved files when the saved tab is active and the picker is open.
// Cancellable: storage reads can be slow or reject, and outlive the popover.
useEffect(() => {
if (activeTab !== "saved" || !isOpen) return;
let cancelled = false;
setSavedLoading(true);
loadRecentFiles()
.then(setSavedStubs)
.finally(() => setSavedLoading(false));
.then((stubs) => {
if (!cancelled) setSavedStubs(stubs);
})
.catch((error: unknown) => {
if (!cancelled) setSavedStubs([]);
console.warn("Failed to load saved files for the picker:", error);
})
.finally(() => {
if (!cancelled) setSavedLoading(false);
});
return () => {
cancelled = true;
setSavedLoading(false);
};
}, [activeTab, isOpen, loadRecentFiles]);
const workbenchIdSet = useMemo(
@@ -541,7 +555,9 @@ export function FileSelectorPicker({
</div>
<ScrollArea h={260} className={styles.list}>
{savedLoading ? (
{/* Workbench renders from memory, so a stuck storage read must not
spin it too. */}
{savedLoading && activeTab === "saved" ? (
<div className={styles.emptyState}>
<Loader size="sm" />
</div>
@@ -61,7 +61,8 @@ import {
deleteServerFile,
type DeleteScope,
} from "@app/services/serverStorageDelete";
import { fileStorage } from "@app/services/fileStorage";
import { fileStorage, onRecordUnreadable } from "@app/services/fileStorage";
import { alert } from "@app/components/toast";
import { useBulkAddProgress } from "@app/services/bulkAddProgress";
import { useFolderMembership } from "@app/hooks/useFolderMembership";
import { useAllWatchedFolders } from "@app/hooks/useAllWatchedFolders";
@@ -288,6 +289,19 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
// Leaf files = user-visible files (excludes intermediate tool outputs)
const [allFileStubs, setAllFileStubs] = useState<StirlingFileStub[]>([]);
// Files whose stored bytes this session PROVED unreadable. Rows render a
// "data lost" state instead of pretending the file can open; storage keeps
// the record so a reload re-tests it.
const [lostFileIds, setLostFileIds] = useState<ReadonlySet<string>>(
() => new Set(),
);
useEffect(
() =>
onRecordUnreadable((fileId) =>
setLostFileIds((prev) => new Set(prev).add(fileId as string)),
),
[],
);
const [stubsLoaded, setStubsLoaded] = useState(false);
// Kebab "Save to cloud" target; drives BulkUploadToServerModal.
const [saveToServerTarget, setSaveToServerTarget] = useState<
@@ -306,32 +320,45 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const storageEnabled = config?.storageEnabled === true && !isAnonymous;
const refreshStubs = useCallback(async () => {
// Leaf files from IDB - same source as the file selection modal.
const stubs = await indexedDB.loadLeafMetadata();
const idbIds = new Set(stubs.map((s) => s.id as string));
// `stubsLoaded` gates the spinner, so the `finally` below must set it on
// every path - callers never await this, so a rejection goes nowhere.
let stubs: StirlingFileStub[] = [];
try {
// Leaf files from IDB - same source as the file selection modal.
stubs = await indexedDB.loadLeafMetadata();
} catch (error) {
// Carry on with the in-memory workbench files: an unreadable library
// should cost the user their history, not the file they're working on.
console.error("Failed to read the file library from storage:", error);
}
// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
try {
const idbIds = new Set(stubs.map((s) => s.id as string));
// Also include workbench files not yet flushed to IDB.
const pendingStubs = state.files.ids
.map((id) => state.files.byId[id])
.filter(
(stub): stub is NonNullable<typeof stub> =>
!!stub && stub.isLeaf !== false && !idbIds.has(stub.id as string),
);
const allStubs = [...stubs, ...pendingStubs];
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
const superseded = new Set(
allStubs.map((s) => s.parentFileId as string | undefined),
);
const allStubs = [...stubs, ...pendingStubs];
// A version swap briefly lists both the old leaf (IDB) and its replacement (workbench); two stubs for one lineage collide on the row key and corrupt React reconciliation, so drop any stub another names as its parent.
const superseded = new Set(
allStubs.map((s) => s.parentFileId as string | undefined),
);
const currentStubs = allStubs.filter(
(s) => !superseded.has(s.id as string),
);
setAllFileStubs(
currentStubs.sort(
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
),
);
setStubsLoaded(true);
const currentStubs = allStubs.filter(
(s) => !superseded.has(s.id as string),
);
setAllFileStubs(
currentStubs.sort(
(a, b) => (b.lastModified ?? 0) - (a.lastModified ?? 0),
),
);
} finally {
setStubsLoaded(true);
}
}, [indexedDB, state.files.ids, state.files.byId]);
// Refresh on mount, workbench changes, or external IndexedDB writes —
@@ -370,7 +397,9 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
setDeleteTarget(stub);
return;
}
await fileActions.removeFiles([fileId], true);
// Its superseded versions go too - see orphanedAncestorIds.
const orphans = await fileStorage.orphanedAncestorIds([fileId]);
await fileActions.removeFiles([fileId, ...orphans], true);
await refreshStubs();
},
[allFileStubs, fileActions, refreshStubs],
@@ -388,7 +417,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
await deleteServerFile(stub.remoteStorageId);
}
if (scope === "device" || scope === "everywhere") {
await fileActions.removeFiles([stub.id], true);
const orphans = await fileStorage.orphanedAncestorIds([stub.id]);
await fileActions.removeFiles([stub.id, ...orphans], true);
} else if (scope === "cloud") {
// Local copy kept - drop the dead remote pointer so the cloud badge
// clears (the sidebar doesn't reconcile with the server itself).
@@ -524,6 +554,22 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
const stub = allFileStubs.find((s) => s.id === fileId);
if (!stub) return;
// Its bytes are gone; opening it can only fail. Say so instead of a
// click that goes nowhere.
if (stub.dataUnavailable || lostFileIds.has(fileId as string)) {
alert({
alertType: "warning",
title: t("fileSidebar.dataLostTitle", "File data is unavailable"),
body: t(
"fileSidebar.dataLostBody",
"This browser lost this file's contents. Upload it again to keep working with it.",
),
expandable: false,
durationMs: 6000,
});
return;
}
// In the Watched Folders view a click sends the file into the open folder
// (mirrors how a click toggles a file into the active workbench elsewhere).
// On the folder list (no folder open) it's a no-op so browsing isn't disrupted.
@@ -578,6 +624,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
},
[
allFileStubs,
lostFileIds,
t,
state.files.ids,
state.ui.selectedFileIds,
fileActions,
@@ -765,6 +813,8 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
? state.files.byId[workbenchFileId]?.thumbnailUrl
: undefined) || stub.thumbnailUrl;
const fileOrigin = getFileOrigin(stub);
const dataUnavailable =
stub.dataUnavailable === true || lostFileIds.has(stub.id as string);
// Key by lineage (originalFileId) so a version swap updates the row in place instead of
// remounting. But a 1-input→many-output op (split) yields sibling leaves that share one
// originalFileId; those would collide on the key, so fall back to the unique leaf id when a
@@ -787,6 +837,7 @@ const FileSidebar = forwardRef<HTMLDivElement, FileSidebarProps>(
thumbnailUrl={thumbnailUrl}
onClick={handleFileClick}
onEyeClick={handleEyeClick}
dataUnavailable={dataUnavailable}
draggable={isWatchedFoldersActive}
onDragStart={handleWatchedFolderDragStart}
folders={memberFolders}
@@ -447,3 +447,13 @@
transform: translateY(-50%) scale(1);
}
}
/* The stored bytes are gone - the row says so instead of pretending to open. */
.file-sidebar-datalost-badge {
display: inline-flex;
align-items: center;
gap: 0.15rem;
color: var(--c-danger);
font-size: 0.7rem;
white-space: nowrap;
}
@@ -9,6 +9,7 @@ import ShieldOutlinedIcon from "@mui/icons-material/ShieldOutlined";
import MoreVertIcon from "@mui/icons-material/MoreVert";
import CloudUploadOutlinedIcon from "@mui/icons-material/CloudUploadOutlined";
import CloudDoneIcon from "@mui/icons-material/CloudDone";
import ErrorOutlineIcon from "@mui/icons-material/ErrorOutlineOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutlined";
import HistoryIcon from "@mui/icons-material/History";
import type { FileId } from "@app/types/file";
@@ -163,6 +164,9 @@ export interface FileItemProps {
onVersionHistory?: (fileId: FileId) => void;
/** Whether this file has more than one version (drives the menu item). */
hasVersionHistory?: boolean;
/** The stored bytes are gone (WebKit lost the blob's backing store). The row
* says so instead of pretending the file can open. */
dataUnavailable?: boolean;
}
const MAX_VISIBLE_FOLDER_TAGS = 2;
@@ -177,6 +181,7 @@ export const FileItem = React.memo(function FileItem({
isSelected,
isActive,
isViewedInViewer,
dataUnavailable,
thumbnailUrl,
onClick,
onEyeClick,
@@ -294,6 +299,21 @@ export const FileItem = React.memo(function FileItem({
</>
)}
</span>
{dataUnavailable && (
<Tooltip
label={t(
"fileSidebar.fileItem.dataLostTooltip",
"This browser lost this file's contents. Upload it again to keep working with it.",
)}
withArrow
position="top"
>
<span className="file-sidebar-datalost-badge" data-no-select>
<ErrorOutlineIcon sx={{ fontSize: "0.85rem" }} />
{t("fileSidebar.fileItem.dataLost", "Data lost")}
</span>
</Tooltip>
)}
{isUploadedToCloud && (
<Tooltip
label={t(
@@ -6,6 +6,9 @@ export function PolicyEnforcingOverlay(_props: {
accentVar?: string;
/** Category of the enforcing policy — picks its icon in the real overlay. */
categoryId?: string;
/** Shows a dismiss control in the real overlay, so a run that never settles
* can't leave the surface underneath permanently unclickable. */
onDismiss?: () => void;
}) {
return null;
}
@@ -58,8 +58,8 @@ const FullscreenToolList = ({
);
const recommendedItems = useMemo(() => {
if (!quickSection)
return [] as Array<{ id: string; tool: ToolRegistryEntry }>;
const items: Array<{ id: string; tool: ToolRegistryEntry }> = [];
return [] as Array<{ id: ToolId; tool: ToolRegistryEntry }>;
const items: Array<{ id: ToolId; tool: ToolRegistryEntry }> = [];
quickSection.subcategories.forEach((sc) =>
sc.tools.forEach((t) => items.push(t)),
);
@@ -217,13 +217,13 @@ const FullscreenToolList = ({
</header>
{showDescriptions ? (
<div className="tool-panel__fullscreen-grid tool-panel__fullscreen-grid--detailed">
{recommendedItems.map((item: any) =>
{recommendedItems.map((item) =>
renderToolItem(item.id, item.tool),
)}
</div>
) : (
<div className="tool-panel__fullscreen-list">
{recommendedItems.map((item: any) =>
{recommendedItems.map((item) =>
renderToolItem(item.id, item.tool),
)}
</div>
@@ -34,13 +34,17 @@ export default function ToolSelector({
const [shouldAutoFocus, setShouldAutoFocus] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Filter out excluded tools (like 'automate' itself) and tools that don't support automation
// Filter out excluded tools (like 'automate' itself), tools that don't support
// automation, and tools with no operationConfig - the executor resolves a step
// through operationConfig, so offering one without it fails only at run time.
const baseFilteredTools = useMemo(() => {
return (
Object.entries(toolRegistry) as [ToolId, ToolRegistryEntry][]
).filter(
([key, tool]) =>
!excludeTools.includes(key) && getToolSupportsAutomate(tool),
!excludeTools.includes(key) &&
getToolSupportsAutomate(tool) &&
Boolean(tool.operationConfig),
);
}, [toolRegistry, excludeTools]);
@@ -5,7 +5,10 @@ import FileUploadButton from "@app/components/shared/FileUploadButton";
interface CertificateFilesSettingsProps {
parameters: CertSignParameters;
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
onParameterChange: <K extends keyof CertSignParameters>(
key: K,
value: CertSignParameters[K],
) => void;
disabled?: boolean;
}
@@ -4,7 +4,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet
interface CertificateFormatSettingsProps {
parameters: CertSignParameters;
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
onParameterChange: <K extends keyof CertSignParameters>(
key: K,
value: CertSignParameters[K],
) => void;
disabled?: boolean;
}
@@ -7,7 +7,10 @@ import { useAppConfig } from "@app/contexts/AppConfigContext";
interface CertificateTypeSettingsProps {
parameters: CertSignParameters;
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
onParameterChange: <K extends keyof CertSignParameters>(
key: K,
value: CertSignParameters[K],
) => void;
disabled?: boolean;
}
@@ -22,7 +22,10 @@ import {
interface HardwareCertificateSettingsProps {
parameters: CertSignParameters;
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
onParameterChange: <K extends keyof CertSignParameters>(
key: K,
value: CertSignParameters[K],
) => void;
disabled?: boolean;
}
@@ -176,16 +179,20 @@ const HardwareCertificateSettings = ({
setError(null);
listWindowsCertificates()
.then(applyCerts)
.catch((e: any) =>
.catch((e) => {
const err = e as {
response?: { data?: { message?: string } };
message?: string;
};
setError(
e?.response?.data?.message ||
e?.message ||
err?.response?.data?.message ||
err?.message ||
t(
"certSign.hardware.windowsLoadError",
"Could not read the Windows certificate store",
),
),
)
);
})
.finally(() => setLoading(false));
}, [applyCerts, t]);
@@ -221,16 +228,20 @@ const HardwareCertificateSettings = ({
pin: parameters.password,
})
.then(applyCerts)
.catch((e: any) =>
.catch((e) => {
const err = e as {
response?: { data?: { message?: string } };
message?: string;
};
setError(
e?.response?.data?.message ||
e?.message ||
err?.response?.data?.message ||
err?.message ||
t(
"certSign.hardware.pkcs11LoadError",
"Could not read certificates from the token. Check the PIN and driver.",
),
),
)
);
})
.finally(() => setLoading(false));
}, [
applyCerts,
@@ -5,7 +5,10 @@ import { CertSignParameters } from "@app/hooks/tools/certSign/useCertSignParamet
interface SignatureAppearanceSettingsProps {
parameters: CertSignParameters;
onParameterChange: (key: keyof CertSignParameters, value: any) => void;
onParameterChange: <K extends keyof CertSignParameters>(
key: K,
value: CertSignParameters[K],
) => void;
disabled?: boolean;
}
@@ -101,7 +104,12 @@ const SignatureAppearanceSettings = ({
<NumberInput
label={t("certSign.pageNumber", "Page Number")}
value={parameters.pageNumber}
onChange={(value) => onParameterChange("pageNumber", value || 1)}
onChange={(value) =>
onParameterChange(
"pageNumber",
typeof value === "number" ? value : 1,
)
}
min={1}
disabled={disabled}
/>
@@ -24,7 +24,10 @@ const SignatureSettingsInput = ({
}: SignatureSettingsInputProps) => {
const { t } = useTranslation();
const handleChange = (key: keyof SignatureSettings, val: any) => {
const handleChange = <K extends keyof SignatureSettings>(
key: K,
val: SignatureSettings[K],
) => {
onChange({ ...value, [key]: val });
};
@@ -104,7 +107,9 @@ const SignatureSettingsInput = ({
<NumberInput
label={t("certSign.pageNumber", "Page Number")}
value={value.pageNumber || 1}
onChange={(val) => handleChange("pageNumber", val || 1)}
onChange={(val) =>
handleChange("pageNumber", typeof val === "number" ? val : 1)
}
min={1}
disabled={disabled}
size="xs"
@@ -52,6 +52,14 @@ import {
const MAX_RENDER_WIDTH = 820;
const MIN_BOX_SIZE = 18;
// Firefox-only fallback for document.caretRangeFromPoint (not in lib.dom.d.ts).
const docWithCaret = document as Document & {
caretPositionFromPoint?: (
x: number,
y: number,
) => { offsetNode: Node; offset: number } | null;
};
const normalizeFontFormat = (format?: string | null): string => {
if (!format) {
return "ttf";
@@ -352,7 +360,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
new Map(),
);
const draggingImageRef = useRef<string | null>(null);
const rndRefs = useRef<Map<string, any>>(new Map());
const rndRefs = useRef<Map<string, Rnd>>(new Map());
const pendingDragUpdateRef = useRef<number | null>(null);
const [fontFamilies, setFontFamilies] = useState<Map<string, string>>(
new Map(),
@@ -1378,7 +1386,7 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
const cssTop = (pageHeight - bounds.top) * scale;
// Get current position from Rnd component
const currentState = rndRef.state || {};
const currentState = (rndRef.state as { x?: number; y?: number }) || {};
const currentX = currentState.x ?? 0;
const currentY = currentState.y ?? 0;
@@ -2851,11 +2859,13 @@ const PdfTextEditorView = ({ data }: PdfTextEditorViewProps) => {
}
}
} else if (
(document as any).caretPositionFromPoint
docWithCaret.caretPositionFromPoint
) {
const pos = (
document as any
).caretPositionFromPoint(clickX, clickY);
const pos =
docWithCaret.caretPositionFromPoint(
clickX,
clickY,
);
if (pos) {
const range = document.createRange();
range.setStart(
@@ -12,7 +12,7 @@ const mockSignatures: SavedSignature[] = [
signerName: "Jordan Lee",
fontFamily: "cursive",
fontSize: 32,
textColor: "#1a1a1a",
textColor: "#1a1a1a", // theme-allow-color signature ink is user data
createdAt: Date.now(),
updatedAt: Date.now(),
},
@@ -58,6 +58,7 @@ import {
IndexedDBProvider,
useIndexedDB,
} from "@app/contexts/IndexedDBContext";
import { onRecordUnreadable } from "@app/services/fileStorage";
import { useZipConfirmation } from "@app/hooks/useZipConfirmation";
import ZipWarningModal from "@app/components/shared/ZipWarningModal";
import EncryptedPdfUnlockModal from "@app/components/shared/EncryptedPdfUnlockModal";
@@ -186,6 +187,21 @@ function FileContextInner({
setUnlockError(null);
}, [activeEncryptedFileId]);
// Storage proved a file's bytes unreadable (WebKit losing a blob's backing
// store). Drop it: the viewer would otherwise spin on a document that can
// never load. The record stays, so a reload re-tests it.
useEffect(
() =>
onRecordUnreadable((fileId) => {
if (!stateRef.current.files.byId[fileId]) return;
console.error(
`[FileContext] dropping ${fileId} from the workbench: its stored bytes are unreadable`,
);
lifecycleManager.removeFiles([fileId], stateRef);
}),
[lifecycleManager],
);
const handleUnlockSkip = useCallback(() => {
if (activeEncryptedFileId) {
dismissedEncryptedFilesRef.current.add(activeEncryptedFileId);
@@ -455,7 +455,10 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
})
.map((s) => s.id);
if (localIds.length > 0) {
await fileActions.removeFiles(localIds, true);
// Take the superseded versions with it, or their bytes sit in storage
// forever - invisible, because listings only show leaves.
const orphans = await fileStorage.orphanedAncestorIds(localIds);
await fileActions.removeFiles([...localIds, ...orphans], true);
}
}
@@ -222,3 +222,40 @@ describe("fileContextReducer — silent CONSUME_FILES (background enforcement)",
expect(next.ui.selectedFileIds).toEqual(["b2"]);
});
});
describe("fileContextReducer — REMOVE_FILES", () => {
/** Deleting from the library dispatches this for files that were never in the
* workbench; reallocating then re-renders every consumer for nothing. */
it("is a true no-op when none of the ids are in the workbench", () => {
const state = stateWith([stub("a")]);
const next = fileContextReducer(state, {
type: "REMOVE_FILES",
payload: { fileIds: ["gone" as FileId] },
});
expect(next).toBe(state);
});
it("still removes the ids it does hold", () => {
const state = stateWith([stub("a"), stub("b")]);
const next = fileContextReducer(state, {
type: "REMOVE_FILES",
payload: { fileIds: ["a" as FileId, "gone" as FileId] },
});
expect(next.files.ids).toEqual(["b"]);
expect(next.files.byId["a" as FileId]).toBeUndefined();
});
it("keeps the files slice when only a selection is cleared", () => {
const base = stateWith([stub("a")]);
const state: FileContextState = {
...base,
ui: { ...base.ui, selectedFileIds: ["gone" as FileId] },
};
const next = fileContextReducer(state, {
type: "REMOVE_FILES",
payload: { fileIds: ["gone" as FileId] },
});
expect(next.files).toBe(state.files);
expect(next.ui.selectedFileIds).toEqual([]);
});
});
@@ -183,6 +183,20 @@ export function fileContextReducer(
const remainingIds = state.files.ids.filter(
(id) => !fileIds.includes(id),
);
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(
(id) => !fileIds.includes(id),
);
// Deleting a library file that was never in the workbench removes nothing
// here, and must not re-render every file and UI consumer.
const removedFromWorkbench =
remainingIds.length !== state.files.ids.length ||
fileIds.some((id) => id in state.files.byId);
const deselected =
validSelectedFileIds.length !== state.ui.selectedFileIds.length;
if (!removedFromWorkbench && !deselected) return state;
const newById = { ...state.files.byId };
// Remove files from state (resource cleanup handled by lifecycle manager)
@@ -190,21 +204,14 @@ export function fileContextReducer(
delete newById[id];
});
// Clear selections that reference removed files
const validSelectedFileIds = state.ui.selectedFileIds.filter(
(id) => !fileIds.includes(id),
);
return {
...state,
files: {
ids: remainingIds,
byId: newById,
},
ui: {
...state.ui,
selectedFileIds: validSelectedFileIds,
},
files: removedFromWorkbench
? { ids: remainingIds, byId: newById }
: state.files,
ui: deselected
? { ...state.ui, selectedFileIds: validSelectedFileIds }
: state.ui,
};
}
@@ -26,6 +26,9 @@ import {
clearBulkAddProgress,
} from "@app/services/bulkAddProgress";
const DEBUG = process.env.NODE_ENV === "development";
/** How long a file may sit unhydrated before the console says so. Reporting only:
* the read is never abandoned, because large files legitimately take time. */
const STALLED_LOAD_MS = 8000;
const HYDRATION_CONCURRENCY = 2;
let activeHydrations = 0;
const hydrationQueue: Array<() => Promise<void>> = [];
@@ -447,10 +450,8 @@ export async function addFiles(
if (file.type === "application/pdf") {
try {
if (await FileAnalyzer.isPDFUserPasswordProtected(file)) {
fileStub.processedFile = (fileStub.processedFile || {
pages: [],
}) as any;
fileStub.processedFile!.isEncrypted = true;
fileStub.processedFile = fileStub.processedFile || { pages: [] };
fileStub.processedFile.isEncrypted = true;
}
} catch (error) {
// Never block upload on analysis failure — but log so it's debuggable
@@ -699,7 +700,7 @@ export async function undoConsumeFiles(
file: File,
fileId: FileId,
existingThumbnail?: string,
) => Promise<any>;
) => Promise<StirlingFileStub>;
deleteFile: (fileId: FileId) => Promise<void>;
bumpRevision?: () => void;
} | null,
@@ -856,61 +857,78 @@ export async function addStirlingFileStubs(
// Load File object and hydrate metadata in background (non-blocking)
const fileId = stub.id;
// Load File object from IndexedDB asynchronously
scheduleMetadataHydration(async () => {
const stirlingFile = await fileStorage.getStirlingFile(fileId);
// Regenerate page metadata + thumbnails. Queued, because parsing several
// PDFs at once is what the concurrency limit exists to bound.
const scheduleMetadataFor = (stirlingFile: StirlingFile): void => {
scheduleMetadataHydration(async () => {
const processedFileMetadata =
await generateProcessedFileMetadata(stirlingFile);
if (!processedFileMetadata) return;
const updates: Partial<StirlingFileStub> = {
processedFile: processedFileMetadata,
};
// Update thumbnail only if current stub doesn't have one
const currentStub = stateRef.current.files.byId[fileId];
if (
!currentStub?.thumbnailUrl &&
processedFileMetadata.thumbnailUrl
) {
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
lifecycleManager.trackBlobUrl(processedFileMetadata.thumbnailUrl);
}
}
lifecycleManager.updateStirlingFileStub(fileId, updates, stateRef);
});
};
// Load and publish the File, ahead of any parsing. NOT queued: whether a
// file opens at all must not wait on other files' parses.
void (async () => {
// A storage read that never settles renders as a file that silently won't
// open. Name it in the console rather than leaving the user guessing.
const stall = setTimeout(
() =>
console.error(
`[Hydration] ${stub.name} (${fileId}) has been loading for ${STALLED_LOAD_MS / 1000}s - the IndexedDB read has not settled`,
),
STALLED_LOAD_MS,
);
const stirlingFile = await fileStorage
.getStirlingFile(fileId)
.finally(() => clearTimeout(stall));
if (!stirlingFile) {
// A row with no bytes renders empty and its clicks look dead, so take it
// back out. Storage keeps the record; fileStorage has said why.
console.error(
`[Hydration] No readable data for ${stub.name} (${fileId}); removing it from the workbench`,
);
lifecycleManager.removeFiles([fileId], stateRef);
return;
}
// Store the loaded file in filesRef
filesRef.current.set(fileId, stirlingFile);
// Check if processedFile data needs regeneration
if (stirlingFile.type.startsWith("application/pdf")) {
const needsProcessing =
!stub.processedFile ||
!stub.processedFile.pages ||
stub.processedFile.pages.length === 0 ||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
if (needsProcessing) {
// Regenerate metadata
const processedFileMetadata =
await generateProcessedFileMetadata(stirlingFile);
if (processedFileMetadata) {
const updates: Partial<StirlingFileStub> = {
processedFile: processedFileMetadata,
};
// Update thumbnail only if current stub doesn't have one
const currentStub = stateRef.current.files.byId[fileId];
if (
!currentStub?.thumbnailUrl &&
processedFileMetadata.thumbnailUrl
) {
updates.thumbnailUrl = processedFileMetadata.thumbnailUrl;
if (processedFileMetadata.thumbnailUrl.startsWith("blob:")) {
lifecycleManager.trackBlobUrl(
processedFileMetadata.thumbnailUrl,
);
}
}
lifecycleManager.updateStirlingFileStub(
fileId,
updates,
stateRef,
);
return;
}
}
}
// Stub dispatch triggers re-render so the viewer appears (ADD_FILES alone doesn't update selectors).
// filesRef is a ref, so the selectors gating the workbench only see the
// file once something dispatches. Parsing it can't be a precondition.
lifecycleManager.updateStirlingFileStub(fileId, {}, stateRef);
});
const needsProcessing =
!stub.processedFile ||
!stub.processedFile.pages ||
stub.processedFile.pages.length === 0 ||
stub.processedFile.totalPages !== stub.processedFile.pages.length;
if (
stirlingFile.type.startsWith("application/pdf") &&
needsProcessing
) {
scheduleMetadataFor(stirlingFile);
}
})().catch((error) =>
console.error(`[Hydration] Failed to load ${fileId}:`, error),
);
}
return loadedFiles;
@@ -398,7 +398,7 @@ export function useFileContext() {
addFiles: actions.addFiles,
consumeFiles: actions.consumeFiles,
undoConsumeFiles: actions.undoConsumeFiles,
recordOperation: (_fileId: FileId, _operation: any) => {}, // Operation tracking not implemented
recordOperation: (_fileId: FileId, _operation: unknown) => {}, // Operation tracking not implemented
markOperationApplied: (_fileId: FileId, _operationId: string) => {}, // Operation tracking not implemented
markOperationFailed: (
_fileId: FileId,
@@ -0,0 +1,77 @@
import { describe, expect, test, vi } from "vitest";
import type {
FileContextState,
StirlingFileStub,
} from "@app/types/fileContext";
import type { FileId } from "@app/types/file";
/**
* A clicked file is only visible once hydration DISPATCHES: the workbench reads
* files out of a ref, so `activeFiles` stays empty until then. Parsing must not
* gate that - a PDF engine that stalls used to leave the workbench on its empty
* state with the row showing as open, and clicks doing nothing.
*/
const getStirlingFile = vi.hoisted(() => vi.fn());
vi.mock("@app/services/fileStorage", () => ({
fileStorage: { getStirlingFile },
}));
/** The stall under test: the page parse never settles. */
vi.mock("@app/utils/thumbnailUtils", () => ({
generateThumbnailPairWithMetadata: () => new Promise(() => {}),
}));
const stub = (id: string): StirlingFileStub =>
({
id: id as FileId,
name: `${id}.pdf`,
type: "application/pdf",
size: 10,
lastModified: 0,
}) as StirlingFileStub;
async function harness(ids: string[]) {
vi.resetModules();
getStirlingFile.mockImplementation(
async (id: FileId) =>
new File(["%PDF-1.7"], `${id}.pdf`, { type: "application/pdf" }),
);
const { addStirlingFileStubs } =
await import("@app/contexts/file/fileActions");
const stubs = ids.map(stub);
const state = {
files: { ids: [], byId: {} },
pinnedFiles: new Set(),
ui: { selectedFileIds: [], selectedPageNumbers: [] },
} as unknown as FileContextState;
const stateRef = { current: state };
const filesRef = { current: new Map<FileId, File>() };
const published: FileId[] = [];
const lifecycleManager = {
updateStirlingFileStub: (fileId: FileId) => published.push(fileId),
removeFiles: () => {},
trackBlobUrl: () => {},
};
await addStirlingFileStubs(
stubs,
{},
stateRef,
filesRef,
() => {},
lifecycleManager as never,
);
return { filesRef, published };
}
describe("workbench hydration — a stalled parse can't hide the file", () => {
test("publishes every file's bytes while their parses hang", async () => {
// Three, because the parse queue only runs two at a time: the third proves
// loading isn't queued behind parses that never finish.
const { filesRef, published } = await harness(["a", "b", "c"]);
await vi.waitFor(() => expect(published).toHaveLength(3));
expect([...filesRef.current.keys()]).toEqual(["a", "b", "c"]);
});
});
@@ -5,6 +5,7 @@
import { FileId } from "@app/types/file";
import {
FileContextAction,
FileContextState,
StirlingFileStub,
ProcessedFilePage,
} from "@app/types/fileContext";
@@ -39,7 +40,7 @@ export class FileLifecycleManager {
*/
cleanupFile = (
fileId: FileId,
stateRef?: React.MutableRefObject<any>,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
// Use comprehensive cleanup (same as removeFiles)
this.cleanupAllResourcesForFile(fileId, stateRef);
@@ -77,7 +78,7 @@ export class FileLifecycleManager {
scheduleCleanup = (
fileId: FileId,
delay: number = 30000,
stateRef?: React.MutableRefObject<any>,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
// Cancel existing timer
const existingTimer = this.cleanupTimers.get(fileId);
@@ -116,7 +117,7 @@ export class FileLifecycleManager {
*/
removeFiles = (
fileIds: FileId[],
stateRef?: React.MutableRefObject<any>,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
fileIds.forEach((fileId) => {
// Clean up all resources for this file
@@ -132,7 +133,7 @@ export class FileLifecycleManager {
*/
private cleanupAllResourcesForFile = (
fileId: FileId,
stateRef?: React.MutableRefObject<any>,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
// Remove from files ref
this.filesRef.current.delete(fileId);
@@ -188,7 +189,7 @@ export class FileLifecycleManager {
updateStirlingFileStub = (
fileId: FileId,
updates: Partial<StirlingFileStub>,
stateRef?: React.MutableRefObject<any>,
stateRef?: React.MutableRefObject<FileContextState>,
): void => {
// Guard against updating removed files (race condition protection)
if (!this.filesRef.current.has(fileId)) {
@@ -0,0 +1,33 @@
/**
* Registry invariant: the Automate picker offers a tool whenever it doesn't opt out via
* `supportsAutomate: false`, but automationExecutor resolves each step through the tool's
* `operationConfig`. A tool that is offered without one is selectable in the builder and
* only fails when the automation runs, with "Tool operation not supported: <toolId>".
*
* So a tool must either carry an operationConfig or declare supportsAutomate: false.
*/
import { describe, expect, test, vi } from "vitest";
import { renderHook } from "@testing-library/react";
import { useTranslatedToolCatalog } from "@app/data/useTranslatedToolRegistry";
import { getToolSupportsAutomate } from "@app/data/toolsTaxonomy";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string, fallback?: string) => fallback ?? key,
i18n: { changeLanguage: vi.fn(), language: "en-US" },
}),
Trans: ({ children }: { children?: unknown }) => children,
}));
describe("automatable tools", () => {
test("every tool offered to Automate can be executed as a step", () => {
const { result } = renderHook(() => useTranslatedToolCatalog());
const offeredWithoutConfig = Object.entries(result.current.regularTools)
.filter(([, entry]) => entry && getToolSupportsAutomate(entry))
.filter(([, entry]) => !entry.operationConfig)
.map(([id]) => id);
expect(offeredWithoutConfig).toEqual([]);
});
});
@@ -50,6 +50,8 @@ import { changeMetadataOperationConfig } from "@app/hooks/tools/changeMetadata/u
import { signOperationConfig } from "@app/hooks/tools/sign/useSignOperation";
import { cropOperationConfig } from "@app/hooks/tools/crop/useCropOperation";
import { removeAnnotationsOperationConfig } from "@app/hooks/tools/removeAnnotations/useRemoveAnnotationsOperation";
import { removeImageOperationConfig } from "@app/hooks/tools/removeImage/useRemoveImageOperation";
import { pageLayoutOperationConfig } from "@app/hooks/tools/pageLayout/usePageLayoutOperation";
import { extractImagesOperationConfig } from "@app/hooks/tools/extractImages/useExtractImagesOperation";
import { replaceColorOperationConfig } from "@app/hooks/tools/replaceColor/useReplaceColorOperation";
import { removePagesOperationConfig } from "@app/hooks/tools/removePages/useRemovePagesOperation";
@@ -526,6 +528,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
maxFiles: -1,
endpoints: ["validate-signature"],
synonyms: getSynonyms(t, "validateSignature"),
// Reports on signatures rather than transforming the PDF, and its hook is
// not on the operationConfig seam, so it cannot run as an automation step.
supportsAutomate: false,
automationSettings: null,
},
@@ -755,6 +760,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.PAGE_FORMATTING,
maxFiles: -1,
endpoints: ["multi-page-layout"],
operationConfig: asRegistryConfig(pageLayoutOperationConfig),
automationSettings: lazySettings(
() => import("@app/components/tools/pageLayout/PageLayoutSettings"),
),
@@ -967,7 +973,7 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.REMOVAL,
maxFiles: -1,
endpoints: ["remove-image-pdf"],
operationConfig: undefined,
operationConfig: asRegistryConfig(removeImageOperationConfig),
synonyms: getSynonyms(t, "removeImage"),
automationSettings: null,
},
@@ -1196,6 +1202,9 @@ export function useTranslatedToolCatalog(): TranslatedToolCatalog {
subcategoryId: SubcategoryId.ADVANCED_FORMATTING,
endpoints: ["scanner-effect"],
synonyms: getSynonyms(t, "scannerEffect"),
// No frontend implementation yet (component is null), so it has no
// operationConfig to execute as an automation step.
supportsAutomate: false,
automationSettings: null,
},
@@ -80,6 +80,13 @@ export const changePermissionsOperationConfig = defineSingleFileTool({
operationType: "changePermissions",
endpoint: ENDPOINT, // Change Permissions is a fake endpoint for the Add Password tool
defaultParameters,
// Both tools post to add-password. A permissions-only step carries none of the encryption
// fields, so it is this tool and not Add Password; keyLength, always sent by Add Password,
// is the reliable tell even when a password happens to be blank.
claimsStoredStep: (apiParams) =>
!("password" in apiParams) &&
!("ownerPassword" in apiParams) &&
!("keyLength" in apiParams),
});
export const useChangePermissionsOperation = () => {
@@ -345,11 +345,15 @@ export const useConvertOperation = (parameters?: ConvertParameters) => {
...convertOperationConfig,
customProcessor: customConvertProcessor, // Use instance-specific processor for translation support
getErrorMessage: (error) => {
if (error.response?.data && typeof error.response.data === "string") {
return error.response.data;
const err = error as {
response?: { data?: unknown };
message?: string;
};
if (err.response?.data && typeof err.response.data === "string") {
return err.response.data;
}
if (error.message) {
return error.message;
if (err.message) {
return err.message;
}
return t(
"convert.errorConversion",
@@ -179,13 +179,15 @@ export const useOCROperation = () => {
const ocrConfig: ToolOperationConfig<OCRParameters> = {
...ocrOperationConfig,
responseHandler,
getErrorMessage: (error) =>
error.message?.includes("OCR tools") &&
error.message?.includes("not installed")
getErrorMessage: (error) => {
const message = (error as { message?: string }).message;
return message?.includes("OCR tools") &&
message?.includes("not installed")
? "OCR tools (OCRmyPDF or Tesseract) are not installed on the server. Use the standard or fat Docker image instead of ultra-lite, or install OCR tools manually."
: createStandardErrorHandler(
t("ocr.error.failed", "OCR operation failed"),
)(error),
)(error);
},
};
return useToolOperation(ocrConfig);
@@ -24,6 +24,8 @@ import { SPLIT_METHODS } from "@app/constants/splitConstants";
import { redactOperationConfig } from "@app/hooks/tools/redact/useRedactOperation";
import { autoRotateOperationConfig } from "@app/hooks/tools/autoRotate/useAutoRotateOperation";
import { defaultParameters as autoRotateDefaults } from "@app/hooks/tools/autoRotate/useAutoRotateParameters";
import { addPasswordOperationConfig } from "@app/hooks/tools/addPassword/useAddPasswordOperation";
import { changePermissionsOperationConfig } from "@app/hooks/tools/changePermissions/useChangePermissionsOperation";
function entry(over: Partial<ToolRegistryEntry>): ToolRegistryEntry {
return {
@@ -239,6 +241,46 @@ describe("serialize/deserialize round-trip", () => {
});
});
describe("shared-endpoint disambiguation", () => {
const addPassword = entry({
name: "Add Password",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(addPasswordOperationConfig),
});
const changePermissions = entry({
name: "Change Permissions",
automationSettings: NoopSettings,
operationConfig: asRegistryConfig(changePermissionsOperationConfig),
});
const ADD_PASSWORD = "/api/v1/security/add-password";
// Permissions only, no encryption fields: this is Change Permissions.
const permsOnly = {
operation: ADD_PASSWORD,
parameters: { preventPrinting: true },
};
// Carries keyLength (and a password): this is Add Password, even with a blank owner password.
const withPassword = {
operation: ADD_PASSWORD,
parameters: { password: "s3cret", ownerPassword: "", keyLength: 256 },
};
// Both share an endpoint, so the wrong one would win by registry order without a discriminator.
for (const [label, registry] of [
["add-password declared first", { addPassword, changePermissions }],
["change-permissions declared first", { changePermissions, addPassword }],
] as const) {
test(`each stored step reloads as its own tool (${label})`, () => {
expect(deserializeToolStep(permsOnly, registry).toolId).toBe(
"changePermissions",
);
expect(deserializeToolStep(withPassword, registry).toolId).toBe(
"addPassword",
);
});
}
});
describe("stepRequiresUpload", () => {
const step = (params: Record<string, unknown>): WorkingToolStep => ({
toolId: "compress" as ToolId,
@@ -226,11 +226,13 @@ function findToolByEndpoint(
step: ToolApiStep,
registry: Partial<ToolRegistry>,
): [ToolId, ToolRegistryEntry] | undefined {
const staticMatches: [ToolId, ToolRegistryEntry][] = [];
let dynamic: [ToolId, ToolRegistryEntry] | undefined;
for (const [id, entry] of Object.entries(registry)) {
const endpoint = entry?.operationConfig?.endpoint;
if (typeof endpoint === "string") {
if (endpoint === step.operation) return [id as ToolId, entry];
if (endpoint === step.operation)
staticMatches.push([id as ToolId, entry]);
} else if (typeof endpoint === "function" && !dynamic) {
const declared = entry?.operationConfig?.endpoints;
const matched = declared
@@ -239,9 +241,33 @@ function findToolByEndpoint(
if (matched) dynamic = [id as ToolId, entry];
}
}
if (staticMatches.length > 0) {
return disambiguateStaticMatches(staticMatches, step.parameters);
}
return dynamic;
}
/**
* Most endpoints belong to one tool, so the single match is returned unchanged. When several
* share an endpoint (Add Password and its permissions-only alias Change Permissions), prefer the
* specialised tool that claims the stored parameters; otherwise fall back to the general owner
* that declares no such claim.
*/
function disambiguateStaticMatches(
matches: [ToolId, ToolRegistryEntry][],
parameters: Record<string, unknown>,
): [ToolId, ToolRegistryEntry] {
if (matches.length === 1) return matches[0];
const claimed = matches.find(([, entry]) =>
entry.operationConfig?.claimsStoredStep?.(parameters),
);
if (claimed) return claimed;
const general = matches.find(
([, entry]) => !entry.operationConfig?.claimsStoredStep,
);
return general ?? matches[0];
}
/** A stored step kept verbatim because its endpoint maps to no known tool. */
function unmappedStep(step: ToolApiStep): UnknownToolStep {
return {
@@ -74,7 +74,7 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
responseHandler?: ResponseHandler;
/** Extract user-friendly error messages from API errors */
getErrorMessage?: (error: any) => string;
getErrorMessage?: (error: unknown) => string;
/** Default parameter values for automation */
defaultParameters?: TParams;
@@ -101,6 +101,13 @@ interface BaseToolOperationConfig<TParams, TEndpoint extends ToolEndpoint> {
*/
fromApiParams?(apiParams: ToolApiParams[TEndpoint]): Partial<TParams>;
/**
* Whether a stored step belongs to this tool, used only to tell apart tools that share an endpoint.
* Receives the raw stored request body. Absent means the tool is the general owner of its
* endpoint and claims any step no specialised sibling claims.
*/
claimsStoredStep?(apiParams: Record<string, unknown>): boolean;
/**
* For custom tools: if true, success implies all input files were successfully processed.
* Use this for tools like Automate or Merge where Many-to-One relationships exist
@@ -218,7 +218,7 @@ export const useToolOperation = <TParams>(
// Listen for global error file id events from HTTP interceptor during this run
let externalErrorFileIds: string[] = [];
const errorListener = (e: Event) => {
const detail = (e as CustomEvent)?.detail as any;
const detail = (e as CustomEvent<{ fileIds?: unknown }>)?.detail;
if (detail?.fileIds) {
externalErrorFileIds = Array.isArray(detail.fileIds)
? detail.fileIds
@@ -588,7 +588,7 @@ export const useToolOperation = <TParams>(
};
}
}
} catch (error: any) {
} catch (error) {
try {
const handled = await handle422Error(error, (id) =>
fileActions.markFileError(id as FileId),
@@ -691,21 +691,22 @@ export const useToolOperation = <TParams>(
// Show success message
actions.setStatus(t("undoSuccess", "Operation undone successfully"));
} catch (error: any) {
} catch (error) {
let errorMessage = extractErrorMessage(error);
// Provide more specific error messages based on error type
if (error.message?.includes("Mismatch between input files")) {
const err = error as { message?: string; name?: string };
if (err.message?.includes("Mismatch between input files")) {
errorMessage = t(
"undoDataMismatch",
"Cannot undo: operation data is corrupted",
);
} else if (error.message?.includes("IndexedDB")) {
} else if (err.message?.includes("IndexedDB")) {
errorMessage = t(
"undoStorageError",
"Undo completed but some files could not be saved to storage",
);
} else if (error.name === "QuotaExceededError") {
} else if (err.name === "QuotaExceededError") {
errorMessage = t(
"undoQuotaError",
"Cannot undo: insufficient storage space",
@@ -389,6 +389,18 @@ export const useFileManager = () => {
// Optimistic update — remove from UI immediately, delete IDB in background
setFiles(files.filter((_, i) => i !== index));
onRemovedFromWorkbench?.(file.id);
// Superseded versions go with it (see orphanedAncestorIds); best-effort,
// because failing to tidy history must not fail the delete itself.
void fileStorage
.orphanedAncestorIds([file.id])
.then((orphans) =>
orphans.length > 0
? fileStorage.deleteMultipleStirlingFiles(orphans)
: undefined,
)
.catch((error) =>
console.warn("Failed to remove superseded versions:", error),
);
indexedDB.deleteFile(file.id).catch((error) => {
console.error("Failed to remove file from IndexedDB:", error);
// Restore consistency — file is still in IDB so refresh brings it back
@@ -3,23 +3,26 @@ import "fake-indexeddb/auto";
import { expectConsole } from "@app/tests/failOnConsole";
/**
* Regression test for the WebKit nightly breakage introduced with the
* large-file OOM fix (#7175): `storeStirlingFile` began putting the `File`
* itself into IndexedDB (persisted by reference, so multi-GB uploads never
* materialize in JS memory). WebKit refuses blob values whenever it can't write
* the blob's backing file and rejects the request with `UnknownError: Error
* preparing Blob/File data to be stored in object store`, so on WebKit every
* upload silently failed to persist: files vanished on navigation, Compare
* slots never filled, and the classification backfill had no bytes to read.
* WebKit refuses blob values when it can't write the blob's backing file, so
* every upload silently failed to persist after #7175. Retried as a copy now.
*
* The service now retries such a rejection with an ArrayBuffer copy and stops
* offering blobs for the rest of the session.
* It can also accept one and then lose the backing store. fake-indexeddb returns no
* Blob, so that loss is injected at the read; real round-trips: the e2e spec.
*/
const nativeAdd = IDBObjectStore.prototype.add;
const alertMock = vi.hoisted(() => vi.fn());
vi.mock("@app/components/toast", () => ({
alert: (options: unknown) => alertMock(options),
}));
/** What each `add` attempt carried in `data` — the blob path or the copy path. */
const nativeAdd = IDBObjectStore.prototype.add;
const nativePut = IDBObjectStore.prototype.put;
const nativeGet = IDBObjectStore.prototype.get;
/** What each `add` attempt carried in `data`: blob path or copy path. */
let attempts: Array<"blob" | "copy"> = [];
/** The same, for `put` - the rewrite path a lost backing store recovers through. */
let putAttempts: Array<"blob" | "copy"> = [];
/** An IDBRequest that fails asynchronously, the way WebKit rejects blob puts. */
class FailingRequest extends EventTarget {
@@ -32,10 +35,7 @@ class FailingRequest extends EventTarget {
}
}
/**
* Record every add attempt, optionally failing the blob-valued ones the way an
* engine without blob storage does.
*/
/** Record every add, optionally failing the blob-valued ones. */
function instrumentAdd(options: { rejectBlobs: boolean }) {
IDBObjectStore.prototype.add = function (
this: IDBObjectStore,
@@ -58,11 +58,72 @@ function instrumentAdd(options: { rejectBlobs: boolean }) {
} as typeof IDBObjectStore.prototype.add;
}
/**
* A fresh service per test: whether the engine accepts blobs is remembered for
* the process lifetime by design, so tests must not inherit that decision from
* each other.
*/
/** Record every put, so the copy-rewrite recovery can be observed. */
function instrumentPut() {
IDBObjectStore.prototype.put = function (
this: IDBObjectStore,
value: unknown,
key?: IDBValidKey,
) {
putAttempts.push(
(value as { data?: unknown } | null)?.data instanceof Blob
? "blob"
: "copy",
);
return key === undefined
? nativePut.call(this, value)
: nativePut.call(this, value, key);
} as typeof IDBObjectStore.prototype.put;
}
/** A stored blob whose backing store the engine has lost: it still reports a name,
* type and size, and every read of its bytes fails the way WebKit's does. */
function blobWithLostBackingStore(): Blob {
const lost = () => {
throw new DOMException(
"The object can not be found here.",
"NotFoundError",
);
};
return Object.assign(
new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
{ slice: lost, arrayBuffer: lost, text: lost, stream: lost },
);
}
/** The next `deadReads` reads come back with a lost backing store, later ones
* untouched - so a repaired record can still be read normally. */
function loseBackingStoreOnRead(deadReads: number) {
let remaining = deadReads;
IDBObjectStore.prototype.get = function (
this: IDBObjectStore,
key: IDBValidKey | IDBKeyRange,
) {
const request = nativeGet.call(this, key as IDBValidKey);
// One substitution per request, however often `result` is read.
let injected = false;
return new Proxy(request, {
get(target, prop) {
// Receiver must be the real request: IDBRequest's accessors are branded.
const value = Reflect.get(target, prop, target);
if (prop !== "result") {
return typeof value === "function" ? value.bind(target) : value;
}
if (!value || injected || remaining === 0) return value;
injected = true;
remaining--;
return { ...(value as object), data: blobWithLostBackingStore() };
},
set(target, prop, value) {
Reflect.set(target, prop, value, target);
return true;
},
});
} as typeof IDBObjectStore.prototype.get;
}
/** A fresh service per test: the blob decision is remembered by design, so
* tests must not inherit it from each other. */
async function freshFileStorage() {
vi.resetModules();
const [{ fileStorage }, { createStirlingFile, createNewStirlingFileStub }] =
@@ -86,10 +147,58 @@ async function freshFileStorage() {
beforeEach(() => {
attempts = [];
putAttempts = [];
alertMock.mockClear();
// The blob verdict is deliberately durable, so each test must start undecided.
localStorage.clear();
});
afterEach(() => {
IDBObjectStore.prototype.add = nativeAdd;
IDBObjectStore.prototype.put = nativePut;
IDBObjectStore.prototype.get = nativeGet;
});
/** Abort the transaction the moment a write is issued over it. */
function abortOnPut() {
IDBObjectStore.prototype.put = function (this: IDBObjectStore) {
const request = new FailingRequest(
new DOMException("transaction aborted", "AbortError"),
) as unknown as IDBRequest<IDBValidKey>;
this.transaction.abort();
return request;
} as typeof IDBObjectStore.prototype.put;
}
describe("read-modify-write — a refused rewrite must not hang or vanish", () => {
/** The abort guard used to sit on the read promise, leaving the write with a
* dead reject - and `.catch` can't rescue a promise that never settles. */
test("settles instead of hanging when the write transaction aborts", async () => {
expectConsole.error(/Failed to mark file as processed/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await store("aborts.pdf");
abortOnPut();
// Before the fix this never settled and the test timed out.
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(false);
});
/** The copy-and-retry recovery can't be exercised here: it needs a record that
* reads back as a Blob, which fake-indexeddb never returns. */
test("a metadata rewrite still commits, and reports commit not put", async () => {
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await store("rewrite.pdf");
await expect(fileStorage.markFileAsProcessed(id)).resolves.toBe(true);
// Missing record: `false`, not a throw and not a claim of success.
await expect(
fileStorage.markFileAsProcessed("nope" as never),
).resolves.toBe(false);
expect((await fileStorage.getStirlingFile(id))?.name).toBe("rewrite.pdf");
});
});
describe("storeStirlingFile — blob-value fallback", () => {
@@ -113,8 +222,7 @@ describe("storeStirlingFile — blob-value fallback", () => {
const id = await store("webkit.pdf");
expect(attempts).toEqual(["blob", "copy"]);
// Readable back is what every downstream consumer depends on: rehydration
// after navigation, thumbnails, the classification backfill.
// Readable back is what rehydration, thumbnails and backfill depend on.
expect((await fileStorage.getStirlingFile(id))?.name).toBe("webkit.pdf");
});
@@ -127,12 +235,32 @@ describe("storeStirlingFile — blob-value fallback", () => {
attempts = [];
const id = await store("second.pdf");
// Straight to the copy path — no repeated blob probe, and only the single
// warning expected above.
// Straight to the copy path, and only the one warning expected above.
expect(attempts).toEqual(["copy"]);
expect((await fileStorage.getStirlingFile(id))?.name).toBe("second.pdf");
});
/** Committing is not evidence the bytes survived, and by the next reload the
* source File is gone: without this the upload looks fine and the file is dead. */
test("repairs a record whose stored blob loses its backing store", async () => {
expectConsole.warn(/could not read its bytes back/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
instrumentPut();
loseBackingStoreOnRead(1); // only the store's own read-back is dead
const id = await store("dead-on-arrival.pdf");
// Accepted as a blob, then rewritten from the file still in hand.
expect(attempts).toEqual(["blob"]);
expect(putAttempts).toEqual(["copy"]);
expect((await fileStorage.getStirlingFile(id))?.name).toBe(
"dead-on-arrival.pdf",
);
// Self-healed, so nothing to tell the user about.
expect(alertMock).not.toHaveBeenCalled();
});
test("does not retry a failure a copy can't fix (quota)", async () => {
const { store } = await freshFileStorage();
IDBObjectStore.prototype.add = function (this: IDBObjectStore) {
@@ -144,3 +272,246 @@ describe("storeStirlingFile — blob-value fallback", () => {
expect(attempts).toEqual(["blob"]);
});
});
/** An earlier session's record can't be repaired, and the user has to be told - but
* the telling must never gate the open. Awaiting the probe stalled every file in
* Safari, where the probe read of a lost backing store never settles. */
describe("reads — a stored blob whose bytes are gone", () => {
test("hands the file over and reports the loss out of band", async () => {
expectConsole.warn(/could not read its bytes back/);
expectConsole.error(/cannot be read/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await store("lost.pdf");
loseBackingStoreOnRead(5); // every read from here on
// Not null, and not awaited on the probe: the caller is never blocked.
expect((await fileStorage.getStirlingFile(id))?.name).toBe("lost.pdf");
await new Promise((resolve) => setTimeout(resolve));
// Told once, not once per reader: every consumer of the file hits this record.
expect(alertMock).toHaveBeenCalledTimes(1);
expect(alertMock.mock.calls[0][0]).toMatchObject({
alertType: "warning",
body: expect.stringContaining("lost.pdf"),
});
});
/** The loop this closes: a reload re-decides optimistically, writes blobs the
* engine loses again, and the browser never settles on a shape that works. */
test("remembers across reloads that this browser loses blob values", async () => {
expectConsole.warn(/could not read its bytes back/);
expectConsole.error(/cannot be read/);
const first = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await first.store("lost.pdf");
loseBackingStoreOnRead(1);
expect(await first.fileStorage.getStirlingFile(id)).not.toBeNull();
await new Promise((resolve) => setTimeout(resolve));
// A new page load: a fresh service, same browser profile.
const next = await freshFileStorage();
attempts = [];
const later = await next.store("after-reload.pdf");
expect(attempts).toEqual(["copy"]);
expect((await next.fileStorage.getStirlingFile(later))?.name).toBe(
"after-reload.pdf",
);
});
test("stops offering blob values for the rest of the session", async () => {
expectConsole.warn(/could not read its bytes back/);
expectConsole.error(/cannot be read/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const first = await store("lost.pdf");
loseBackingStoreOnRead(1);
expect(await fileStorage.getStirlingFile(first)).not.toBeNull();
await new Promise((resolve) => setTimeout(resolve));
// An engine that loses a blob it accepted can't be trusted with the next one,
// so the read failure degrades writes too.
attempts = [];
const second = await store("later.pdf");
expect(attempts).toEqual(["copy"]);
expect((await fileStorage.getStirlingFile(second))?.name).toBe("later.pdf");
});
});
/** Deleting a file used to leave its superseded versions in storage forever,
* invisible (listings filter on isLeaf) and still holding their full bytes. */
describe("orphanedAncestorIds", () => {
const store = async (
fileStorage: { storeStirlingFile: (f: never, s: never) => Promise<void> },
id: string,
parentFileId: string | undefined,
isLeaf: boolean,
) => {
const { createStirlingFile, createNewStirlingFileStub } =
await import("@app/types/fileContext");
const file = new File(["%PDF-1.7"], `${id}.pdf`, {
type: "application/pdf",
});
const base = createNewStirlingFileStub(file);
await fileStorage.storeStirlingFile(
createStirlingFile(file, id as never) as never,
{ ...base, id, isLeaf, parentFileId, originalFileId: "v1" } as never,
);
};
test("takes the superseded versions with the leaf", async () => {
const { fileStorage } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
await store(fileStorage as never, "v1", undefined, false);
await store(fileStorage as never, "v2", "v1", true);
expect(await fileStorage.orphanedAncestorIds(["v2" as never])).toEqual([
"v1",
]);
});
test("leaves a split sibling's history alone", async () => {
const { fileStorage } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
// Distinct ids: the fake database outlives the module reset between tests.
await store(fileStorage as never, "split-root", undefined, false);
await store(fileStorage as never, "split-a", "split-root", true);
await store(fileStorage as never, "split-b", "split-root", true);
// `split-b` still descends from the root, so deleting `split-a` can't strip it.
expect(await fileStorage.orphanedAncestorIds(["split-a" as never])).toEqual(
[],
);
// Once both leaves go, the shared ancestor is genuinely unreachable.
expect(
await fileStorage.orphanedAncestorIds([
"split-a" as never,
"split-b" as never,
]),
).toEqual(["split-root"]);
});
});
/** Handing dead bytes over is only safe if whoever holds them is told to let go -
* otherwise the viewer renders a document that never loads (an endless spinner). */
describe("confirmed-unreadable records", () => {
test("notifies listeners and refuses to hand the same file out twice", async () => {
expectConsole.warn(/could not read its bytes back/);
expectConsole.error(/cannot be read/);
const { fileStorage, store } = await freshFileStorage();
const { onRecordUnreadable } = await import("@app/services/fileStorage");
instrumentAdd({ rejectBlobs: false });
const id = await store("doomed.pdf");
const dropped: string[] = [];
const unsubscribe = onRecordUnreadable((fileId) => dropped.push(fileId));
loseBackingStoreOnRead(5);
// First read still hands the file over: the probe is out of band.
expect(await fileStorage.getStirlingFile(id)).not.toBeNull();
await new Promise((resolve) => setTimeout(resolve));
// The holder is told, so the workbench can drop it instead of spinning.
expect(dropped).toEqual([id]);
// And a second consumer never gets the same dead bytes.
expect(await fileStorage.getStirlingFile(id)).toBeNull();
unsubscribe();
});
});
/** A readable-blob substitute, for the rescue path: fake-indexeddb never returns
* Blob values, so a healthy legacy blob record is injected the same way a dead
* one is. */
function substituteHealthyBlobOnRead(reads: number) {
let remaining = reads;
IDBObjectStore.prototype.get = function (
this: IDBObjectStore,
key: IDBValidKey | IDBKeyRange,
) {
const request = nativeGet.call(this, key as IDBValidKey);
let injected = false;
return new Proxy(request, {
get(target, prop) {
const value = Reflect.get(target, prop, target);
if (prop !== "result") {
return typeof value === "function" ? value.bind(target) : value;
}
if (!value || injected || remaining === 0) return value;
injected = true;
remaining--;
return {
...(value as object),
data: new Blob(["%PDF-1.7 stirling"], { type: "application/pdf" }),
};
},
set(target, prop, value) {
Reflect.set(target, prop, value, target);
return true;
},
});
} as typeof IDBObjectStore.prototype.get;
}
/** The library must tell the truth per row: a record whose bytes are gone lists
* as data-lost instead of a file that pretends to open. */
describe("stub listings — data-lost auditing", () => {
test("flags a dead record on the stub once the audit lands", async () => {
expectConsole.warn(/could not read its bytes back/);
expectConsole.error(/cannot be read/);
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
const id = await store("husk.pdf");
loseBackingStoreOnRead(1);
// First read schedules the out-of-band audit; unknown is not yet flagged.
expect(
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
).toBeUndefined();
await new Promise((resolve) => setTimeout(resolve));
expect((await fileStorage.getStirlingFileStub(id))?.dataUnavailable).toBe(
true,
);
});
test("rescues a still-readable legacy blob to a copy on a no-blob browser", async () => {
// The durable verdict says this browser loses blob values...
localStorage.setItem("stirling.indexeddb.blobValuesUnsupported", "true");
const { fileStorage, store } = await freshFileStorage();
instrumentAdd({ rejectBlobs: false });
instrumentPut();
const id = await store("legacy.pdf");
// ...and a legacy record still holds a READABLE blob: save it while we can.
substituteHealthyBlobOnRead(5);
await fileStorage.getStirlingFileStub(id);
await vi.waitFor(() => expect(putAttempts).toContain("copy"));
// Rescued, not condemned: the stub stays openable.
expect(
(await fileStorage.getStirlingFileStub(id))?.dataUnavailable,
).toBeUndefined();
});
});
/** One hung request inside the TTL bump's readwrite transaction wedged the whole
* store: every later read and write queued behind it forever - the infinite
* "Loading files..." after a Safari reload. Maintenance must not touch
* blob-bodied records on a browser that can't rewrite them anyway. */
describe("maintenanceMayRewrite", () => {
test("keeps maintenance away from blob records on a no-blob browser", async () => {
const { maintenanceMayRewrite } = await import("@app/services/fileStorage");
const blobRecord = { data: new Blob(["x"]) };
const copyRecord = { data: new ArrayBuffer(1) };
expect(maintenanceMayRewrite(blobRecord, false)).toBe(false);
// Copies never hang and their rewrite is accepted - always safe.
expect(maintenanceMayRewrite(copyRecord, false)).toBe(true);
// On engines that genuinely support blobs (Chrome), nothing changes.
expect(maintenanceMayRewrite(blobRecord, true)).toBe(true);
expect(maintenanceMayRewrite(copyRecord, true)).toBe(true);
});
});
+506 -186
View File
@@ -15,6 +15,7 @@ import {
indexedDBManager,
DATABASE_CONFIGS,
} from "@app/services/indexedDBManager";
import { alert } from "@app/components/toast";
/**
* Storage record - single source of truth
@@ -75,15 +76,135 @@ function isBlobValueRejection(error: unknown): boolean {
return name === "UnknownError" || name === "DataCloneError";
}
/** This engine loses Blob values, remembered per browser: session-scoped, each
* reload re-decides optimistically and writes more files it will lose. */
const BLOB_VALUES_UNSUPPORTED_KEY = "stirling.indexeddb.blobValuesUnsupported";
function readBlobValuesSupported(): boolean {
try {
return localStorage.getItem(BLOB_VALUES_UNSUPPORTED_KEY) !== "true";
} catch {
// Storage unavailable (private mode): decide fresh each session.
return true;
}
}
function persistBlobValuesUnsupported(): void {
try {
localStorage.setItem(BLOB_VALUES_UNSUPPORTED_KEY, "true");
} catch {
// Storage unavailable: the session-scoped flag still degrades this session.
}
}
/**
* Whether maintenance writes (the thumbnail TTL bump) may re-read/re-write this
* record. In WebKit, a `get` touching a blob-bodied record whose backing store is
* damaged can HANG rather than error - and one pending request wedges the whole
* object store: every later transaction, read or write, queues behind it forever.
* That was the infinite "Loading files..." after a reload: the TTL bump's
* transaction never completed, so nothing else on the store ever ran. On a
* browser whose verdict is "blobs unsupported" the rewrite would be refused
* anyway, so blob-bodied records are not worth the risk of touching at all.
*/
export function maintenanceMayRewrite(
record: { data: ArrayBuffer | Blob },
blobValuesSupported: boolean,
): boolean {
return !(record.data instanceof Blob) || blobValuesSupported;
}
/** WebKit loses backing stores for blobs it accepted, and only a real read shows
* it. One byte is enough: what fails is opening the store, not the length. */
async function blobReadFailure(data: Blob): Promise<unknown> {
try {
await data.slice(0, 1).arrayBuffer();
return null;
} catch (error) {
return error ?? new Error("Reading a stored blob's bytes failed");
}
}
/** Notified when a record's bytes are proven unreadable, so whoever is holding the
* file can drop it instead of rendering a document that never arrives. */
const unreadableListeners = new Set<(fileId: FileId) => void>();
export function onRecordUnreadable(
listener: (fileId: FileId) => void,
): () => void {
unreadableListeners.add(listener);
return () => unreadableListeners.delete(listener);
}
/** The probe read itself can hang in WebKit, so anything that awaits it needs a
* deadline. Distinct from a failure: nothing was proven either way. */
const PROBE_UNANSWERED = { unanswered: true } as const;
const PROBE_DEADLINE_MS = 3000;
function withProbeDeadline(
probe: Promise<unknown>,
): Promise<unknown | typeof PROBE_UNANSWERED> {
return Promise.race([
probe,
new Promise<typeof PROBE_UNANSWERED>((resolve) =>
setTimeout(() => resolve(PROBE_UNANSWERED), PROBE_DEADLINE_MS),
),
]);
}
/**
* The File for a stored record. Re-wrapping a stored blob can cost WebKit the
* backing handle, so hand it back untouched when its identity fields match.
*/
function fileFromRecord(record: StoredStirlingFileRecord): File {
const { data } = record;
if (
data instanceof File &&
data.name === record.name &&
data.type === record.type &&
data.lastModified === record.lastModified
) {
return data;
}
return new File([data], record.name, {
type: record.type,
lastModified: record.lastModified,
});
}
/**
* Settle on abort, for promises whose settle paths (a cursor tick, a request not
* yet issued) never arrive. Call ONCE per transaction - there is one slot.
*/
function settleOnAbort(
transaction: IDBTransaction,
settle: (reason: Error) => void,
): void {
transaction.onabort = () =>
settle(
transaction.error ??
new Error("IndexedDB transaction aborted before it completed"),
);
}
class FileStorageService {
private readonly dbConfig = DATABASE_CONFIGS.FILES;
private readonly storeName = "files";
/**
* Whether this engine accepts Blob/File values in IndexedDB. Optimistic: the
* blob path avoids copying multi-GB files into JS memory, so we try it and
* remember the answer, rather than pre-emptively degrading everywhere.
*/
private blobValuesSupported = true;
/** Whether this engine takes Blob/File values, which avoid copying multi-GB
* files into JS memory. Optimistic; a No outlives the session (see the key). */
private blobValuesSupported = readBlobValuesSupported();
/** Whether a stored blob's bytes have come back yet. Until they have, each
* store proves it: accepting the write is no evidence the bytes survived. */
private blobReadbackVerified = false;
/** Ids whose TTL write failed. Without this the swallowed failure repeats a
* whole-file rewrite on every listing. Session-scoped on purpose. */
private readonly unwritableRecords = new Set<FileId>();
/** Ids already reported as unreadable, so one dead record is surfaced once
* rather than on every read of it. Session-scoped on purpose. */
private readonly unreadableRecords = new Set<FileId>();
/** Ids whose blob bytes this session has already audited (either way), so
* listings don't re-probe every record on every refresh. */
private readonly auditedRecords = new Set<FileId>();
/**
* Get database connection using centralized manager
@@ -101,7 +222,8 @@ class FileStorageService {
/** Fire-and-forget: bump thumbnailStoredAt (or clear expired thumbnail) for a set of ids. */
private async bumpThumbnailTTL(ids: FileId[], clear = false): Promise<void> {
if (ids.length === 0) return;
const targets = ids.filter((id) => !this.unwritableRecords.has(id));
if (targets.length === 0) return;
const db = await this.getDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
@@ -112,7 +234,7 @@ class FileStorageService {
// Issue all gets up front - each onsuccess creates a put before the
// transaction can auto-commit, keeping it alive until all puts settle.
ids.forEach((id) => {
targets.forEach((id) => {
const req = store.get(id);
req.onsuccess = () => {
const record = req.result as StoredStirlingFileRecord | undefined;
@@ -123,7 +245,30 @@ class FileStorageService {
} else {
record.thumbnailStoredAt = Date.now();
}
store.put(record);
// One unwritable record must not take the batch with it: a rejected
// put aborts the transaction the other queued gets are still using.
try {
const put = store.put(record);
put.onerror = (event) => {
// The write we just swallowed is the one that would have taken
// this record out of the expiring set, so stop retrying it.
this.unwritableRecords.add(id);
this.noteBlobRefusal(put.error);
console.warn(
`[fileStorage] thumbnail TTL bump skipped for ${id}:`,
put.error,
);
// Swallow it here so the failure doesn't abort the transaction.
event.preventDefault();
event.stopPropagation();
};
} catch (error) {
this.unwritableRecords.add(id);
console.warn(
`[fileStorage] thumbnail TTL bump could not be issued for ${id}:`,
error,
);
}
};
req.onerror = () => reject(req.error);
});
@@ -186,18 +331,255 @@ class FileStorageService {
} catch (error) {
// Recoverable: re-add as a copy, and stop offering blobs this session.
// Anything else is the caller's to report.
if (!(record.data instanceof Blob) || !isBlobValueRejection(error)) {
if (!(record.data instanceof Blob) || !this.noteBlobRefusal(error)) {
throw error;
}
this.blobValuesSupported = false;
console.warn(
"IndexedDB rejected a Blob value; falling back to in-memory copies for this session. " +
"Very large files may now exhaust renderer memory.",
error,
);
record.data = await record.data.arrayBuffer();
await this.addFileRecord(db, record);
return;
}
// Committed is not retrievable. Prove the round-trip while the source File is
// still in hand; after a reload there is nothing left to repair from.
if (record.data instanceof Blob && !this.blobReadbackVerified) {
await this.verifyStoredBlobReadable(db, record, stirlingFile);
}
}
/** Read one stored blob back, rewriting the record from {@code source} if its
* bytes don't come with it. Runs until one round-trip succeeds. */
private async verifyStoredBlobReadable(
db: IDBDatabase,
record: StoredStirlingFileRecord,
source: File,
): Promise<void> {
// A record we can't read back at all is the caller's problem, not the probe's.
const stored = await this.readRecord(db, record.id).catch(() => undefined);
if (!(stored?.data instanceof Blob)) return;
const failure = await withProbeDeadline(blobReadFailure(stored.data));
if (!failure) {
this.blobReadbackVerified = true;
return;
}
if (failure === PROBE_UNANSWERED) {
// Nothing proven, and an upload must never wait on a probe. Leave the record
// as written; the read path reports it if the bytes really are gone.
console.warn(
`[fileStorage] readability probe for ${record.id} did not answer in ${PROBE_DEADLINE_MS}ms`,
);
return;
}
this.noteBlobUnreadable(failure);
try {
record.data = await source.arrayBuffer();
await this.putRecord(db, record);
} catch (error) {
// The record is unusable either way, and the read path reports that to the
// user. Don't turn a write that already committed into a failure.
console.warn(
`[fileStorage] could not rewrite ${record.id} as an in-memory copy:`,
error,
);
}
}
/** Refused a Blob value? Stop offering blobs on this browser. Any write can flip
* this: WebKit refuses per-operation, not per-engine. */
private noteBlobRefusal(error: unknown): boolean {
if (!isBlobValueRejection(error)) return false;
this.disableBlobValues(
"IndexedDB rejected a Blob value; falling back to in-memory copies on this browser. " +
"Very large files may now exhaust renderer memory.",
error,
);
return true;
}
/** A stored blob whose bytes won't read back means blob values can't be trusted
* on this engine either, even though it accepted the write. */
private noteBlobUnreadable(error: unknown): void {
this.disableBlobValues(
"IndexedDB accepted a Blob value but could not read its bytes back; " +
"falling back to in-memory copies on this browser. " +
"Very large files may now exhaust renderer memory.",
error,
);
}
private disableBlobValues(message: string, error: unknown): void {
if (!this.blobValuesSupported) return;
this.blobValuesSupported = false;
persistBlobValuesUnsupported();
console.warn(message, error);
}
/**
* Audit a blob-backed record's bytes WITHOUT gating anything on the answer.
* Awaiting this was a mistake: in Safari the probe read of a lost backing store
* can stay pending forever, so it stalled every file open instead of the one
* consumer that would have failed anyway.
*
* Two outcomes, both out of band:
* - Bytes gone: mark + report, so the library shows "data lost" instead of a
* file that pretends to open.
* - Bytes readable on a browser whose verdict is "blobs unsupported": RESCUE the
* record to an ArrayBuffer copy now, while the bytes still exist. Legacy blob
* records on WebKit are one engine hiccup away from being lost for good.
*/
private reportIfUnreadable(record: StoredStirlingFileRecord): void {
if (!(record.data instanceof Blob)) return;
if (this.auditedRecords.has(record.id)) return;
this.auditedRecords.add(record.id);
void blobReadFailure(record.data).then((failure) => {
if (!failure) {
this.blobReadbackVerified = true;
if (!this.blobValuesSupported) void this.rescueBlobRecord(record.id);
return;
}
this.noteBlobUnreadable(failure);
this.reportUnreadableRecord(record, failure);
});
}
/**
* Rewrite one still-readable legacy blob record as an ArrayBuffer copy. Reads
* the FULL bytes (the audit only proved the first one) and goes through
* {@link updateRecord}'s read-modify-write so a concurrent metadata update
* isn't clobbered by a stale snapshot.
*/
private async rescueBlobRecord(fileId: FileId): Promise<void> {
try {
const db = await this.getDatabase();
const record = await this.readRecord(db, fileId);
if (!(record?.data instanceof Blob)) return;
const bytes = await withProbeDeadline(record.data.arrayBuffer());
if (bytes === PROBE_UNANSWERED || !(bytes instanceof ArrayBuffer)) return;
record.data = bytes;
await this.putRecord(db, record);
console.info(
`[fileStorage] rescued "${record.name}" (${fileId}) to an in-memory copy before this browser could lose its blob`,
);
} catch (error) {
// Best-effort: a failed rescue leaves the record exactly as it was.
console.warn(`[fileStorage] could not rescue ${fileId}:`, error);
}
}
/** One console error and one toast per dead record: every consumer of the file
* hits the same record, and the user needs the reason once, not per reader. */
private reportUnreadableRecord(
record: StoredStirlingFileRecord,
failure: unknown,
): void {
if (this.unreadableRecords.has(record.id)) return;
this.unreadableRecords.add(record.id);
// Whoever is holding it needs to let go, or the viewer renders a document
// whose bytes never arrive - a spinner with no terminal state.
for (const listener of unreadableListeners) listener(record.id);
console.error(
`[fileStorage] stored data for "${record.name}" (${record.id}) cannot be read; ` +
"the browser no longer has the blob's backing store",
failure,
);
alert({
alertType: "warning",
title: "File data is unavailable",
body:
`"${record.name}" is saved in this browser but its contents can no longer be read. ` +
"Upload the file again to keep working on it.",
expandable: false,
durationMs: 8000,
});
}
/** Read-modify-write one record in one transaction, resolving on COMMIT. Split
* across two promises, the abort guard covers one and the other hangs. */
private async updateRecord(
fileId: FileId,
mutate: (record: StoredStirlingFileRecord) => boolean | void,
): Promise<boolean> {
const db = await this.getDatabase();
try {
return await this.readModifyWrite(db, fileId, mutate);
} catch (error) {
// The record we read back still carries its Blob body; retry as a copy.
if (!this.noteBlobRefusal(error)) throw error;
return await this.rewriteRecordAsCopy(db, fileId, mutate);
}
}
/** {@link updateRecord}'s happy path: one transaction, resolve on commit. */
private readModifyWrite(
db: IDBDatabase,
fileId: FileId,
mutate: (record: StoredStirlingFileRecord) => boolean | void,
): Promise<boolean> {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
let written = false;
settleOnAbort(transaction, reject);
transaction.onerror = () => reject(transaction.error);
transaction.oncomplete = () => resolve(written);
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(fileId);
getRequest.onerror = () => reject(getRequest.error);
getRequest.onsuccess = () => {
const record = getRequest.result as
| StoredStirlingFileRecord
| undefined;
// Nothing to write: let the empty transaction commit and report false.
if (!record || mutate(record) === false) return;
written = true;
store.put(record);
};
});
}
/** Recovery path: two transactions, because materializing the copy is async
* and a transaction cannot survive an await. Last-write-wins either way. */
private async rewriteRecordAsCopy(
db: IDBDatabase,
fileId: FileId,
mutate: (record: StoredStirlingFileRecord) => boolean | void,
): Promise<boolean> {
const record = await this.readRecord(db, fileId);
if (!record || mutate(record) === false) return false;
if (record.data instanceof Blob) {
record.data = await record.data.arrayBuffer();
}
await this.putRecord(db, record);
return true;
}
/** One record by id, in its own transaction. */
private readRecord(
db: IDBDatabase,
fileId: FileId,
): Promise<StoredStirlingFileRecord | undefined> {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
settleOnAbort(transaction, reject);
const request = transaction.objectStore(this.storeName).get(fileId);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve(request.result);
});
}
/** One `put`, resolving on commit. */
private putRecord(
db: IDBDatabase,
record: StoredStirlingFileRecord,
): Promise<void> {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
settleOnAbort(transaction, reject);
transaction.onerror = () => reject(transaction.error);
transaction.oncomplete = () => resolve();
transaction.objectStore(this.storeName).put(record);
});
}
/** Single `add` of a file record. Rejects with the underlying IDB error. */
@@ -215,6 +597,7 @@ class FileStorageService {
}
const transaction = db.transaction([this.storeName], "readwrite");
settleOnAbort(transaction, reject);
const store = transaction.objectStore(this.storeName);
const request = store.add(record);
@@ -227,37 +610,21 @@ class FileStorageService {
});
}
/**
* Get StirlingFile with full data - for loading into workbench
*/
/** Get StirlingFile with full data - for loading into workbench. Null covers
* both no such record and bytes gone; neither is a file callers can use. */
async getStirlingFile(id: FileId): Promise<StirlingFile | null> {
// Already proven unreadable this session: don't hand the same dead bytes to
// another consumer that will spin on them. Session-scoped, so a reload retries.
if (this.unreadableRecords.has(id)) return null;
const db = await this.getDatabase();
const record = await this.readRecord(db, id);
if (!record) return null;
// Reporting only, and NEVER awaited: WebKit can leave a read of a lost backing
// store pending forever, and this is the path every file open goes through.
this.reportIfUnreadable(record);
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const record = request.result as StoredStirlingFileRecord | undefined;
if (!record) {
resolve(null);
return;
}
// Create File from stored data
const blob = new Blob([record.data], { type: record.type });
const file = new File([blob], record.name, {
type: record.type,
lastModified: record.lastModified,
});
// Convert to StirlingFile with preserved IDs
const stirlingFile = createStirlingFile(file, record.fileId);
resolve(stirlingFile);
};
});
// Convert to StirlingFile with preserved IDs
return createStirlingFile(fileFromRecord(record), record.fileId);
}
/**
@@ -278,6 +645,7 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
settleOnAbort(transaction, reject);
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
@@ -295,9 +663,13 @@ class FileStorageService {
// We still gate thumbnailUrl on freshness so stale thumbnails
// don't leak through this read path.
const fresh = this.isThumbnailFresh(record);
// Out-of-band byte audit, so the library reflects lost data (and rescues
// still-readable legacy blobs) instead of listing files that can't open.
this.reportIfUnreadable(record);
const stub: StirlingFileStub = {
id: record.id,
dataUnavailable: this.unreadableRecords.has(record.id) || undefined,
name: record.name,
type: record.type,
size: record.size,
@@ -338,6 +710,7 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
settleOnAbort(transaction, reject);
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const stubs: StirlingFileStub[] = [];
@@ -352,12 +725,18 @@ class FileStorageService {
const record = cursor.value as StoredStirlingFileRecord;
if (record && record.name && typeof record.size === "number") {
const fresh = this.isThumbnailFresh(record);
if (record.thumbnail) {
if (
record.thumbnail &&
maintenanceMayRewrite(record, this.blobValuesSupported)
) {
if (fresh) tobump.push(record.id);
else toexpire.push(record.id);
}
this.reportIfUnreadable(record);
stubs.push({
id: record.id,
dataUnavailable:
this.unreadableRecords.has(record.id) || undefined,
name: record.name,
type: record.type,
size: record.size,
@@ -425,6 +804,7 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
settleOnAbort(transaction, reject);
const store = transaction.objectStore(this.storeName);
const request = store.openCursor();
const leafStubs: StirlingFileStub[] = [];
@@ -444,12 +824,18 @@ class FileStorageService {
record.isLeaf !== false
) {
const fresh = this.isThumbnailFresh(record);
if (record.thumbnail) {
if (
record.thumbnail &&
maintenanceMayRewrite(record, this.blobValuesSupported)
) {
if (fresh) tobump.push(record.id);
else toexpire.push(record.id);
}
this.reportIfUnreadable(record);
leafStubs.push({
id: record.id,
dataUnavailable:
this.unreadableRecords.has(record.id) || undefined,
name: record.name,
type: record.type,
size: record.size,
@@ -579,6 +965,46 @@ class FileStorageService {
return cleared;
}
/**
* Superseded versions that nothing else needs once {@code deleting} goes.
*
* Deleting a file removes one record; its older versions keep their full bytes
* and are invisible (listings filter on isLeaf), so they accumulate forever.
* Only for user-facing "delete this file" - deleting ONE version from the
* history journey must leave the rest of the chain alone.
*/
async orphanedAncestorIds(deleting: FileId[]): Promise<FileId[]> {
if (deleting.length === 0) return [];
const stubs = await this.getAllStirlingFileStubs();
const byId = new Map(stubs.map((s) => [s.id as string, s]));
const doomed = new Set(deleting.map(String));
// Anything a surviving record descends from has to stay: split siblings
// share a lineage, so one leaf's delete must not strip another's history.
const keep = new Set<string>();
for (const stub of stubs) {
if (doomed.has(stub.id as string)) continue;
let cursor = stub.parentFileId as string | undefined;
while (cursor && !keep.has(cursor)) {
keep.add(cursor);
cursor = byId.get(cursor)?.parentFileId as string | undefined;
}
}
const orphans: FileId[] = [];
for (const id of deleting) {
let cursor = byId.get(String(id))?.parentFileId as string | undefined;
while (cursor) {
if (!keep.has(cursor) && !doomed.has(cursor) && byId.has(cursor)) {
doomed.add(cursor);
orphans.push(cursor as FileId);
}
cursor = byId.get(cursor)?.parentFileId as string | undefined;
}
}
return orphans;
}
/**
* Delete StirlingFile - single operation, no sync issues
*/
@@ -587,11 +1013,12 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const request = store.delete(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => resolve();
// On commit, not on the request: callers refresh their list from storage as
// soon as this resolves, and an aborted delete would put the row back.
settleOnAbort(transaction, reject);
transaction.onerror = () => reject(transaction.error);
transaction.oncomplete = () => resolve();
transaction.objectStore(this.storeName).delete(id);
});
}
@@ -617,45 +1044,16 @@ class FileStorageService {
* Update thumbnail for existing file
*/
async updateThumbnail(id: FileId, thumbnail: string): Promise<boolean> {
const db = await this.getDatabase();
return new Promise((resolve, _reject) => {
try {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const getRequest = store.get(id);
getRequest.onsuccess = () => {
const record = getRequest.result as StoredStirlingFileRecord;
if (record) {
record.thumbnail = thumbnail;
record.thumbnailStoredAt = Date.now();
const updateRequest = store.put(record);
updateRequest.onsuccess = () => {
resolve(true);
};
updateRequest.onerror = () => {
console.error("Failed to update thumbnail:", updateRequest.error);
resolve(false);
};
} else {
resolve(false);
}
};
getRequest.onerror = () => {
console.error(
"Failed to get file for thumbnail update:",
getRequest.error,
);
resolve(false);
};
} catch (error) {
console.error("Transaction error during thumbnail update:", error);
resolve(false);
}
});
// Reports failure as `false` rather than rejecting; callers just need an answer.
try {
return await this.updateRecord(id, (record) => {
record.thumbnail = thumbnail;
record.thumbnailStoredAt = Date.now();
});
} catch (error) {
console.error("Failed to update thumbnail:", error);
return false;
}
}
/**
@@ -666,6 +1064,7 @@ class FileStorageService {
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
settleOnAbort(transaction, reject);
const store = transaction.objectStore(this.storeName);
const request = store.clear();
@@ -720,24 +1119,16 @@ class FileStorageService {
async createBlobUrl(id: FileId): Promise<string | null> {
try {
const db = await this.getDatabase();
const record = await this.readRecord(db, id);
if (!record) return null;
return new Promise((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readonly");
const store = transaction.objectStore(this.storeName);
const request = store.get(id);
request.onerror = () => reject(request.error);
request.onsuccess = () => {
const record = request.result as StoredStirlingFileRecord | undefined;
if (record) {
const blob = new Blob([record.data], { type: record.type });
const url = URL.createObjectURL(blob);
resolve(url);
} else {
resolve(null);
}
};
});
// Stored blobs are handed straight to createObjectURL — re-wrapping
// one can cost WebKit the backing handle. See fileFromRecord.
const blob =
record.data instanceof Blob
? record.data
: new Blob([record.data], { type: record.type });
return URL.createObjectURL(blob);
} catch (error) {
console.warn(`Failed to create blob URL for ${id}:`, error);
return null;
@@ -750,32 +1141,9 @@ class FileStorageService {
*/
async markFileAsProcessed(fileId: FileId): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>(
(resolve, reject) => {
const request = store.get(fileId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
},
);
if (!record) {
return false; // File not found
}
// Update the isLeaf flag to false
record.isLeaf = false;
await new Promise<void>((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
return await this.updateRecord(fileId, (record) => {
record.isLeaf = false;
});
return true;
} catch (error) {
console.error("Failed to mark file as processed:", error);
return false;
@@ -835,32 +1203,9 @@ class FileStorageService {
*/
async markFileAsLeaf(fileId: FileId): Promise<boolean> {
try {
const db = await this.getDatabase();
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
const record = await new Promise<StoredStirlingFileRecord | undefined>(
(resolve, reject) => {
const request = store.get(fileId);
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
},
);
if (!record) {
return false; // File not found
}
// Update the isLeaf flag to true
record.isLeaf = true;
await new Promise<void>((resolve, reject) => {
const request = store.put(record);
request.onsuccess = () => resolve();
request.onerror = () => reject(request.error);
return await this.updateRecord(fileId, (record) => {
record.isLeaf = true;
});
return true;
} catch (error) {
console.error("Failed to mark file as leaf:", error);
return false;
@@ -870,41 +1215,16 @@ class FileStorageService {
/**
* Update metadata fields for a stored file record.
*
* Resolves on transaction.oncomplete, NOT on the individual put's onsuccess,
* so callers only receive `true` once the write actually commits. If the
* transaction aborts after put() succeeded but before commit, we return false
* - the previous behavior incorrectly claimed success in that window.
* Returns `true` only once the write commits, never on the put's `onsuccess`.
* {@link updateRecord} owns that guarantee for every write in this class.
*/
async updateFileMetadata(
fileId: FileId,
updates: Partial<StoredStirlingFileRecord>,
): Promise<boolean> {
try {
const db = await this.getDatabase();
return await new Promise<boolean>((resolve, reject) => {
const transaction = db.transaction([this.storeName], "readwrite");
const store = transaction.objectStore(this.storeName);
let recordFound = false;
const getRequest = store.get(fileId);
getRequest.onsuccess = () => {
const record = getRequest.result as
| StoredStirlingFileRecord
| undefined;
if (!record) {
// Don't commit anything; caller wants false.
return;
}
recordFound = true;
const updatedRecord = { ...record, ...updates };
store.put(updatedRecord);
};
getRequest.onerror = () => reject(getRequest.error);
transaction.oncomplete = () => resolve(recordFound);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () =>
reject(transaction.error ?? new Error("updateFileMetadata aborted"));
return await this.updateRecord(fileId, (record) => {
Object.assign(record, updates);
});
} catch (error) {
console.error("Failed to update file metadata:", error);
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import "fake-indexeddb/auto";
import { expectConsole } from "@app/tests/failOnConsole";
import type { DatabaseConfig } from "@app/services/indexedDBManager";
/**
* A blocked open fires `blocked` and then nothing at all - no success, no error -
* until the other connection goes away. Unguarded, the open promise never settles
* and every caller hangs SILENTLY: the file library spun forever with an empty
* console, which is why this kept being reported as unreproducible.
*/
const config = (name: string, version: number): DatabaseConfig => ({
name,
version,
stores: [{ name: "things", keyPath: "id" }],
});
/** A raw connection on an older version that never yields, i.e. the other tab. */
function holdOlderVersion(name: string): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(name, 1);
request.onupgradeneeded = () => {
if (!request.result.objectStoreNames.contains("things")) {
request.result.createObjectStore("things", { keyPath: "id" });
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
afterEach(() => {
vi.useRealTimers();
vi.resetModules();
});
describe("openDatabase — blocked by another connection", () => {
test("rejects with something actionable instead of hanging", async () => {
expectConsole.warn(/blocked by another connection/);
const { indexedDBManager } = await import("@app/services/indexedDBManager");
const held = await holdOlderVersion("blocked-db");
vi.useFakeTimers();
const open = indexedDBManager.openDatabase(config("blocked-db", 2));
const settled = vi.fn();
void open.then(settled, settled);
// Still pending before the grace period is up: a tab that yields quickly
// must not be failed prematurely.
await vi.advanceTimersByTimeAsync(4_000);
expect(settled).not.toHaveBeenCalled();
await vi.advanceTimersByTimeAsync(2_000);
await expect(open).rejects.toThrow(/blocked by another connection/);
held.close();
});
test("dedupes concurrent callers onto one connection", async () => {
const { indexedDBManager } = await import("@app/services/indexedDBManager");
const spy = vi.spyOn(indexedDB, "open");
// Racing in the same tick is the case registration-after-await could not
// dedupe, and only the first request would ever receive `blocked`.
const [a, b, c] = await Promise.all([
indexedDBManager.openDatabase(config("shared-db", 1)),
indexedDBManager.openDatabase(config("shared-db", 1)),
indexedDBManager.openDatabase(config("shared-db", 1)),
]);
expect(a).toBe(b);
expect(b).toBe(c);
expect(
spy.mock.calls.filter(([name]) => name === "shared-db"),
).toHaveLength(1);
spy.mockRestore();
});
});
@@ -19,6 +19,11 @@ export interface DatabaseConfig {
}[];
}
/** How long to wait out another connection before failing an open with something
* the user can act on. Rejecting does NOT cancel the request, so a connection
* that arrives later is closed rather than held. */
const BLOCKED_GRACE_MS = 5000;
class IndexedDBManager {
private static instance: IndexedDBManager;
private databases = new Map<string, IDBDatabase>();
@@ -47,6 +52,26 @@ class IndexedDBManager {
return existingPromise;
}
// Registered BEFORE anything async. A map written after a yield point can't
// dedupe callers racing into it in the same tick, so every context that opened
// this database during boot got its own connection - and per spec only the
// FIRST request ever receives `blocked`, leaving the rest waiting on an event
// that never comes.
const initPromise = this.openWithRecovery(config);
this.initPromises.set(config.name, initPromise);
try {
const db = await initPromise;
this.databases.set(config.name, db);
return db;
} catch (error) {
this.initPromises.delete(config.name);
throw error;
}
}
/** The v6/v7 wipe, kept off {@link openDatabase}'s synchronous registration path. */
private async openWithRecovery(config: DatabaseConfig): Promise<IDBDatabase> {
// SaaS lineage shipped a v6 and a v7 of stirling-pdf-files whose
// upgrade paths corrupted records (separate cursor walks racing in
// one versionchange transaction). The SaaS build wipes those
@@ -64,18 +89,7 @@ class IndexedDBManager {
await this.deleteDatabase(config.name);
}
}
const initPromise = this.performDatabaseInit(config);
this.initPromises.set(config.name, initPromise);
try {
const db = await initPromise;
this.databases.set(config.name, db);
return db;
} catch (error) {
this.initPromises.delete(config.name);
throw error;
}
return this.performDatabaseInit(config);
}
private performDatabaseInit(config: DatabaseConfig): Promise<IDBDatabase> {
@@ -83,15 +97,60 @@ class IndexedDBManager {
console.log(`Opening IndexedDB: ${config.name} v${config.version}`);
const request = indexedDB.open(config.name, config.version);
// A blocked upgrade fires `blocked` and then NOTHING - no success, no error -
// until the other connection goes away. Unguarded, the promise never settles
// and every awaiting caller hangs with nothing in the console.
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
request.onblocked = () => {
console.warn(
`Opening ${config.name} is blocked by another connection (another tab on an older version?). ` +
`Giving up in ${BLOCKED_GRACE_MS}ms if it doesn't yield.`,
);
blockedTimer = setTimeout(() => {
if (settled) return;
settled = true;
reject(
new Error(
`Opening ${config.name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms. ` +
"Close other tabs of this app and reload.",
),
);
}, BLOCKED_GRACE_MS);
};
request.onerror = () => {
clearTimeout(blockedTimer);
if (settled) return;
settled = true;
console.error(`Failed to open ${config.name}:`, request.error);
reject(request.error);
};
request.onsuccess = () => {
clearTimeout(blockedTimer);
const db = request.result;
// We already gave up waiting: close it rather than hold a handle nobody
// awaits, or we become the next tab's blocker.
if (settled) {
db.close();
return;
}
settled = true;
console.log(`Successfully opened ${config.name}`);
// Another tab wants a newer schema. Forget BEFORE closing: a cached but
// closed handle is worse than none, because every transaction on it throws.
db.onversionchange = () => {
console.warn(
`${config.name}: another tab requested a version change; closing this connection`,
);
this.databases.delete(config.name);
this.initPromises.delete(config.name);
db.close();
};
// Set up close handler to clean up our references
db.onclose = () => {
console.log(`Database ${config.name} closed`);
@@ -329,9 +388,36 @@ class IndexedDBManager {
return new Promise((resolve, reject) => {
const deleteRequest = indexedDB.deleteDatabase(name);
// A delete blocks exactly like an upgrade, and this one is awaited on the
// files open path - so an unguarded block hangs the whole storage layer.
let settled = false;
let blockedTimer: ReturnType<typeof setTimeout> | undefined;
deleteRequest.onerror = () => reject(deleteRequest.error);
deleteRequest.onblocked = () => {
console.warn(
`Deleting ${name} is blocked by another connection; giving up in ${BLOCKED_GRACE_MS}ms.`,
);
blockedTimer = setTimeout(() => {
if (settled) return;
settled = true;
reject(
new Error(
`Deleting ${name} was blocked by another connection for ${BLOCKED_GRACE_MS}ms.`,
),
);
}, BLOCKED_GRACE_MS);
};
deleteRequest.onerror = () => {
clearTimeout(blockedTimer);
if (settled) return;
settled = true;
reject(deleteRequest.error);
};
deleteRequest.onsuccess = () => {
clearTimeout(blockedTimer);
if (settled) return;
settled = true;
console.log(`Deleted database: ${name}`);
resolve();
};
@@ -343,17 +429,32 @@ class IndexedDBManager {
*/
async getDatabaseVersion(name: string): Promise<number | null> {
return new Promise((resolve) => {
// This probe runs BEFORE the guarded open, and a versionless open can be
// delayed indefinitely by another tab mid-versionchange. Unknown after the
// grace period beats hanging every storage consumer: the real open that
// follows has its own blocked guard and a message the user can act on.
const giveUp = setTimeout(() => {
console.warn(
`Version probe for ${name} did not answer in ${BLOCKED_GRACE_MS}ms; proceeding without it.`,
);
resolve(null);
}, BLOCKED_GRACE_MS);
const request = indexedDB.open(name);
request.onsuccess = () => {
clearTimeout(giveUp);
const db = request.result;
const version = db.version;
db.close();
resolve(version);
};
request.onerror = () => resolve(null);
request.onerror = () => {
clearTimeout(giveUp);
resolve(null);
};
request.onupgradeneeded = () => {
// Cancel the upgrade
request.transaction?.abort();
clearTimeout(giveUp);
resolve(null);
};
});
@@ -0,0 +1,75 @@
import { describe, expect, test, vi } from "vitest";
/**
* A WASM instantiate that fails must reject, not hang. `instantiateWasm` reports
* success by callback, so a swallowed rejection leaves `init()` pending and takes
* every thumbnail, page parse and form read with it - silently.
*/
const init = vi.hoisted(() => vi.fn());
vi.mock("@embedpdf/pdfium", () => ({ init }));
const wasmModule = vi.hoisted(() => ({}) as WebAssembly.Module);
vi.mock("@app/services/wasmPrecompiler", () => ({
pdfiumWasmModulePromise: Promise.resolve(wasmModule),
startEagerWasmCompilation: () => {},
pdfiumWasmUrl: "http://localhost/pdfium.wasm",
}));
/** emscripten's contract: it calls instantiateWasm and waits to be called back. */
function emscriptenInit(
instantiate: (imports: object, ok: () => void) => void,
) {
return new Promise(() => {
instantiate({}, () => {});
});
}
async function loadService() {
vi.resetModules();
return await import("@app/services/pdfiumService");
}
describe("pdfium bootstrap", () => {
test("rejects when instantiating the pre-compiled module fails", async () => {
const failure = new Error("LinkError: import mismatch");
vi.spyOn(WebAssembly, "instantiate").mockRejectedValue(failure as never);
init.mockImplementation((overrides: Record<string, never>) =>
emscriptenInit(
overrides.instantiateWasm as unknown as (
imports: object,
ok: () => void,
) => void,
),
);
const { getPdfiumModule } = await loadService();
// Before the fix this never settled, so the test timed out.
await expect(getPdfiumModule()).rejects.toThrow(/LinkError/);
});
test("a failed load isn't cached, so the next call retries", async () => {
const instantiate = vi
.spyOn(WebAssembly, "instantiate")
.mockRejectedValueOnce(new Error("transient") as never)
.mockResolvedValue({} as never);
const ready = { PDFiumExt_Init: () => {} };
init.mockImplementation(
(overrides: Record<string, never>) =>
new Promise((resolve) => {
const instantiateWasm = overrides.instantiateWasm as unknown as (
imports: object,
ok: () => void,
) => void;
instantiateWasm({}, () => resolve(ready));
}),
);
const { getPdfiumModule } = await loadService();
await expect(getPdfiumModule()).rejects.toThrow(/transient/);
await expect(getPdfiumModule()).resolves.toBe(ready);
expect(instantiate).toHaveBeenCalledTimes(2);
});
});
@@ -80,17 +80,24 @@ function wasmUrl(): string {
* This is the low-level PDFium WASM interface with all C functions wrapped.
* Prefer `withDocument()` for document-scoped work.
*/
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
if (_module) return _module;
if (!_initPromise) {
// Ensure eager compilation has started if PDF service is requested before idle timeout
startEagerWasmCompilation();
/** Reuses the WASM pre-compiled at boot. Every failure must reach this promise:
* `instantiateWasm` reports success by callback, so a rejection inside it leaves
* `init()` pending forever - and with it every thumbnail, parse and form read. */
async function initPdfiumModule(): Promise<WrappedPdfiumModule> {
// Ensure eager compilation has started if PDF service is requested before idle timeout
startEagerWasmCompilation();
const overrides: PdfiumModuleOverrides = {
locateFile: () => wasmUrl(),
};
const overrides: PdfiumModuleOverrides = { locateFile: () => wasmUrl() };
const precompiled = await pdfiumWasmModulePromise;
// Eagerly reuse pre-compiled WASM module from app boot if available
let reportFailure: (error: unknown) => void = () => {};
const instantiateFailed = new Promise<never>((_, reject) => {
reportFailure = reject;
});
// No pre-compiled module: leave instantiateWasm alone so emscripten fetches the
// WASM itself and rejects init() on failure, instead of a fallback that can't.
if (precompiled) {
overrides.instantiateWasm = (
imports: WebAssembly.Imports,
successCallback: (
@@ -98,40 +105,34 @@ export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
module: WebAssembly.Module,
) => void,
) => {
pdfiumWasmModulePromise
.then((wasmModule) => {
if (wasmModule) {
return WebAssembly.instantiate(wasmModule, imports).then(
(instance) => {
successCallback(instance, wasmModule);
},
);
} else {
throw new Error("No pre-compiled WASM module found");
}
})
.catch((err: unknown) => {
console.warn(
"Eager WebAssembly instantiation failed, falling back to streaming compilation:",
err,
);
WebAssembly.instantiateStreaming(fetch(wasmUrl()), imports).then(
(result) => {
successCallback(result.instance, result.module);
},
);
});
WebAssembly.instantiate(precompiled, imports)
.then((instance) => successCallback(instance, precompiled))
.catch(reportFailure);
};
}
_initPromise = init(overrides as Partial<PdfiumModule>).then((m) => {
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
try {
m.PDFiumExt_Init();
} catch {
/* already initialized */
}
_module = m;
return m;
const m = await Promise.race([
init(overrides as Partial<PdfiumModule>),
instantiateFailed,
]);
// Call PDFiumExt_Init to ensure extensions (form fill etc.) are set up
try {
m.PDFiumExt_Init();
} catch {
/* already initialized */
}
_module = m;
return m;
}
export async function getPdfiumModule(): Promise<WrappedPdfiumModule> {
if (_module) return _module;
if (!_initPromise) {
_initPromise = initPdfiumModule().catch((error: unknown) => {
// Don't cache the failure: every PDF feature in the app goes through here,
// so a transient WASM fetch would take them all down for the session.
_initPromise = null;
throw error;
});
}
return _initPromise;
+4
View File
@@ -2,6 +2,10 @@ import "@testing-library/jest-dom";
import { vi } from "vitest";
import { installFailOnConsole } from "@app/tests/failOnConsole";
// jsdom is missing the same APIs WebKit is, so tests must agree with the
// browser. Same module `src/index.tsx` installs.
import "@app/utils/engineShims";
installFailOnConsole();
// Mock localStorage for tests
@@ -68,6 +68,8 @@ const mockedApiClient = vi.mocked(apiClient);
// Mock only essential services that are actually called by the tests
vi.mock("../../services/fileStorage", () => ({
// FileContext subscribes to this to drop files whose bytes are unreadable.
onRecordUnreadable: () => () => {},
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
@@ -66,6 +66,8 @@ const mockedApiClient = vi.mocked(apiClient);
// Mock only essential services that are actually called by the tests
vi.mock("../../services/fileStorage", () => ({
// FileContext subscribes to this to drop files whose bytes are unreadable.
onRecordUnreadable: () => () => {},
fileStorage: {
init: vi.fn().mockResolvedValue(undefined),
storeFile: vi.fn().mockImplementation((file, thumbnail) => {
@@ -131,6 +131,9 @@ export interface MockAppApiOptions {
languages?: string[];
/** Default locale. */
defaultLocale?: string;
/** Advertise server-side storage. Off by default - a spec that stubs or
* blocks `/api/v1/storage/files` must set this or the route is never hit. */
storageEnabled?: boolean;
/** Merge overrides into the endpoint availability map. */
endpointsAvailability?: Record<string, { enabled: boolean }>;
/** Backend probe status. Set to `"DOWN"` to exercise offline-mode UI. */
@@ -156,6 +159,7 @@ export async function mockAppApis(
},
languages = ["en-US"],
defaultLocale = "en-US",
storageEnabled = false,
endpointsAvailability = {},
backendStatus = "UP",
} = opts;
@@ -173,6 +177,7 @@ export async function mockAppApis(
isAdmin,
languages,
defaultLocale,
storageEnabled,
},
}),
);
@@ -230,4 +230,7 @@ test.describe("Compare tool slot selection", () => {
page.locator('[data-testid="compare-slot-comparison"]'),
).toHaveAttribute("data-slot-state", "empty");
});
// These specs stop at slot state. Actually running a comparison lives in
// `engine-capabilities.spec.ts`, which is cross-browser in PR CI.
});
@@ -0,0 +1,138 @@
/** Runs on all three engines in PR CI, asserting on evidence that can only exist
* if the engine did the work. Keep small - it is paid for three times per PR. */
import path from "path";
import type { Page } from "@playwright/test";
import { test, expect } from "@app/tests/helpers/stub-test-base";
import { dismissTourTooltip, uploadFiles } from "@app/tests/helpers/ui-helpers";
const FIXTURES_DIR = path.join(import.meta.dirname, "../test-fixtures");
const SAMPLE_PDF = path.join(FIXTURES_DIR, "sample.pdf");
const PDF_A = path.join(FIXTURES_DIR, "compare_sample_a.pdf");
const PDF_B = path.join(FIXTURES_DIR, "compare_sample_b.pdf");
/** A missing global or prototype method always surfaces as one of these.
* Matching the shape keeps benign engine noise out (console-clean.spec.ts). */
const MISSING_API_ERROR =
/is not a function|is not a constructor|undefined is not an object|has no method/i;
/** Collect the "this engine lacks an API we used" errors seen on the page. */
function recordMissingApiErrors(page: Page): string[] {
const errors: string[] = [];
page.on("pageerror", (error: Error) => {
const text = String(error);
if (MISSING_API_ERROR.test(text)) errors.push(text);
});
return errors;
}
async function fillCompareSlot(
page: Page,
role: "base" | "comparison",
filePath: string,
) {
await page
.getByTestId(`compare-slot-${role}-add-input`)
.setInputFiles(filePath);
await expect(
page.locator(`[data-testid="compare-slot-${role}"]`),
).toHaveAttribute("data-slot-state", "filled", { timeout: 20_000 });
// The upload modal's overlay outlives its close transition and eats clicks.
await page
.locator(".mantine-Modal-overlay")
.waitFor({ state: "detached", timeout: 5_000 })
.catch(() => {
/* already gone */
});
}
test.describe("engine capabilities", { tag: "@engine-capability" }, () => {
test("extracts PDF text and completes a comparison", async ({ page }) => {
test.setTimeout(120_000);
const missingApis = recordMissingApiErrors(page);
await page.locator('[data-tour="tool-button-compare"]').first().click();
await page.waitForSelector('[data-testid="compare-slot-base"]', {
timeout: 20_000,
});
await fillCompareSlot(page, "base", PDF_A);
await fillCompareSlot(page, "comparison", PDF_B);
// By test id: `name` matches as a substring, so "Compare" also hits the
// tool button that opened this panel.
await page.getByTestId("compare-execute").click();
// Counted results, not headings: an extraction returning nothing still
// renders empty panes, which is how the WebKit failure looked like success.
const deletions = page.getByText(/Deletions \((\d+)\)/);
const additions = page.getByText(/Additions \((\d+)\)/);
await expect(deletions).toBeVisible({ timeout: 60_000 });
await expect(additions).toBeVisible();
expect(await deletions.innerText()).not.toMatch(/\(0\)/);
expect(await additions.innerText()).not.toMatch(/\(0\)/);
expect(missingApis, "no missing-API errors during comparison").toEqual([]);
});
test("rasterises page thumbnails via the PDF engine", async ({ page }) => {
test.setTimeout(120_000);
const missingApis = recordMissingApiErrors(page);
await uploadFiles(page, SAMPLE_PDF);
await dismissTourTooltip(page);
// A page thumbnail only exists if the WASM engine loaded, rendered and
// encoded. When it fails the grid still renders, just with no <img>.
await page.getByText("PDF Multi Tool", { exact: true }).first().click();
const thumbnail = page
.locator("[data-page-id] img[data-original-rotation]")
.first();
await expect(thumbnail).toBeVisible({ timeout: 60_000 });
// An empty encode still yields a src; require enough payload to be real.
const src = await thumbnail.getAttribute("src");
expect(src ?? "").toMatch(/^data:image\//);
expect(src?.length ?? 0).toBeGreaterThan(1_000);
expect(missingApis, "no missing-API errors during thumbnailing").toEqual(
[],
);
});
test("reads a stored file's bytes back after a reload", async ({ page }) => {
test.setTimeout(120_000);
const missingApis = recordMissingApiErrors(page);
await uploadFiles(page, SAMPLE_PDF);
// Full reload: FileContext rehydrates from IndexedDB, not from memory.
await page.reload({ waitUntil: "domcontentloaded" });
const restored = page.locator(".file-sidebar-file-item").first();
await expect(restored).toBeVisible({ timeout: 30_000 });
// Rendering it is the assertion that matters: the metadata record survives
// even when the bytes were never stored, so a filename proves nothing.
await restored.hover();
await restored
.locator(".file-sidebar-eye-btn")
.click({ timeout: 15_000, force: true });
const firstPage = page.locator('[data-page-index="0"]').first();
await expect(firstPage).toBeVisible({ timeout: 60_000 });
// A tile that decoded has non-zero naturalWidth. A blob stored but not
// readable back resolves to nothing, and renders as an empty page.
const tile = firstPage.locator('img[src^="blob:"]').first();
await expect(tile).toBeAttached({ timeout: 30_000 });
await expect
.poll(() => tile.evaluate((img: HTMLImageElement) => img.naturalWidth), {
timeout: 30_000,
})
.toBeGreaterThan(0);
expect(missingApis, "no missing-API errors after rehydration").toEqual([]);
});
});
@@ -0,0 +1,140 @@
/** The sidebar and file pickers must reach a resting state even when storage
* can't be read. Blocks the database for real, then asserts the UI recovers. */
import path from "path";
import { test, expect } from "@app/tests/helpers/stub-test-base";
const SAMPLE_PDF = path.join(
import.meta.dirname,
"../test-fixtures/sample.pdf",
);
/** The app opens `stirling-pdf-files` at this version; block it from below. */
const BLOCKING_VERSION = 8;
/** Park a connection on an older version and never yield it, the way a tab
* running an older build does, so the app's upgrade can't proceed. */
async function blockTheFilesDatabase(page: import("@playwright/test").Page) {
await page.addInitScript((version: number) => {
const request = indexedDB.open("stirling-pdf-files", version);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains("files")) {
db.createObjectStore("files", { keyPath: "id" });
}
};
request.onsuccess = () => {
// Deliberately NO onversionchange handler: hold the database open.
(window as unknown as { __blocker?: IDBDatabase }).__blocker =
request.result;
};
}, BLOCKING_VERSION);
}
/** Closing the context is NOT enough: WebKit keeps origin databases between
* contexts, so a stray v8 makes a random later spec fail. */
async function unblockTheFilesDatabase(page: import("@playwright/test").Page) {
await page.evaluate(async () => {
const holder = window as unknown as { __blocker?: IDBDatabase };
holder.__blocker?.close();
holder.__blocker = undefined;
await new Promise<void>((resolve) => {
const request = indexedDB.deleteDatabase("stirling-pdf-files");
request.onsuccess = () => resolve();
request.onerror = () => resolve();
request.onblocked = () => resolve();
});
});
}
test.use({ autoGoto: false });
test("the sidebar stops loading even when the file library is unreadable", async ({
page,
}) => {
// Allow for the manager's blocked-upgrade grace period plus app boot.
test.setTimeout(120_000);
const warnings: string[] = [];
page.on("console", (message) => {
if (message.type() === "warning" || message.type() === "error") {
warnings.push(message.text());
}
});
await blockTheFilesDatabase(page);
await page.goto("/", { waitUntil: "domcontentloaded" });
try {
// Storage is not on the critical path for rendering the workbench.
await page.waitForSelector('[data-tour="tool-button-compare"]', {
timeout: 60_000,
});
// The spinner is the whole bug: it must clear once the manager gives up.
await expect(page.locator(".file-sidebar-loading")).toHaveCount(0, {
timeout: 60_000,
});
// The reason must be discoverable - a silent console made the original
// report impossible to diagnose.
expect(
warnings.some((text) => /blocked by another open connection/i.test(text)),
`expected a blocked-database warning, got: ${JSON.stringify(warnings)}`,
).toBe(true);
} finally {
await unblockTheFilesDatabase(page);
}
});
// `loadRecentFiles` only fetches `/api/v1/storage/files` when app-config
// advertises `storageEnabled`; without it the route below is never requested.
test.describe("with server-side storage enabled", () => {
test.use({ stubOptions: { storageEnabled: true } });
test("the picker's Workbench tab lists files while saved files are still loading", async ({
page,
}) => {
test.setTimeout(120_000);
// Hold the saved-files read open so the picker stays stuck all test.
let sawSavedFilesRequest = false;
await page.route("**/api/v1/storage/files", async () => {
sawSavedFilesRequest = true;
await new Promise(() => {
/* never responds */
});
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await page.waitForSelector('[data-tour="tool-button-compare"]', {
timeout: 60_000,
});
// Put a file in the workbench so the Workbench tab has something to show.
await page.getByTestId("files-button").click();
await page.locator('[data-testid="file-input"]').setInputFiles(SAMPLE_PDF);
await expect(page.locator(".file-sidebar-file-item").first()).toBeVisible({
timeout: 30_000,
});
await page.locator('[data-tour="tool-button-compare"]').first().click();
await page.getByTestId("compare-slot-base-add").click();
// The picker must actually be stuck, or the rest proves nothing.
const picker = page.locator(".mantine-Popover-dropdown");
await expect(picker).toBeVisible({ timeout: 30_000 });
await expect
.poll(() => sawSavedFilesRequest, { timeout: 30_000 })
.toBe(true);
// Workbench lists from memory, so a stuck read must not hide it. Scope to
// the popover: the sidebar row behind it matches the same locator.
await picker
.getByRole("button", { name: "Workbench", exact: true })
.click();
await expect(
picker.getByRole("button", { name: /sample\.pdf/ }).first(),
).toBeVisible({ timeout: 30_000 });
});
});

Some files were not shown because too many files have changed in this diff Show More