Files
Stirling-PDF/frontend/editor/vitest.config.ts
T
Anthony StirlingandSaul 6143610608 fix(frontend): preserve pdf link targets in desktop viewer (#7235)
Same PR as #6396, just with the conflicts resolved and some fixes on
top. Original commit by @saul1310 is preserved as-is; everything else is
a follow-up commit.

Refs #6272

## Conflicts

#6396 was written before the frontend was restructured, so all four
files it touched moved (`frontend/src/**` -> `frontend/editor/src/**`)
and `LinkLayer.tsx` had drifted. Cherry-picked with rename detection and
re-resolved against current `main`.

## Fixes on top

- **Reuse the existing platform seam instead of adding a second one.**
`main` already has `@app/platform/*` seams with per-flavour
implementations; #6396 added a parallel `@app/utils/openExternalUrl`
core+desktop pair that re-implemented the Tauri shell call already in
`desktop/platform/openExternal.ts`. Split into a pure sanitiser
(`@app/utils/externalUrl`) and a platform seam
(`@app/platform/openExternalTab`), with the desktop impl delegating to
the existing `openExternal`.
- **Kept PDF links off the `openExternal` seam.** That seam is for
leave-and-return redirects (Stripe) and its saas impl is
`window.location.assign` - routing PDF links through it would navigate
the whole app away from the user's document. `openExternalTab` always
opens alongside the app; desktop shadows it to escape the webview.
- **Fixed the same defect in two sibling call sites** that #6396 didn't
cover: `BookmarkSidebar` (bookmark URI / LaunchAppOrOpenFile actions)
and `useAnnotationMenuHandlers` (annotation menu "go to link"). Both
called `window.open` on an unsanitised PDF-supplied URI, so on desktop
they trapped the link in the webview exactly like the viewer did.
- **Dropped the unguarded fallback.** The old code fell back to
`window.open(uri)` when `new URL()` threw, so an unparseable URI
bypassed the allowlist entirely. It is now blocked.
- Empty/whitespace URIs are blocked rather than silently resolving to
the app's own page via the base URL.
- Tests: sanitiser cases (casing, leading whitespace, `data:`,
`vbscript:`, unparseable), a core seam test asserting
new-tab-not-navigate, and a desktop seam regression test asserting the
URL goes to the OS rather than `window.open`.
- **`openExternalTab` now re-validates its own input.** Every caller
sanitises first, so nothing reached it unvalidated - but it is the sink
that hands a URL to `window.open` (executes `javascript:` in our origin)
or to an OS handler on desktop, and its safety shouldn't depend on
callers remembering. Both impls fail closed, with tests that call them
directly with `javascript:`/`data:`/`file:`/`ftp:`.

## Unrelated fix included (flagged deliberately)

The last commit fixes `frontend/editor/vitest.config.ts`: `testTimeout:
10000` was set on the root `test` block, but tests all run under
`projects`, which do not inherit it - so the whole suite has silently
been running at vitest's 5s default.

This is not cosmetic. It made `task check` fail intermittently on
unrelated portal specs (`demoData`, `ConnectionModal`); the ConsignO
test takes 2966ms with only the portal project running, i.e. 59% of a
budget it was never meant to have, so any CPU contention tips it over.
Proven with an identical 6.5s probe test: times out at 5000ms on the old
config, passes at 6512ms on the fixed one.

Happy to split this into its own PR if preferred - it is here because
the gate could not be trusted without it.

## Validation

Typecheck passes for all 7 build flavours (core, proprietary, saas,
desktop, cloud, prototypes, portal); ESLint, Prettier, dpdm and the full
1662-test vitest suite pass.

Driven live against the dev server + backend with a PDF carrying five
URI annotations (https, `javascript:`, mailto, `file:`, relative). 14/14
behavioural checks pass on this branch; 5 of them fail on `main`:

| check | main | this PR |
| --- | --- | --- |
| safe https link exposes real href (copy-link) | `href="#"` |
`https://example.com/safe-link?a=1` |
| link opens in new tab / tabnabbing-proof | no `target`/`rel` |
`_blank` + `noopener noreferrer` |
| mailto link exposes real href | `href="#"` | `mailto:test@example.com`
|
| relative URI resolved against app origin | `href="#"` | resolved |
| `javascript:` / `file:` never reach href | blocked | blocked |
| clicking blocked link doesn't execute or navigate | ok | ok |
| clicking safe link opens new tab at source URL | - | ok, app not
navigated away |

---

## 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)
- [x] I have performed a self-review of my own code
- [x] My changes generate no new warnings

### Testing (if applicable)

- [x] I have run `task check` to verify linters, typechecks, and tests
pass
- [x] I have tested my changes locally

---------

Co-authored-by: Saul <saulifshin.cs@gmail.com>
2026-08-14 18:07:42 +01:00

162 lines
4.2 KiB
TypeScript

import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react-swc";
import tsconfigPaths from "vite-tsconfig-paths";
// Projects do NOT inherit the root test.testTimeout, so every project silently
// ran at vitest's 5s default. Spread this into each one instead.
const TIMEOUTS = { testTimeout: 10000, hookTimeout: 10000 };
export default defineConfig({
test: {
globals: true,
environment: "jsdom",
setupFiles: ["./src/core/setupTests.ts"],
css: false,
exclude: [
"node_modules/",
"src/**/*.spec.ts", // Exclude Playwright E2E tests
"src/tests/test-fixtures/**",
],
...TIMEOUTS,
coverage: {
reporter: ["text", "json", "html"],
exclude: [
"node_modules/",
"src/core/setupTests.ts",
"src/proprietary/setupTests.ts",
"src/saas/setupTests.ts",
"**/*.d.ts",
"src/tests/test-fixtures/**",
"src/**/*.spec.ts",
],
},
projects: [
{
test: {
name: "core",
...TIMEOUTS,
include: ["src/core/**/*.test.{ts,tsx}"],
environment: "jsdom",
globals: true,
setupFiles: ["./src/core/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
projects: ["./tsconfig.core.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
{
test: {
name: "portal",
...TIMEOUTS,
include: ["src/portal/**/*.test.{ts,tsx}"],
environment: "jsdom",
globals: true,
setupFiles: ["./src/portal/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
// Broad project so @app/@portal resolve in every editor file the
// portal tests pull in (core/ui, core, ...).
projects: ["./tsconfig.portal.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
{
test: {
name: "proprietary",
...TIMEOUTS,
include: ["src/proprietary/**/*.test.{ts,tsx}"],
environment: "jsdom",
globals: true,
setupFiles: ["./src/core/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
projects: ["./tsconfig.proprietary.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
{
test: {
name: "desktop",
...TIMEOUTS,
include: ["src/desktop/**/*.test.{ts,tsx}"],
environment: "jsdom",
globals: true,
setupFiles: ["./src/core/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
projects: ["./tsconfig.desktop.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
{
test: {
name: "saas",
...TIMEOUTS,
// src/saas = editor-saas layer; src/portal-saas = the portal's saas
// overrides (sibling to src/portal). Both build under the saas flavor,
// so both resolve @portal via the saas cascade (tsconfig.saas.vite.json).
include: [
"src/saas/**/*.test.{ts,tsx}",
"src/portal-saas/**/*.test.{ts,tsx}",
],
environment: "jsdom",
globals: true,
setupFiles: ["./src/saas/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
projects: ["./tsconfig.saas.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
{
test: {
name: "prototypes",
...TIMEOUTS,
include: ["src/prototypes/**/*.test.{ts,tsx}"],
environment: "jsdom",
globals: true,
setupFiles: ["./src/core/setupTests.ts"],
},
plugins: [
react(),
tsconfigPaths({
projects: ["./tsconfig.prototypes.vite.json"],
}),
],
esbuild: {
target: "es2020",
},
},
],
},
esbuild: {
target: "es2020",
},
});