openpencil/tests/engine/app/document/io/browser.test.ts

82 lines
2.8 KiB
TypeScript
Raw Normal View History

feat: open documents and layers from openpencil:// and web links (#708) * feat(desktop): register openpencil:// deep link scheme Signed-off-by: Marc Went <marc@went.io> * feat(desktop): parse openpencil://open?file&node links Signed-off-by: Marc Went <marc@went.io> * feat(desktop): queue openpencil:// links as pending opens Signed-off-by: Marc Went <marc@went.io> * fix(desktop): read cold-start deep links on windows/linux Signed-off-by: Marc Went <marc@went.io> * feat(app): resolve openpencil:// links and select the target node A link's file is repo-relative, so it is resolved against the paths of the open tabs and otherwise located once by the user through the dialog picker. Nothing else is read from disk and no fs scope is widened. The node is matched by exact name on the current page, selected and zoomed to; a missing node raises a notice instead of failing silently. Signed-off-by: Marc Went <marc@went.io> * docs: document the openpencil:// URL scheme Describe the link format, the relative-path rule, how the file is resolved against open tabs or a one-time picker, and that the scheme can only open a document and select a layer. Signed-off-by: Marc Went <marc@went.io> * test(app): cover cancelled deep-link picks Inject the file picker and open entry points into openDeepLink so the test can drive the branch where the picked file is not the requested one. The repository lint forbids module registry mocking, and the existing file batch helper takes its opener the same way. Reword the module comment: the opened file joins the recent-files list like any other opened file, and a one-segment file matches the first open tab whose path ends with it. Signed-off-by: Marc Went <marc@went.io> * docs: sharpen the URL scheme notes Selecting by name selects every layer with that name on the current page and zooms to the whole selection. Record that the first matching open tab wins, that path separators may stay literal in the query, and how the scheme reaches the app on each platform. Signed-off-by: Marc Went <marc@went.io> * refactor(app): keep the deep-link io type internal Nothing outside the module names the injected io type, so contextual typing at the call site is enough. Drop the redundant recording array from the cancelled pick test. Signed-off-by: Marc Went <marc@went.io> * fix(desktop): tag pending opens by producer The frontend classified a pending entry by the shape of its path, which called a canonicalized Windows path (`\\?\C:\…`) relative and sent a double-clicked document into the deep-link resolver. Rust now says which producer queued the entry, and the tail both producers shared moves into `queue_pending`. Signed-off-by: Marc Went <marc@went.io> * test(app): assert the opener receives the resolved path Signed-off-by: Marc Went <marc@went.io> * test(desktop): refuse a percent-encoded parent segment Signed-off-by: Marc Went <marc@went.io> * chore(desktop): relax the deep-link plugin pin Signed-off-by: Marc Went <marc@went.io> * chore(desktop): drop the unused deep-link capability Draining links is Rust-side, so the webview never calls `deep-link:allow-get-current`. Signed-off-by: Marc Went <marc@went.io> * fix(app): clamp link values in notices Signed-off-by: Marc Went <marc@went.io> * fix(desktop): pass deep links through the linux desktop entry The bundler's default desktop template writes `Exec={{exec}}` with no field code, so a Linux cold start from a deb, rpm or AppImage never receives the `openpencil://` link as an argument and `get_current()` has nothing to recover. Ship a custom template that is the bundler default plus `%U`, wired to both the deb and rpm bundlers (AppImage reuses the deb data dir). MIME types still come from `{{mime_type}}`, so the file associations are unchanged. Signed-off-by: Marc Went <marc@went.io> * feat(app): open documents from ?file= links in the browser The desktop build takes openpencil:// links; the web app had no equivalent. It now reads file and node off its own address bar on boot, fetches the document from an absolute https URL without credentials and without following redirects, selects the named layer through the same path the deep link uses, and strips both params so a reload does not re-open. Signed-off-by: Marc Went <marc@went.io> * fix(app): keep router state coherent when stripping web link params Rewriting history directly left the router's own record of the current URL pointing at the un-stripped one, so the next router.push wrote file and node back into the history entry. The strip is now an injected action that goes through router.replace, preserving the route, hash and every other query key. Also clamp the failure detail, take the last value of a repeated key like the desktop parser does, share deep-link's clamp instead of copying it, and report a failed fetch through toast.error. Signed-off-by: Marc Went <marc@went.io> * fix(app): resolve deep links by filesystem case and bound remote fetches Deep links resolved their file by comparing path segments in JavaScript, which is case-sensitive: on macOS and Windows `Web/Design/hikyo.pen` and `web/design/hikyo.pen` name the same file, yet both the open-tab lookup and the picker check refused it and the link was cancelled. The comparison now goes through a `path_matches_suffix` Tauri command that canonicalizes the candidate and folds ASCII case on macOS and Windows while staying exact on Linux. `resolveDeepLinkFile` takes the comparator as an argument, so it stays testable without Tauri, and the rule is tested in `deep_link.rs`. An already open document is focused through `activateTabForPath` instead of `openFileFromPath`, which re-read the file from disk first and rejected the whole link when it had moved or lost its permissions since the tab opened it. The picker branch still opens the file, and a tab that closed between the snapshot and the activate falls back to opening it. A web link's `file` URL drops its fragment. The tab identity compares source URLs exactly, so two links to one document differing only in fragment opened two tabs. A document fetched from a URL is capped at 64 MiB, counted off the streamed body rather than the sender's `Content-Length`, with the request aborted the moment it goes over instead of buffering whatever the host decides to send. Draining the pending-open queue goes through `openDesignFileBatch`, the per-item catch every other open path already uses, so one failing entry no longer skips the rest of the batch. Signed-off-by: Marc Went <marc@went.io> * fix(desktop): match a deep-link suffix against the literal path too Canonicalizing the candidate resolves a symlink that sits inside the trailing segments, so a monorepo checkout where `packages/web` links to `../apps/web` would stop matching a link that spells the path the way the tab does. Compare both spellings: the canonical path keeps `..` and prefix symlinks working, the literal one keeps the path the user actually sees. Both inputs are already-open or user-picked paths, so trying the literal one grants nothing new. Signed-off-by: Marc Went <marc@went.io> * fix(app): cap the automation fetch and chain a caller's abort signal `openBrowserFileFromURL` replaced a caller-supplied `signal` with the one the size cap needs, so a caller could no longer cancel its own request. The two are chained instead: the caller's abort aborts the cap's controller, and an already-aborted signal is honoured before the fetch goes out. `handleOpenFile` in the automation bridge was the last fetch buffering an unbounded body. It reads a document the same way, so it gets the same 64 MiB ceiling, counted off the stream and aborted on overflow. Its relative-path resolution and its lack of a format assert are unchanged. `path_matches_suffix` runs `async`, so `canonicalize` cannot block the main thread on a stale network mount, and it now refuses an absolute or `..`-bearing suffix: `parse_open_url` already does, but this is the comparison every caller funnels through and an absolute suffix would otherwise match on its segments alone. The command itself gained tests over a real temp tree — exact match, the platform case rule, the symlinked trailing directory that motivated the literal fallback, a missing file, and the refusals. The docs and the module header claimed an opened file always joins the recent files list, in the same breath as saying an already open tab is focused without re-reading it. Only the former opens anything, so only the former touches the list. Signed-off-by: Marc Went <marc@went.io> * docs(changelog): note the 64 MiB ceiling on the automation bridge openFile Signed-off-by: Marc Went <marc@went.io> * fix(app): deliver cold-start deep links through the deep-link path macOS hands a launch `openpencil://` link to the app as `RunEvent::Opened` before the app's `setup` closure runs. Traced on a cold `open`: `RunEvent::Opened` at T+0.085 s, `setup` at T+0.342 s, and `on_open_url` never fired. The plugin's `deep-link://new-url` emit therefore reached no listener and the URL survived only in the plugin's `current`, which was drained under `#[cfg(any(windows, target_os = "linux"))]` on the assumption that macOS was unaffected. It is not: a cold link launched the app to an empty tab with no picker, no toast and no log line, while the same link fired at a running app worked. The drain now runs on every desktop platform; `register_all` stays gated, macOS does not support it. Nothing is queued twice. `RunEvent::Opened` is dispatched on the thread that runs `setup`, so a link cannot arrive between registering `on_open_url` and reading `current`, and anything later is no longer in `current`. A cold double-clicked document is unaffected: `current` now also yields its `file://` URL, and the `scheme == "openpencil"` filter in `queue_deep_links` drops it, leaving `queue_open_paths` the only producer for that path. The pending-open routing moves out of `WorkspaceView.vue` into `app/document/io/pending-open.ts`, so which entry reaches the deep-link resolver and which reaches the plain opener is unit-testable without mounting the view. A drain that fails wholesale — the `take_pending_open` invoke, the event binding — now raises a toast instead of only a console line; per-entry failures were already toasted. Signed-off-by: Marc Went <marc@went.io> * refactor(app): share one bounded body reader readBodyWithLimit reimplemented the chunked cap that vectorize's readBoundedResponse already applied, and it lived in the menu module while the automation bridge imported it from there. Move the reader to the browser document-io owner as readBoundedBody, returning bytes with an optional overflow hook and error message, and have both the document fetch and the vectorize providers use it. The automation bridge now opens a browser file through openBrowserFileFromURL instead of re-inlining fetch, cap and tab creation, so it also gets the same format check as the Tauri path, and the caller's abort signal is combined with the cap's controller through AbortSignal.any. * fix(app): report a failed tab activation activateTabForPath returned true after calling switchTab, but switchTab silently does nothing when the tab is gone. A tab that closed while the identity lookup awaited therefore looked focused, and the caller skipped opening the file, so the link did nothing at all. Return whether a tab was actually activated. * fix(app): translate the document link notices The four notices added for document links existed only in the English defaults, so a localized build showed English toasts. check:i18n does not cover the app-level notification catalog, which is why nothing caught it. Also correct the docs: a `.` segment is refused along with `..`, matching the matcher. * fix(desktop): refuse a dot segment in deep links The parser accepted `web/./design.pen` while path_ends_with_segments refuses `.`, so such a link was queued and could then never match an open tab or a picked file — it failed silently after asking the user to locate the file. Refuse `.` alongside `..` in the parser and drop the whitespace-only line left in the capability file. * refactor(app): tidy the document link plumbing Four smaller things from review: - A dismissed file picker is not a wrong file, so it no longer reports "expected a file ending in …", which named a file the user never chose. - Reuse es-toolkit's omit for stripping the link params, as the MCP settings form already does. - Drop the openDesignFileBatch re-export from menu/use.ts; nothing imports it from there. - Move the exact-name lookup out of the view: selectNodesByName lives with the other selection helpers and walks the graph directly, instead of building a whole FigmaAPI facade from the automation bridge to answer one query. * refactor(app): centralize focusing nodes The name lookup was a link-shaped helper in the selection domain, and it baked one strategy into the action. Split it into the two things a caller actually needs: focusNodes(ids) is the select-and-zoom primitive that share and collaboration references want, and focusNodesByName resolves an exact name on the current page first. The store dependency is a narrow interface, as with the viewport actions, so the action is unit-testable and stale ids can be ignored instead of selected. --------- Signed-off-by: Marc Went <marc@went.io> Co-authored-by: Danila Poyarkov <dev@dannote.net>
2026-09-18 11:45:49 +00:00
import { afterEach, describe, expect, mock, test } from 'bun:test'
import { readBoundedBody } from '@/app/document/io/browser'
import { openBrowserFileFromURL } from '@/app/shell/menu/files'
const MIB = 1024 * 1024
const originalFetch = globalThis.fetch
function streamed(chunks: number[]): Response {
return new Response(
new ReadableStream<Uint8Array>({
start(controller) {
for (const size of chunks) controller.enqueue(new Uint8Array(size))
controller.close()
}
})
)
}
afterEach(() => {
globalThis.fetch = originalFetch
})
describe('readBoundedBody', () => {
test('buffers a body that stays under the cap', async () => {
const onExceeded = mock(() => undefined)
const bytes = await readBoundedBody(streamed([20, 10]), MIB, { onExceeded })
expect(bytes.byteLength).toBe(30)
expect(onExceeded).not.toHaveBeenCalled()
})
test('aborts the request as soon as the cap is exceeded', async () => {
const onExceeded = mock(() => undefined)
// The third chunk pushes it over: the read stops there instead of draining the
// body, and the notice names the cap the user has to stay under.
const body = streamed([MIB / 2, MIB / 2, 1, 64 * MIB])
await expect(readBoundedBody(body, MIB, { onExceeded })).rejects.toThrow('exceeds 1 MiB')
expect(onExceeded).toHaveBeenCalledTimes(1)
})
test('accepts a body of exactly the cap and refuses one byte more', async () => {
const onExceeded = mock(() => undefined)
const exact = await readBoundedBody(streamed([MIB / 2, MIB / 2]), MIB, { onExceeded })
expect(exact.byteLength).toBe(MIB)
expect(onExceeded).not.toHaveBeenCalled()
await expect(readBoundedBody(streamed([MIB, 1]), MIB, { onExceeded })).rejects.toThrow(
'exceeds'
)
expect(onExceeded).toHaveBeenCalledTimes(1)
})
test('counts the bytes that arrive, not a Content-Length claim', async () => {
const response = streamed([MIB, MIB])
response.headers.set('Content-Length', '1')
const onExceeded = mock(() => undefined)
await expect(readBoundedBody(response, MIB, { onExceeded })).rejects.toThrow('exceeds')
expect(onExceeded).toHaveBeenCalledTimes(1)
})
test('lets a caller name its own oversized-body error', async () => {
await expect(
readBoundedBody(streamed([MIB, 1]), MIB, { sizeError: 'Vectorized SVG is too large' })
).rejects.toThrow('Vectorized SVG is too large')
})
})
describe('openBrowserFileFromURL', () => {
test('refuses a response with no body instead of opening an empty document', async () => {
globalThis.fetch = mock(async () => new Response(null, { status: 200 })) as typeof fetch
await expect(openBrowserFileFromURL(new URL('https://example.com/design.pen'))).rejects.toThrow(
'carried no body'
)
})
})