基于 PR #74 的颗粒度讨论向前兼容拓展服务化 API: - TabDescriptor 新增可选 urlTarget(url) 谓词:聊天/界面外链被拦截时按注册 顺序路由到第一个命中的插件 tab 类型(openTab({ type, url, title })), 内置 browser 退居隐式兜底;SIDEBAR_FEATURES 追加 'urlTarget' - 浏览器链接设置改为二级菜单(齿轮弹窗):新增 browserInterceptHttp (默认开)+ browserInterceptHttps(默认关);browserInterceptLinks 保留为总闸(默认开,旧文档零迁移) - 拦截管线:registerLinkInterception 的 takeoverEnabled 改收解析后的 URL,协议/同源纯判定(shouldInterceptLink)签名不变 - 测试:matchUrlTarget(注册顺序/抛错吞掉/browser 永不命中)、jsdom 点击接管门控、parsePrefs 新字段默认值;全默认字面量同步 - 文档:设计文档 + AGENTS.md(字段/示例/设置键)+ README(外链分流)
123 lines
4.8 KiB
TypeScript
123 lines
4.8 KiB
TypeScript
// @vitest-environment jsdom
|
|
/**
|
|
* External-link interception policy tests: the pure decision (which http(s)
|
|
* external links are taken over into the sidebar browser), the click shape
|
|
* (plain left clicks only — modified clicks always bypass), and the
|
|
* registered click capture (the caller's `takeoverEnabled(url)` gate).
|
|
*/
|
|
import { describe, expect, it } from 'vitest'
|
|
import { isPlainLeftClick, registerLinkInterception, shouldInterceptLink } 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 funnels into the sidebar browser, which then applies
|
|
// its own loopback/self address-bar policy on render — the interception
|
|
// itself must not silently drop links the browser will surface as blocked.
|
|
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('registerLinkInterception', () => {
|
|
/** Dispatch one click on an anchor with the given absolute href. */
|
|
const clickAnchor = (href: string, init: MouseEventInit = {}): void => {
|
|
const anchor = document.createElement('a')
|
|
anchor.href = href
|
|
document.body.appendChild(anchor)
|
|
anchor.dispatchEvent(new MouseEvent('click', {
|
|
bubbles: true,
|
|
cancelable: true,
|
|
button: 0,
|
|
...init,
|
|
}))
|
|
anchor.remove()
|
|
}
|
|
|
|
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 (the sidebar-takeover defaults: https off).
|
|
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('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,
|
|
})
|
|
clickAnchor(`${SELF}/settings`)
|
|
clickAnchor('mailto:a@b.c')
|
|
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,
|
|
})
|
|
clickAnchor('http://example.com/', { ctrlKey: true })
|
|
clickAnchor('http://example.com/', { metaKey: true })
|
|
clickAnchor('http://example.com/', { button: 1 })
|
|
expect(gateCalls).toBe(0)
|
|
dispose()
|
|
})
|
|
})
|