feat: 内置 HTML 预览器 + 沙箱浏览器 tab(v0.5.0)
- html viewer:.html/.htm 沙箱 iframe 预览(相对资源经 /sidebar/html 路径编码路由加载,CSP sandbox + 信任围栏 + cwd 边界),预览/编辑切换 - browser tab:+ 菜单多开(browser:<n>),地址栏后退/前进/刷新/在浏览器中打开(15px 图标),URL 与标题按会话持久化(patchTab + nextBrowser) - 安全:iframe 一律不透明源沙箱(无 allow-same-origin/top-navigation、no-referrer、权限策略全禁、禁 srcdoc);地址栏仅 http(s)、拒绝 localhost 等本机地址;界面实时沙箱状态(关闭红色警示)+ 临时解锁/恢复 - 设置:htmlViewerNoSandbox / htmlViewerDefaultUnsafe / browserNoSandbox / browserInterceptLinks 四个声明式二级设置(默认安全) - 外链拦截:聊天/界面 http(s) 外链默认在侧边栏打开(openTab seed.url),Ctrl/Cmd 点击放行 - 嵌入拒绝面板:browser.probe 路由探测 X-Frame-Options/frame-ancestors,被拒站点显示原因 + 在浏览器中打开/仍然加载 - 设置页可滚动;内置 7 tab + 9 viewer;新增设计文档;268 个测试全绿
This commit is contained in:
@@ -15,6 +15,7 @@ const elementStub = (): Record<string, unknown> => ({
|
||||
classList: { add: () => {}, remove: () => {}, contains: () => false },
|
||||
setAttribute: () => {},
|
||||
getAttribute: () => null,
|
||||
hasAttribute: () => false,
|
||||
appendChild: () => {},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* browser.probe helpers: the CSP frame-ancestors parser (host side) and the
|
||||
* embeddability verdict (client side). Together they turn the target's
|
||||
* response headers into the "refused to be embedded" explanation the
|
||||
* sidebar browser shows instead of the browser's cryptic blank frame.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { extractFrameAncestors } from '../src/browser-probe.ts'
|
||||
import { embeddabilityOf } from '../src/client/browser.ts'
|
||||
|
||||
describe('extractFrameAncestors', () => {
|
||||
it('extracts a frame-ancestors source list from a CSP header', () => {
|
||||
expect(extractFrameAncestors("default-src 'self'; frame-ancestors 'none'"))
|
||||
.toEqual(["'none'"])
|
||||
expect(extractFrameAncestors("frame-ancestors 'self' https://example.com"))
|
||||
.toEqual(["'self'", 'https://example.com'])
|
||||
expect(extractFrameAncestors('frame-ancestors *')).toEqual(['*'])
|
||||
})
|
||||
|
||||
it('is undefined when the directive is absent or empty', () => {
|
||||
expect(extractFrameAncestors(null)).toBeUndefined()
|
||||
expect(extractFrameAncestors("default-src 'self'")).toBeUndefined()
|
||||
expect(extractFrameAncestors('frame-ancestors')).toBeUndefined()
|
||||
expect(extractFrameAncestors('frame-ancestors ; default-src *')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('embeddabilityOf', () => {
|
||||
it('blocks X-Frame-Options DENY / SAMEORIGIN', () => {
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'DENY' })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'SAMEORIGIN' })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'sameorigin' })).toBe('blocked')
|
||||
})
|
||||
|
||||
it('blocks a frame-ancestors list that does not allow * ("self" means the SITE origin, never ours)', () => {
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'none'"] })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'"] })).toBe('blocked')
|
||||
expect(embeddabilityOf({ reachable: true, frameAncestors: ["'self'", 'https://x.dev'] })).toBe('blocked')
|
||||
})
|
||||
|
||||
it('allows a wildcard frame-ancestors and absent anti-framing headers', () => {
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, frameAncestors: ['*'] })).toBe('embeddable')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200 })).toBe('embeddable')
|
||||
expect(embeddabilityOf({ reachable: true, status: 200, xFrameOptions: 'ALLOW-FROM https://x' })).toBe('embeddable')
|
||||
})
|
||||
|
||||
it('is unknown when the target is unreachable (plain iframe stays)', () => {
|
||||
expect(embeddabilityOf({ reachable: false })).toBe('unknown')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Browser address-bar policy tests: only http(s) URLs may be navigated,
|
||||
* loopback addresses and the GUI's own origin are refused outright. The
|
||||
* iframe sandbox (opaque origin) is the primary security boundary; this
|
||||
* policy is the address-bar gate on top of it.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isLoopbackHostname, normalizeBrowserUrl } from '../src/client/browser.ts'
|
||||
|
||||
const SELF = 'http://127.0.0.1:3080'
|
||||
|
||||
describe('normalizeBrowserUrl', () => {
|
||||
it('normalizes a bare domain to https', () => {
|
||||
expect(normalizeBrowserUrl('example.com', SELF)).toEqual({ kind: 'ok', url: 'https://example.com/' })
|
||||
})
|
||||
|
||||
it('normalizes a host with a port to https', () => {
|
||||
expect(normalizeBrowserUrl('example.com:8080/path', SELF)).toEqual({ kind: 'ok', url: 'https://example.com:8080/path' })
|
||||
})
|
||||
|
||||
it('keeps an explicit http:// scheme', () => {
|
||||
expect(normalizeBrowserUrl('http://example.com/a?b=1', SELF)).toEqual({ kind: 'ok', url: 'http://example.com/a?b=1' })
|
||||
})
|
||||
|
||||
it('accepts a non-loopback IP literal', () => {
|
||||
expect(normalizeBrowserUrl('https://8.8.8.8/dns', SELF)?.kind).toBe('ok')
|
||||
})
|
||||
|
||||
it('refuses non-http(s) schemes', () => {
|
||||
expect(normalizeBrowserUrl('javascript:alert(1)', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('data:text/html,<b>x</b>', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('file:///etc/passwd', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
expect(normalizeBrowserUrl('about:blank', SELF)).toEqual({ kind: 'blocked', reason: 'scheme' })
|
||||
})
|
||||
|
||||
it('refuses loopback hostnames in every spelling', () => {
|
||||
for (const input of [
|
||||
'http://localhost/', 'https://localhost:3080/', 'http://LOCALHOST/',
|
||||
'http://127.0.0.1/', 'http://127.255.255.255/',
|
||||
'http://[::1]/', 'http://0.0.0.0/',
|
||||
]) {
|
||||
expect(normalizeBrowserUrl(input, SELF), input).toEqual({ kind: 'blocked', reason: 'loopback' })
|
||||
}
|
||||
})
|
||||
|
||||
it('allows the GUI\'s own origin (the sandbox keeps it opaque like any site)', () => {
|
||||
// The user may browse the GUI itself in the sidebar; its host is
|
||||
// loopback, so the self check must win BEFORE the loopback gate.
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:3080/sidebar', SELF)).toEqual({
|
||||
kind: 'ok', url: 'http://127.0.0.1:3080/sidebar',
|
||||
})
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:3080/', SELF)).toEqual({
|
||||
kind: 'ok', url: 'http://127.0.0.1:3080/',
|
||||
})
|
||||
// A different port of the same loopback host is NOT the GUI origin and
|
||||
// stays blocked.
|
||||
expect(normalizeBrowserUrl('http://127.0.0.1:9999/', SELF)).toEqual({ kind: 'blocked', reason: 'loopback' })
|
||||
})
|
||||
|
||||
it('reports invalid input', () => {
|
||||
expect(normalizeBrowserUrl('', SELF)).toEqual({ kind: 'invalid' })
|
||||
expect(normalizeBrowserUrl(' ', SELF)).toEqual({ kind: 'invalid' })
|
||||
expect(normalizeBrowserUrl('ht tp://x', SELF)).toEqual({ kind: 'invalid' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('isLoopbackHostname', () => {
|
||||
it('flags localhost, IPv6 loopback, and the 127/8 block', () => {
|
||||
expect(isLoopbackHostname('localhost')).toBe(true)
|
||||
expect(isLoopbackHostname('::1')).toBe(true)
|
||||
expect(isLoopbackHostname('[::1]')).toBe(true)
|
||||
expect(isLoopbackHostname('127.0.0.1')).toBe(true)
|
||||
expect(isLoopbackHostname('127.8.9.10')).toBe(true)
|
||||
expect(isLoopbackHostname('0.0.0.0')).toBe(true)
|
||||
expect(isLoopbackHostname('example.com')).toBe(false)
|
||||
expect(isLoopbackHostname('128.0.0.1')).toBe(false)
|
||||
expect(isLoopbackHostname('192.168.1.1')).toBe(false)
|
||||
})
|
||||
})
|
||||
+51
-9
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Built-in registration tests: the plugin registers 6 tabs and 8 file
|
||||
* Built-in registration tests: the plugin registers 7 tabs and 9 file
|
||||
* viewers through the same service external plugins use (dogfooding);
|
||||
* the catch-all `code` viewer and the NUL-sniffing `binary-download`
|
||||
* viewer pin the registry's matching behavior.
|
||||
* the catch-all `code` viewer, the NUL-sniffing `binary-download` viewer,
|
||||
* and the html/browser sandbox settings pin the registry's behavior.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
// First import: browser globals before the xterm-carrying builtin graph loads.
|
||||
@@ -11,20 +11,21 @@ import './browser-globals.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { createBetterSidebarService } from '../src/client/service.ts'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import { allLeaves } from '../src/client/state.ts'
|
||||
import { registerBuiltins } from '../src/client/builtins/index.ts'
|
||||
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; dispose: () => void } {
|
||||
function setup(): { service: ReturnType<typeof createBetterSidebarService>; store: ReturnType<typeof createSidebarStore>; dispose: () => void } {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
const dispose = registerBuiltins({} as Context, service)
|
||||
return { service, dispose }
|
||||
return { service, store, dispose }
|
||||
}
|
||||
|
||||
describe('built-in tab registrations', () => {
|
||||
it('registers the 6 built-in tabs', () => {
|
||||
it('registers the 7 built-in tabs', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getTabs().map(t => t.id).sort()).toEqual(
|
||||
['diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
['browser', 'diff', 'editor', 'explorer', 'git', 'subagent', 'terminal'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -52,6 +53,29 @@ describe('built-in tab registrations', () => {
|
||||
expect(toggles.map(t => t.key)).toEqual(['agentTerminalTools'])
|
||||
})
|
||||
|
||||
it('the browser tab declares its sandbox and link-takeover related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getTab('browser')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['browserNoSandbox', 'browserInterceptLinks'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('the browser createTab mints browser:<n> ids and bumps nextBrowser', () => {
|
||||
const { service, store } = setup()
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'browser' })
|
||||
service.openTab({ type: 'browser' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs).filter(t => t.type === 'browser')
|
||||
expect(tabs).toHaveLength(2)
|
||||
expect(tabs[0]!.id).toBe('browser:1')
|
||||
expect(tabs[1]!.id).toBe('browser:2')
|
||||
expect(state.nextBrowser).toBe(3)
|
||||
})
|
||||
|
||||
it('every built-in tab carries the settings-surface icon', () => {
|
||||
const { service } = setup()
|
||||
for (const tab of service.getTabs()) {
|
||||
@@ -61,10 +85,10 @@ describe('built-in tab registrations', () => {
|
||||
})
|
||||
|
||||
describe('built-in file viewer registrations', () => {
|
||||
it('registers the 8 built-in file viewers', () => {
|
||||
it('registers the 9 built-in file viewers', () => {
|
||||
const { service } = setup()
|
||||
expect(service.getFileViewers().map(v => v.id).sort()).toEqual(
|
||||
['binary-download', 'code', 'docx', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
['binary-download', 'code', 'docx', 'html', 'image', 'markdown', 'pdf', 'pptx', 'xlsx'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -84,6 +108,24 @@ describe('built-in file viewer registrations', () => {
|
||||
expect(service.matchFileViewer('readme.md', new Uint8Array([0x61]))?.id).toBe('markdown')
|
||||
})
|
||||
|
||||
it('html claims html/htm before the catch-all', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('index.html')?.id).toBe('html')
|
||||
expect(service.matchFileViewer('page.htm')?.id).toBe('html')
|
||||
expect(service.matchFileViewer('index.html', new Uint8Array([0x3c, 0x21]))?.id).toBe('html')
|
||||
expect(service.matchFileViewer('index.HTML')?.id).toBe('html')
|
||||
})
|
||||
|
||||
it('the html viewer declares its sandbox and default-unsafe related settings', () => {
|
||||
const { service } = setup()
|
||||
const toggles = service.getFileViewers().find(v => v.id === 'html')?.settings?.toggles ?? []
|
||||
expect(toggles.map(t => t.key)).toEqual(['htmlViewerNoSandbox', 'htmlViewerDefaultUnsafe'])
|
||||
expect(toggles[0]?.title).toBeDefined()
|
||||
expect(toggles[0]?.desc).toBeDefined()
|
||||
expect(toggles[1]?.title).toBeDefined()
|
||||
expect(toggles[1]?.desc).toBeDefined()
|
||||
})
|
||||
|
||||
it('binary-download claims legacy office by extension', () => {
|
||||
const { service } = setup()
|
||||
expect(service.matchFileViewer('old.doc')?.id).toBe('binary-download')
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* /sidebar/html route vocabulary tests: the path-encoded URL scheme must
|
||||
* round-trip absolute paths (POSIX and Windows), survive special characters,
|
||||
* and refuse malformed input — and crucially, a relative asset reference
|
||||
* resolved against an encoded document URL stays inside the same route with
|
||||
* the session scope intact (the WHY of the path-encoding design).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { decodeHtmlUrl, encodeHtmlUrl } from '../src/html-route.ts'
|
||||
|
||||
describe('encodeHtmlUrl', () => {
|
||||
it('encodes a POSIX absolute path into route segments', () => {
|
||||
expect(encodeHtmlUrl('sess-1', '/Users/me/proj/index.html'))
|
||||
.toBe('/sidebar/html/sess-1/Users/me/proj/index.html')
|
||||
})
|
||||
|
||||
it('encodes a Windows absolute path (drive colon percent-encoded)', () => {
|
||||
expect(encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html'))
|
||||
.toBe('/sidebar/html/sess-1/C%3A/Users/me/a.html')
|
||||
})
|
||||
|
||||
it('percent-encodes special characters in segments', () => {
|
||||
expect(encodeHtmlUrl('s-1', '/a b/中文/100%.html'))
|
||||
.toBe('/sidebar/html/s-1/a%20b/%E4%B8%AD%E6%96%87/100%25.html')
|
||||
})
|
||||
|
||||
it('ignores leading/trailing slashes (files only)', () => {
|
||||
expect(encodeHtmlUrl('s', '/a//b/x.html')).toBe('/sidebar/html/s/a/b/x.html')
|
||||
})
|
||||
})
|
||||
|
||||
describe('decodeHtmlUrl', () => {
|
||||
it('decodes a POSIX round-trip', () => {
|
||||
const url = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/Users/me/proj/index.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes a Windows round-trip', () => {
|
||||
const url = encodeHtmlUrl('sess-1', 'C:\\Users\\me\\a.html')
|
||||
expect(decodeHtmlUrl(url)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/C:/Users/me/a.html' },
|
||||
})
|
||||
})
|
||||
|
||||
it('decodes special characters round-trip', () => {
|
||||
const url = encodeHtmlUrl('s-1', '/a b/中文/100%.html')
|
||||
const result = decodeHtmlUrl(url)
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.ref.path).toBe('/a b/中文/100%.html')
|
||||
})
|
||||
|
||||
it('refuses a wrong prefix (404)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/api/fs.read')).toEqual({
|
||||
ok: false, status: 404, message: 'not an html route',
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses an empty or double-slash path (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/').ok).toBe(false)
|
||||
expect(decodeHtmlUrl('/sidebar/html//s/a.html').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses malformed percent encoding (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html/s/%E0%A4%A').ok).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses a missing sessionId or file path (400)', () => {
|
||||
expect(decodeHtmlUrl('/sidebar/html//a.html')).toEqual({
|
||||
ok: false, status: 400, message: 'sessionId and file path are required',
|
||||
})
|
||||
expect(decodeHtmlUrl('/sidebar/html/s/')).toEqual({
|
||||
ok: false, status: 400, message: 'sessionId and file path are required',
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps encoded traversal segments for the isWithin fence to bound', () => {
|
||||
// The decoder is not a security boundary by itself: an encoded `..`
|
||||
// decodes to `..` and the HOST refuses it via requireAbsolute +
|
||||
// isWithin(cwd) (the decoded path resolves outside the cwd).
|
||||
const result = decodeHtmlUrl('/sidebar/html/s/Users/me/../../etc/passwd')
|
||||
expect(result.ok).toBe(true)
|
||||
if (result.ok) expect(result.ref.path).toBe('/Users/me/../../etc/passwd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('relative asset resolution stays in-route', () => {
|
||||
it('a relative reference against an encoded document URL keeps the session scope', () => {
|
||||
// WHATWG URL resolution drops the QUERY of a path-relative reference,
|
||||
// which is why the route is path-encoded: resolving ./style.css against
|
||||
// the document URL must land back on the same route with the same
|
||||
// session prefix.
|
||||
const doc = encodeHtmlUrl('sess-1', '/Users/me/proj/index.html')
|
||||
const asset = new URL('./style.css', `http://h${doc}`).pathname
|
||||
expect(asset).toBe('/sidebar/html/sess-1/Users/me/proj/style.css')
|
||||
expect(decodeHtmlUrl(asset)).toEqual({
|
||||
ok: true,
|
||||
ref: { sessionId: 'sess-1', path: '/Users/me/proj/style.css' },
|
||||
})
|
||||
})
|
||||
|
||||
it('deeper and parent-relative references resolve inside the route', () => {
|
||||
const doc = `http://h${encodeHtmlUrl('s', '/a/b/index.html')}`
|
||||
expect(new URL('img/x.png', doc).pathname).toBe('/sidebar/html/s/a/b/img/x.png')
|
||||
expect(new URL('../c.css', doc).pathname).toBe('/sidebar/html/s/a/c.css')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* External-link interception policy tests: the pure decision (which http(s)
|
||||
* external links are taken over into the sidebar browser) and the click
|
||||
* shape (plain left clicks only — modified clicks always bypass).
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { isPlainLeftClick, 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)
|
||||
})
|
||||
})
|
||||
@@ -57,6 +57,6 @@ describe('dsh-better-sidebar plugin export shape', () => {
|
||||
const overridden = (PrefsSchema as unknown as {
|
||||
(input: Record<string, unknown> | undefined): Record<string, unknown>
|
||||
})({ openByDefault: false, defaultWidthPercent: 45 })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(overridden).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Sandbox-contract tests for the two built-in web surfaces (HTML preview
|
||||
* iframe and the browser tab iframe). The iframe sandbox — opaque origin,
|
||||
* no allow-same-origin, no top-navigation — is the PRIMARY security
|
||||
* boundary of both features; these tests pin the exact attribute so a
|
||||
* refactor cannot silently widen it. The side card settings can drop the
|
||||
* sandbox per-feature (warned); those paths render the warning bar and no
|
||||
* sandbox attribute.
|
||||
*/
|
||||
import { describe, expect, it, beforeEach } from 'vitest'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { createElement } from 'react'
|
||||
import './browser-globals.ts'
|
||||
import type { Context } from '../src/context-types.ts'
|
||||
import { TextEditor, HTML_IFRAME_SANDBOX } from '../src/client/TextEditor.tsx'
|
||||
import { BrowserView, BrowserEmbedBlocked, BROWSER_IFRAME_SANDBOX } from '../src/client/BrowserView.tsx'
|
||||
import { createSidebarStore } from '../src/client/state.ts'
|
||||
import type { FileViewerProps } from '../src/client/service.ts'
|
||||
|
||||
const CTX = {} as Context
|
||||
|
||||
// The copy assertions below pin the zh strings: force the zh locale (the
|
||||
// test environment's navigator may be the real Node one with an en locale).
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(globalThis.navigator, 'language', { value: 'zh-CN', configurable: true })
|
||||
})
|
||||
|
||||
function viewerProps(store: ReturnType<typeof createSidebarStore>, overrides: Partial<FileViewerProps> = {}): FileViewerProps {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
path: '/p/a/index.html',
|
||||
title: 'index.html',
|
||||
viewerId: 'html',
|
||||
content: '<h1>hi</h1>',
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
describe('HTML preview iframe sandbox', () => {
|
||||
it('renders the preview iframe with the exact sandbox tokens and no same-origin / top-navigation', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
// The sandbox tokens are exactly the exported constant...
|
||||
expect(iframe).toContain(`sandbox="${HTML_IFRAME_SANDBOX}"`)
|
||||
// ...which must never contain the dangerous tokens.
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(HTML_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
// Cross-origin framing by construction: route-src (never srcdoc).
|
||||
expect(iframe).toContain('src="/sidebar/html/s1/p/a/index.html"')
|
||||
expect(iframe).not.toContain('srcdoc=')
|
||||
// Referrer + permissions policy stay locked even when sandboxed.
|
||||
// (React SSR renders the referrerPolicy prop camelCase as written.)
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('renders the live sandbox status row (green on + temporary unlock action)', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
// Sandbox ON: the green status + the one-tap temporary unlock button.
|
||||
expect(html).toContain('沙箱模式:已启用')
|
||||
expect(html).toContain('临时解锁(不安全)')
|
||||
// No restore action while the sandbox is on.
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
|
||||
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), htmlViewerNoSandbox: true })
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
// The red persistent warning copy is rendered; the temporary-unlock
|
||||
// action is NOT offered (re-enabling is the settings page's job).
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
|
||||
it('starts unsandboxed (red, restorable) when the default-unsafe pref is on', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), htmlViewerDefaultUnsafe: true })
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store)))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
// The red warning + the one-tap restore (this is the LOCAL state).
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).toContain('恢复沙箱')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
})
|
||||
|
||||
it('markdown preview keeps rendering markdown, not an iframe', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(TextEditor, viewerProps(store, {
|
||||
viewerId: 'markdown',
|
||||
path: '/p/readme.md',
|
||||
content: '# hi',
|
||||
})))
|
||||
expect(html).not.toContain('<iframe')
|
||||
expect(html).not.toContain('/sidebar/html/')
|
||||
// The markdown is rendered into markup, not framed.
|
||||
expect(html).toContain('<h1')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser tab iframe sandbox', () => {
|
||||
function tabProps(store: ReturnType<typeof createSidebarStore>, path?: string) {
|
||||
return {
|
||||
ctx: CTX,
|
||||
store,
|
||||
scope: { sessionId: 's1', cwd: '/p' },
|
||||
tab: { id: 'browser:1', type: 'browser', title: 'Browser', ...(path !== undefined ? { path } : {}) },
|
||||
visible: true,
|
||||
}
|
||||
}
|
||||
|
||||
it('renders the start page before any navigation (no iframe, no auto-load)', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store)))
|
||||
expect(html).not.toContain('<iframe')
|
||||
expect(html).toContain('输入网址开始浏览')
|
||||
})
|
||||
|
||||
it('sandboxes the iframe without same-origin / top-navigation', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).toContain(`sandbox="${BROWSER_IFRAME_SANDBOX}"`)
|
||||
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-same-origin')
|
||||
expect(BROWSER_IFRAME_SANDBOX).not.toContain('allow-top-navigation')
|
||||
expect(iframe).toContain('src="https://example.com/"')
|
||||
expect(iframe).toContain('referrerPolicy="no-referrer"')
|
||||
expect(iframe).toContain('allow=""')
|
||||
})
|
||||
|
||||
it('renders the live sandbox status row with the temporary unlock action', () => {
|
||||
const store = createSidebarStore()
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
expect(html).toContain('沙箱模式:已启用')
|
||||
expect(html).toContain('临时解锁(不安全)')
|
||||
})
|
||||
|
||||
it('offers the open-in-browser action once a URL is loaded (disabled before navigation)', () => {
|
||||
const store = createSidebarStore()
|
||||
// No URL yet: the external-open action is disabled.
|
||||
const start = renderToString(createElement(BrowserView, tabProps(store)))
|
||||
expect(start).toContain('aria-label="在浏览器中打开"')
|
||||
expect(start).toContain('title="在浏览器中打开" disabled=""')
|
||||
// With a URL: enabled.
|
||||
const loaded = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
expect(loaded).toContain('aria-label="在浏览器中打开"')
|
||||
expect(loaded).not.toContain('title="在浏览器中打开" disabled=""')
|
||||
})
|
||||
|
||||
it('drops the sandbox attribute with the red warning when the setting is on (no restore action — the global setting owns it)', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ ...store.getPrefs(), browserNoSandbox: true })
|
||||
const html = renderToString(createElement(BrowserView, tabProps(store, 'https://example.com/')))
|
||||
const iframe = /<iframe[^>]*>/.exec(html)?.[0]
|
||||
expect(iframe).toBeDefined()
|
||||
expect(iframe).not.toContain('sandbox=')
|
||||
expect(html).toContain('沙箱已关闭')
|
||||
expect(html).not.toContain('临时解锁(不安全)')
|
||||
expect(html).not.toContain('恢复沙箱')
|
||||
})
|
||||
})
|
||||
|
||||
describe('browser embed-refusal panel', () => {
|
||||
it('explains the refusal with the host, the reason, and both actions', () => {
|
||||
const html = renderToString(createElement(BrowserEmbedBlocked, {
|
||||
url: 'https://arxiv.org/abs/2401.10001',
|
||||
onOpenInBrowser: () => {},
|
||||
onLoadAnyway: () => {},
|
||||
}))
|
||||
expect(html).toContain('arxiv.org 拒绝了嵌入请求')
|
||||
expect(html).toContain('X-Frame-Options / frame-ancestors')
|
||||
expect(html).toContain('在浏览器中打开')
|
||||
expect(html).toContain('仍然加载')
|
||||
})
|
||||
})
|
||||
@@ -307,6 +307,28 @@ describe('service.openTab dedupe', () => {
|
||||
expect(tab?.title).toBe('main.ts')
|
||||
})
|
||||
|
||||
it('a url seed lands the tab with its path pre-set (the sidebar-browser navigation seed)', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
service.registerTab({
|
||||
id: 'browser',
|
||||
title: () => 'Browser',
|
||||
createTab: (state) => ({
|
||||
tab: { id: `browser:${state.nextBrowser}`, type: 'browser', title: 'Browser' },
|
||||
patch: { nextBrowser: state.nextBrowser + 1 },
|
||||
}),
|
||||
component: () => null,
|
||||
})
|
||||
store.setSession('s1')
|
||||
service.openTab({ type: 'browser', url: 'https://example.com/x', title: 'example.com' })
|
||||
const state = store.getSnapshot().state!
|
||||
const tab = allLeaves(state.splits).flatMap(l => l.tabs).find(t => t.type === 'browser')
|
||||
expect(tab?.id).toBe('browser:1')
|
||||
expect(tab?.path).toBe('https://example.com/x')
|
||||
expect(tab?.title).toBe('example.com')
|
||||
expect(state.nextBrowser).toBe(2)
|
||||
})
|
||||
|
||||
it('the descriptor title is the default when no title is given', () => {
|
||||
const store = createSidebarStore()
|
||||
const service = createBetterSidebarService(store)
|
||||
|
||||
+70
-2
@@ -3,7 +3,7 @@
|
||||
* exercises the real integrations — route registration, git against the
|
||||
* actual repository, and a real directory listing. Runs with `pnpm test`.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
@@ -58,7 +58,7 @@ describe('host plugin smoke', () => {
|
||||
inject: () => () => {},
|
||||
}
|
||||
apply(ctx as never)
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file'])
|
||||
expect(routes.map(route => route.path)).toEqual(['/sidebar/api', '/sidebar/file', '/sidebar/html'])
|
||||
expect(upgrades.map(route => route.path)).toEqual(['/sidebar/ws/terminal', '/sidebar/ws/agent-terminals'])
|
||||
// Teardown runs without throwing (pty manager has nothing open).
|
||||
for (const cleanup of effects) cleanup()
|
||||
@@ -508,6 +508,10 @@ describe('side card settings routes', () => {
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
// The enable-switch maps default to {} (everything on).
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
@@ -542,8 +546,70 @@ describe('side card settings routes', () => {
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/plain object/)
|
||||
})
|
||||
/** Fake fetch responses shaped like what the route consumes. */
|
||||
const respond = (status: number, headers: Record<string, string>): Response =>
|
||||
({ status, url: 'https://site.example/', headers: new Headers(headers) }) as unknown as Response
|
||||
|
||||
it('reports X-Frame-Options and frame-ancestors from the target headers', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
vi.stubGlobal('fetch', vi.fn(async () => respond(200, {
|
||||
'x-frame-options': 'SAMEORIGIN',
|
||||
'content-security-policy': "default-src 'self'; frame-ancestors 'none'",
|
||||
})))
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://arxiv.org/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({
|
||||
reachable: true,
|
||||
url: 'https://site.example/',
|
||||
status: 200,
|
||||
xFrameOptions: 'SAMEORIGIN',
|
||||
frameAncestors: ["'none'"],
|
||||
})
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('retries a 405 HEAD as GET', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(respond(405, {}))
|
||||
.mockResolvedValueOnce(respond(200, {}))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(result.value).toMatchObject({ reachable: true, status: 200 })
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('reports an unreachable target as reachable:false', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('ENOTFOUND') }))
|
||||
try {
|
||||
const result = await invoke(route, 'browser.probe', { url: 'https://example.com/' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value).toEqual({ reachable: false })
|
||||
} finally {
|
||||
vi.unstubAllGlobals()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses non-http(s) and loopback URLs', async () => {
|
||||
const route = mountWithSettings(undefined)
|
||||
for (const url of ['javascript:alert(1)', 'file:///etc/passwd', 'http://127.0.0.1:8080/', 'http://localhost/']) {
|
||||
const result = await invoke(route, 'browser.probe', { url })
|
||||
expect(result.ok, url).toBe(false)
|
||||
expect(result.error?.code, url).toBe('bad-request')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
describe('agent terminal tool gating', () => {
|
||||
it('injects the eight tools only when the side-card setting is enabled (default off)', () => {
|
||||
let registered = 0
|
||||
@@ -601,4 +667,6 @@ describe('agent terminal tool gating', () => {
|
||||
expect(live()).toBe(8)
|
||||
expect(registered).toBe(16)
|
||||
})
|
||||
|
||||
|
||||
})
|
||||
|
||||
+63
-5
@@ -4,7 +4,7 @@ import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import { parseUnifiedDiff } from '../src/client/DiffView.tsx'
|
||||
import {
|
||||
activateTab, allLeaves, closeTab, createSidebarStore, defaultWidthFor, insertLeafAt, makeDefaultState,
|
||||
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
moveTab, moveTabToEdge, openDiffTab, openTabInActivePane, patchTab, reconcileAgentTerminals, resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SidebarTab, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { loadPrefs, type SidebarSettingsClient } from '../src/client/prefs.ts'
|
||||
@@ -454,6 +454,52 @@ describe('sidebar state', () => {
|
||||
expect((after.splits as { active: string | null }).active).toBe(tabId)
|
||||
})
|
||||
|
||||
it('patchTab updates the title and path of one open tab (browser persistence)', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string; type: string; title: string; path?: string }[] }
|
||||
s = openTabInActivePane(s, { id: 'browser:1', type: 'browser', title: 'Browser' })
|
||||
const browserId = 'browser:1'
|
||||
s = patchTab(s, browserId, { path: 'https://example.com/', title: 'example.com' })
|
||||
const tab = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(tab).toMatchObject({ title: 'example.com', path: 'https://example.com/' })
|
||||
// A partial patch leaves the other field untouched.
|
||||
s = patchTab(s, browserId, { title: 'example.org' })
|
||||
const again = (s.splits as { tabs: { id: string; title: string; path?: string }[] }).tabs.find(t => t.id === browserId)
|
||||
expect(again).toMatchObject({ title: 'example.org', path: 'https://example.com/' })
|
||||
// Other tabs are untouched.
|
||||
expect(leaf.tabs[0]).toBeDefined()
|
||||
})
|
||||
|
||||
it('patchTab is a no-op for a missing tab id', () => {
|
||||
const s = state()
|
||||
const after = patchTab(s, 'nope', { title: 'X', path: 'https://x/' })
|
||||
expect(after).toBe(s)
|
||||
})
|
||||
|
||||
it('sanitize accepts nextBrowser (defaulting a missing/malformed one to 1)', () => {
|
||||
const base = {
|
||||
panelOpen: true,
|
||||
width: 400,
|
||||
nextTerminal: 1,
|
||||
activePane: 'pane:1',
|
||||
expanded: [],
|
||||
splits: {
|
||||
kind: 'leaf',
|
||||
id: 'pane:1',
|
||||
active: null,
|
||||
tabs: [{ id: 't', type: 'explorer', title: 'Explorer' }],
|
||||
},
|
||||
}
|
||||
// Older persisted states lack the field: they must keep loading.
|
||||
expect(sanitizeState(base)?.nextBrowser).toBe(1)
|
||||
// A present valid value survives; a malformed one falls back to 1.
|
||||
expect(sanitizeState({ ...base, nextBrowser: 7 })?.nextBrowser).toBe(7)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 'x' })?.nextBrowser).toBe(1)
|
||||
expect(sanitizeState({ ...base, nextBrowser: 0 })?.nextBrowser).toBe(1)
|
||||
// The default state seeds 1.
|
||||
expect(makeDefaultState().nextBrowser).toBe(1)
|
||||
})
|
||||
|
||||
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
@@ -838,6 +884,10 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: false,
|
||||
agentTerminalTools: true,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
@@ -851,6 +901,10 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
@@ -864,6 +918,10 @@ describe('side card preferences', () => {
|
||||
autoOpenSubagent: true,
|
||||
agentTerminalTools: false,
|
||||
interceptOpenPath: true,
|
||||
htmlViewerNoSandbox: false,
|
||||
htmlViewerDefaultUnsafe: false,
|
||||
browserNoSandbox: false,
|
||||
browserInterceptLinks: true,
|
||||
tabsEnabled: {},
|
||||
viewersEnabled: {},
|
||||
})
|
||||
@@ -905,9 +963,9 @@ describe('side card preferences', () => {
|
||||
const store = createSidebarStore()
|
||||
// Node environment: no window → the width falls back to PANEL_DEFAULT,
|
||||
// while the open flag still follows the preference.
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
store.setSession('fresh-session')
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
expect(store.getPrefs()).toEqual({ openByDefault: false, defaultWidthPercent: 45, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
const snapshot = store.getSnapshot()
|
||||
expect(snapshot.sessionId).toBe('fresh-session')
|
||||
expect(snapshot.state?.panelOpen).toBe(false)
|
||||
@@ -920,7 +978,7 @@ describe('side card preferences', () => {
|
||||
|
||||
it('skips the default explorer tab when the explorer type is disabled', () => {
|
||||
const store = createSidebarStore()
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: { explorer: false }, viewersEnabled: {} })
|
||||
store.setSession('no-explorer')
|
||||
const state = store.getSnapshot().state!
|
||||
const tabs = allLeaves(state.splits).flatMap(leaf => leaf.tabs)
|
||||
@@ -928,7 +986,7 @@ describe('side card preferences', () => {
|
||||
expect(state.splits.kind).toBe('leaf')
|
||||
// Re-enabling seeds the explorer tab again.
|
||||
const openStore = createSidebarStore()
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setPrefs({ openByDefault: true, defaultWidthPercent: 30, autoOpenSubagent: true, agentTerminalTools: false, interceptOpenPath: true, htmlViewerNoSandbox: false, htmlViewerDefaultUnsafe: false, browserNoSandbox: false, browserInterceptLinks: true, tabsEnabled: {}, viewersEnabled: {} })
|
||||
openStore.setSession('with-explorer')
|
||||
const openTabs = allLeaves(openStore.getSnapshot().state!.splits).flatMap(leaf => leaf.tabs)
|
||||
expect(openTabs.map(tab => tab.type)).toEqual(['explorer'])
|
||||
|
||||
Reference in New Issue
Block a user