Files
apes-sidebar/tests/link-intercept.spec.ts
T
Menghuan1918 6586e3e2d3 feat: adapt to DSH 0.1.7-alpha.1 and yield the read-only previews
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.
2026-09-22 17:09:08 +08:00

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()
})
})