DSH 0.1.7-alpha.1 is 1299 commits past 0.1.6-alpha.2 and rewrites four
contracts this plugin was built on. The peer floor moves to
^0.1.7-alpha.1 (semver's prerelease rule means ^0.1.6-alpha.2 can never
match a 0.1.7 prerelease); 0.1.6-alpha.2 and earlier stay on v0.19.1.
Released as 0.21.0-alpha.1 (dist-tag alpha).
Hard breaks:
- dsh-settings was rewritten: register(ns, schema) is gone, a form is
addressed by the plugin row's Loader entry id and read from the
module's exported Config. The preferences are merged into Config and
marked volatile — which requires DSH's own schemastery, because only
that build wraps a volatile field in the cosmokit reference the
loader's volatile commit walks. With the public package a settings
write reports success and revision 1 while the effective value never
changes.
- ui-primitives renamed its whole icon family (IconXxx14/16 ->
IconXxxRegular/Medium): 26 imported symbols, 17 files. The failing
mode is render-time, not load-time. Five call sites that relied on the
old 14px default now pass size={14} explicitly.
- Session format v4 rejects source.kind === 'plugin'; the side-chat
boundary injection now writes plugin:dsh-better-sidebar, the same kind
DSH's own v3->v4 migration produces, so historical threads survive.
Tool results moved to role 'tool' with top-level content/isError; all
four parsers accept both shapes.
- The client session snapshot dropped subagentsByParent/jobsBySession
and the on-demand subagent-catalog observation API. The Tasks page
reads projectionsBySession and a new jobs.list route.
Yields and convergence:
- The read-only previews go to DSH's documentpreview (Office->PDF, a
spreadsheet worker, zoom, per-directory refresh); editor.canOpen
refuses those 26 extensions. Markdown, HTML (with its sandbox
switches) and the editable code buffer stay.
- The host browser kind is desktop-profile-only now, so the web e2e
lanes expect no browser entry and assert its absence.
- Link takeover claims only urlTarget links and passes the rest to the
host, fixing a regression from the 0.1.6 round: the fallback pointed
at a 'browser' type the plugin no longer registers, so http links in
its own markdown did nothing.
Also fixed, found along the way:
- The file tree refreshes live (new directory watch over the plugin's
own socket), since the plugin owns the `files` kind.
- ctx.sessions.list has no `current` field at either tag; the three
readers were dead. They now use ctx.sidebarRight.mounted.
- package.json.files was missing lib/client-locale.js.
- Two genuinely dead call sites: sessions.openSubagent/open/
refreshSubagents do not exist on the current baseline, so the
topology click, the jump-back and the retry were all no-ops.
- The test harness window shim lacked addEventListener, which the 0.1.7
primitives call at module-evaluation time; and the e2e token exchange
needs Playwright's request stack, not Node's fetch.
307 lines
12 KiB
TypeScript
307 lines
12 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* External-link interception tests: the pure decision (which http(s) external
|
|
* links a click may hand over), the click shape (plain left clicks only —
|
|
* modified clicks always bypass), the takeover gate (only a URL an enabled
|
|
* tab type CLAIMS through `urlTarget` is taken over at all), the open step
|
|
* (the claimed type, or a real-browser `window.open` fallback when that type
|
|
* is gone), and the registered click capture that puts them together.
|
|
*/
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
isPlainLeftClick,
|
|
isTargetAvailable,
|
|
openInterceptedLink,
|
|
registerLinkInterception,
|
|
shouldInterceptLink,
|
|
shouldTakeOverLink,
|
|
} from '../src/client/link-intercept.ts'
|
|
|
|
const SELF = 'http://127.0.0.1:3080'
|
|
|
|
describe('shouldInterceptLink', () => {
|
|
it('takes over http(s) external links', () => {
|
|
expect(shouldInterceptLink('https://example.com/page?a=1', SELF)).toBe('https://example.com/page?a=1')
|
|
expect(shouldInterceptLink('http://example.com/', SELF)).toBe('http://example.com/')
|
|
})
|
|
|
|
it('ignores non-http(s) links (mailto:, javascript:, file:)', () => {
|
|
expect(shouldInterceptLink('mailto:a@b.c', SELF)).toBeNull()
|
|
expect(shouldInterceptLink('javascript:void(0)', SELF)).toBeNull()
|
|
expect(shouldInterceptLink('file:///tmp/x.html', SELF)).toBeNull()
|
|
})
|
|
|
|
it('ignores unparsable hrefs', () => {
|
|
expect(shouldInterceptLink('not a url', SELF)).toBeNull()
|
|
})
|
|
|
|
it('never takes over same-origin (GUI-internal) links', () => {
|
|
expect(shouldInterceptLink('http://127.0.0.1:3080/settings', SELF)).toBeNull()
|
|
expect(shouldInterceptLink('http://127.0.0.1:3080/chat/session-x', SELF)).toBeNull()
|
|
// A different port of the same host is external.
|
|
expect(shouldInterceptLink('http://127.0.0.1:9999/', SELF)).toBe('http://127.0.0.1:9999/')
|
|
})
|
|
|
|
it("takes over LAN hosts (the browser's own blocklist, not the link policy)", () => {
|
|
// The link takeover only ever hands a link to a tab type that claims it;
|
|
// whatever that target does with a loopback/LAN address is its own policy.
|
|
expect(shouldInterceptLink('http://192.168.1.1/', SELF)).toBe('http://192.168.1.1/')
|
|
})
|
|
})
|
|
|
|
describe('isPlainLeftClick', () => {
|
|
const plain = { button: 0, metaKey: false, ctrlKey: false, shiftKey: false, altKey: false }
|
|
|
|
it('accepts a plain left click', () => {
|
|
expect(isPlainLeftClick(plain)).toBe(true)
|
|
})
|
|
|
|
it('bypasses modified clicks (Ctrl/Cmd/Shift/Alt) and non-left buttons', () => {
|
|
expect(isPlainLeftClick({ ...plain, metaKey: true })).toBe(false)
|
|
expect(isPlainLeftClick({ ...plain, ctrlKey: true })).toBe(false)
|
|
expect(isPlainLeftClick({ ...plain, shiftKey: true })).toBe(false)
|
|
expect(isPlainLeftClick({ ...plain, altKey: true })).toBe(false)
|
|
expect(isPlainLeftClick({ ...plain, button: 1 })).toBe(false)
|
|
expect(isPlainLeftClick({ ...plain, button: 2 })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('shouldTakeOverLink', () => {
|
|
const url = new URL('http://example.com/page')
|
|
|
|
it('takes over only a URL some tab type claims', () => {
|
|
expect(shouldTakeOverLink(url, { suspended: false, resolveTarget: () => 'my-plugin:page' })).toBe(true)
|
|
expect(shouldTakeOverLink(url, { suspended: false, resolveTarget: () => undefined })).toBe(false)
|
|
})
|
|
|
|
it('never consults a claim while the sidebar is suspended', () => {
|
|
let claims = 0
|
|
expect(shouldTakeOverLink(url, {
|
|
suspended: true,
|
|
resolveTarget: () => { claims++; return 'my-plugin:page' },
|
|
})).toBe(false)
|
|
// The suspension short-circuits: no claim is looked up at all.
|
|
expect(claims).toBe(0)
|
|
})
|
|
})
|
|
|
|
describe('isTargetAvailable', () => {
|
|
it('accepts a registered type whose switch is absent or on', () => {
|
|
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], {})).toBe(true)
|
|
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], { 'my-plugin:page': true })).toBe(true)
|
|
})
|
|
|
|
it('rejects an unregistered type and a switched-off one', () => {
|
|
expect(isTargetAvailable('my-plugin:page', [], {})).toBe(false)
|
|
expect(isTargetAvailable('my-plugin:page', [{ id: 'other:page' }], {})).toBe(false)
|
|
expect(isTargetAvailable('my-plugin:page', [{ id: 'my-plugin:page' }], { 'my-plugin:page': false })).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('openInterceptedLink', () => {
|
|
let openSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
})
|
|
afterEach(() => {
|
|
openSpy.mockRestore()
|
|
})
|
|
|
|
it('opens the claimed type through the sidebar service, with the host as title', () => {
|
|
const opened: Array<{ type: string; url: string; title?: string }> = []
|
|
const path = openInterceptedLink('https://docs.example.com/a?b=1', {
|
|
resolveTarget: () => 'my-plugin:page',
|
|
isAvailable: () => true,
|
|
sidebar: { openTab: seed => { opened.push(seed) } },
|
|
})
|
|
expect(path).toBe('sidebar')
|
|
expect(opened).toEqual([{ type: 'my-plugin:page', url: 'https://docs.example.com/a?b=1', title: 'docs.example.com' }])
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('falls back to a real browser tab when the claimed type is gone', () => {
|
|
const opened: unknown[] = []
|
|
const path = openInterceptedLink('https://docs.example.com/a', {
|
|
resolveTarget: () => 'my-plugin:page',
|
|
isAvailable: () => false,
|
|
sidebar: { openTab: seed => { opened.push(seed) } },
|
|
})
|
|
expect(path).toBe('window')
|
|
expect(opened).toEqual([])
|
|
expect(openSpy).toHaveBeenCalledWith('https://docs.example.com/a', '_blank', 'noopener,noreferrer')
|
|
})
|
|
|
|
it('falls back to a real browser tab when the plugin service is detached', () => {
|
|
const path = openInterceptedLink('https://docs.example.com/a', {
|
|
resolveTarget: () => 'my-plugin:page',
|
|
isAvailable: () => true,
|
|
sidebar: undefined,
|
|
})
|
|
expect(path).toBe('window')
|
|
expect(openSpy).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it('falls back to a real browser tab when nothing claims the URL', () => {
|
|
const opened: unknown[] = []
|
|
const path = openInterceptedLink('https://unclaimed.example.com/a', {
|
|
resolveTarget: () => undefined,
|
|
isAvailable: () => true,
|
|
sidebar: { openTab: seed => { opened.push(seed) } },
|
|
})
|
|
expect(path).toBe('window')
|
|
expect(opened).toEqual([])
|
|
expect(openSpy).toHaveBeenCalledWith('https://unclaimed.example.com/a', '_blank', 'noopener,noreferrer')
|
|
})
|
|
})
|
|
|
|
describe('registerLinkInterception', () => {
|
|
let openSpy: ReturnType<typeof vi.spyOn>
|
|
|
|
beforeEach(() => {
|
|
openSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
|
|
})
|
|
afterEach(() => {
|
|
openSpy.mockRestore()
|
|
})
|
|
|
|
/** Dispatch one click on an anchor with the given absolute href; the
|
|
* returned event carries `defaultPrevented` for the caller to assert. */
|
|
const clickAnchor = (href: string, init: MouseEventInit = {}): MouseEvent => {
|
|
const anchor = document.createElement('a')
|
|
anchor.href = href
|
|
document.body.appendChild(anchor)
|
|
const event = new MouseEvent('click', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
...init,
|
|
})
|
|
anchor.dispatchEvent(event)
|
|
anchor.remove()
|
|
return event
|
|
}
|
|
|
|
/** The index.tsx wiring (shouldTakeOverLink → openInterceptedLink) over a
|
|
* stub tab registry / tab service, so the capture registration, the gate
|
|
* and the open step are all exercised as shipped. */
|
|
const mount = (stub: {
|
|
tabs: readonly { id: string }[]
|
|
tabsEnabled?: Record<string, boolean>
|
|
claims?: (url: URL) => string | undefined
|
|
suspended?: boolean
|
|
detached?: boolean
|
|
}): { opened: Array<{ type: string; url: string; title?: string }>; dispose: () => void } => {
|
|
const opened: Array<{ type: string; url: string; title?: string }> = []
|
|
const tabsEnabled = stub.tabsEnabled ?? {}
|
|
const resolveTarget = stub.claims ?? (() => undefined)
|
|
const dispose = registerLinkInterception({
|
|
takeoverEnabled: url => shouldTakeOverLink(url, { suspended: stub.suspended ?? false, resolveTarget }),
|
|
openInSidebar: (url) => {
|
|
openInterceptedLink(url, {
|
|
resolveTarget,
|
|
isAvailable: type => isTargetAvailable(type, stub.tabs, tabsEnabled),
|
|
sidebar: stub.detached === true ? undefined : { openTab: seed => { opened.push(seed) } },
|
|
})
|
|
},
|
|
selfOrigin: SELF,
|
|
})
|
|
return { opened, dispose }
|
|
}
|
|
|
|
it('passes the parsed URL to takeoverEnabled and only takes over when it returns true', () => {
|
|
const seen: string[] = []
|
|
const opened: string[] = []
|
|
const dispose = registerLinkInterception({
|
|
takeoverEnabled: (url) => {
|
|
seen.push(url.protocol)
|
|
return url.protocol === 'http:'
|
|
},
|
|
openInSidebar: (url) => { opened.push(url) },
|
|
selfOrigin: SELF,
|
|
})
|
|
// https → the gate refuses (nothing claims it in this stub).
|
|
clickAnchor('https://example.com/page')
|
|
// http → the gate passes, the click is taken over with the absolute href.
|
|
clickAnchor('http://example.com/page')
|
|
expect(seen).toEqual(['https:', 'http:'])
|
|
expect(opened).toEqual(['http://example.com/page'])
|
|
dispose()
|
|
})
|
|
|
|
it('leaves an UNCLAIMED http link to the host: no preventDefault, no open, no window', () => {
|
|
const { opened, dispose } = mount({
|
|
tabs: [{ id: 'my-plugin:page' }],
|
|
claims: url => (url.hostname === 'claimed.example.com' ? 'my-plugin:page' : undefined),
|
|
})
|
|
const event = clickAnchor('http://unclaimed.example.com/readme')
|
|
expect(event.defaultPrevented).toBe(false)
|
|
expect(opened).toEqual([])
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
dispose()
|
|
})
|
|
|
|
it('takes over a CLAIMED link whose type is switched off, falling back to window.open', () => {
|
|
const { opened, dispose } = mount({
|
|
tabs: [{ id: 'my-plugin:page' }],
|
|
tabsEnabled: { 'my-plugin:page': false },
|
|
claims: () => 'my-plugin:page',
|
|
})
|
|
const event = clickAnchor('http://claimed.example.com/page')
|
|
// Taken over (so the anchor's own default cannot double-open)…
|
|
expect(event.defaultPrevented).toBe(true)
|
|
// …but the sidebar cannot serve it any more: a real browser tab does.
|
|
expect(opened).toEqual([])
|
|
expect(openSpy).toHaveBeenCalledTimes(1)
|
|
expect(openSpy).toHaveBeenCalledWith('http://claimed.example.com/page', '_blank', 'noopener,noreferrer')
|
|
dispose()
|
|
})
|
|
|
|
it('takes over a CLAIMED link whose type was unregistered, falling back to window.open', () => {
|
|
const { opened, dispose } = mount({ tabs: [], claims: () => 'my-plugin:page' })
|
|
const event = clickAnchor('http://claimed.example.com/page')
|
|
expect(event.defaultPrevented).toBe(true)
|
|
expect(opened).toEqual([])
|
|
expect(openSpy).toHaveBeenCalledWith('http://claimed.example.com/page', '_blank', 'noopener,noreferrer')
|
|
dispose()
|
|
})
|
|
|
|
it('opens a CLAIMED link with a live target through the sidebar service', () => {
|
|
const { opened, dispose } = mount({ tabs: [{ id: 'my-plugin:page' }], claims: () => 'my-plugin:page' })
|
|
const event = clickAnchor('http://claimed.example.com/page?x=1')
|
|
expect(event.defaultPrevented).toBe(true)
|
|
expect(opened).toEqual([{ type: 'my-plugin:page', url: 'http://claimed.example.com/page?x=1', title: 'claimed.example.com' }])
|
|
expect(openSpy).not.toHaveBeenCalled()
|
|
dispose()
|
|
})
|
|
|
|
it('never consults the gate for same-origin or non-http(s) links', () => {
|
|
let gateCalls = 0
|
|
const dispose = registerLinkInterception({
|
|
takeoverEnabled: () => { gateCalls++; return true },
|
|
openInSidebar: () => { throw new Error('must not open') },
|
|
selfOrigin: SELF,
|
|
})
|
|
expect(clickAnchor(`${SELF}/settings`).defaultPrevented).toBe(false)
|
|
expect(clickAnchor('mailto:a@b.c').defaultPrevented).toBe(false)
|
|
expect(gateCalls).toBe(0)
|
|
dispose()
|
|
})
|
|
|
|
it('bypasses the takeover on modified clicks even when the gate passes', () => {
|
|
let gateCalls = 0
|
|
const dispose = registerLinkInterception({
|
|
takeoverEnabled: () => { gateCalls++; return true },
|
|
openInSidebar: () => { throw new Error('must not open') },
|
|
selfOrigin: SELF,
|
|
})
|
|
expect(clickAnchor('http://example.com/', { ctrlKey: true }).defaultPrevented).toBe(false)
|
|
expect(clickAnchor('http://example.com/', { metaKey: true }).defaultPrevented).toBe(false)
|
|
expect(clickAnchor('http://example.com/', { button: 1 }).defaultPrevented).toBe(false)
|
|
expect(clickAnchor('http://example.com/', { shiftKey: true }).defaultPrevented).toBe(false)
|
|
expect(clickAnchor('http://example.com/', { altKey: true }).defaultPrevented).toBe(false)
|
|
expect(gateCalls).toBe(0)
|
|
dispose()
|
|
})
|
|
})
|