Files
openchamber/packages/ui/src/components/chat/appLinkInteractions.test.ts
T
ChangeHowandBohdan Triapitsyn 3a78d86248 fix(ui): open app deep links from chat after confirmation (#2932)
* fix(ui): open app deep links from chat after confirmation

DOMPurify's default URI policy stripped href from anchors with custom
application schemes (obsidian://, vscode://, ...), so every app link
rendered in chat was dead across web, desktop, VS Code, and mobile.

- Classify safe app-link schemes in lib/url.ts (browser-handled,
  scriptable, webview-internal, network, and self-deep-link schemes
  stay excluded) and let openExternalUrl accept them
- Keep app-link hrefs through the markdown sanitize hook
- Intercept app-link clicks in the markdown renderer and route them
  through a confirmation dialog (Trust and open / Open once, dismiss
  to cancel) mounted in the desktop/web app root and the mobile shell
- Persist per-device trusted schemes in a zustand store; trusted
  schemes open without asking again

* feat(settings): manage trusted app link schemes in General

Add an App links section to Settings > General listing the application
schemes trusted on this device with a delete action; removing a scheme
restores the confirmation dialog for it. Register the section in
settings search.

* fix(ui): enforce app link confirmation

* fix(ui): handle app links by runtime

* fix(vscode): keep app links unsupported

* fix(settings): clarify trusted app links

---------

Co-authored-by: Bohdan Triapitsyn <artmore@protonmail.com>
2026-08-23 01:53:21 +03:00

94 lines
3.3 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});