Просмотр исходного кода

feat(client): refine Sidebar Browser controls

imccyu 1 день назад
Родитель
Сommit
ef1218ac24

+ 16 - 6
apps/web/tests/sidebar-browser.e2e.ts

@@ -39,6 +39,10 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
         body: `<h1>${name}</h1><a href="/inside">Inside navigation</a><p>isolated HTTPS fixture</p>`,
       })
     })
+    await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({
+      contentType: 'text/html',
+      body: '<h1>loopback</h1>',
+    }))
     tripwire = watchConsole(page)
     await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
     await connectFreshWorkspace(page, scaffold.workspaceCwd)
@@ -49,7 +53,7 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     await scaffold?.close()
   })
 
-  it('navigates sandboxed HTTPS with application-known history', async () => {
+  it('navigates sandboxed HTTP(S) with application-known history', async () => {
     onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-browser'))
     const settled = scaffold.whenTurnSettled()
     const composer = page.locator('[data-composer-input]').first()
@@ -60,17 +64,17 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     const column = page.locator('[data-rightbar-col]')
     await page.locator('[data-sidebar-right-expand]').click()
     await column.locator('[data-sidebar-right-guide-entry="browser"]').click()
-    const input = column.getByRole('textbox', { name: 'Enter an HTTPS or local HTTP address' })
+    const input = column.getByRole('textbox', { name: 'Enter an HTTP(S) address' })
     await input.fill('https://browser.test/one')
     await input.press('Enter')
     const frame = column.locator('[data-sidebar-browser-frame]')
     await frame.waitFor({ state: 'visible' })
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'one' }).waitFor()
     expect(await frame.getAttribute('allow')).toBeNull()
-    await column.getByRole('button', { name: 'Disable sandbox and allow loopback' }).click()
+    await column.getByRole('button', { name: 'Disable sandbox restrictions' }).click()
     await expect.poll(() => frame.getAttribute('sandbox')).toBeNull()
     await column.getByText('Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.', { exact: true }).waitFor()
-    await column.getByRole('button', { name: 'Restore sandbox and block loopback' }).click()
+    await column.getByRole('button', { name: 'Restore sandbox restrictions' }).click()
     await expect.poll(() => frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('link', { name: 'Inside navigation' }).click()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'inside' }).waitFor()
@@ -88,10 +92,15 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
     await column.getByRole('button', { name: 'Forward', exact: true }).click()
     await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'two' }).waitFor()
 
+    await input.fill('http://127.0.0.1:3080/preview')
+    await input.press('Enter')
+    await page.frameLocator('[data-sidebar-browser-frame]').getByRole('heading', { name: 'loopback' }).waitFor()
+    expect(await frame.getAttribute('sandbox')).toBe('allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox')
+
     await input.fill('file:///work/index.html')
     await input.press('Enter')
     const blocked = await column.getByRole('alert').innerText()
-    expect(blocked).toBe('Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.')
+    expect(blocked).toBe('Only HTTP and HTTPS addresses are supported; use Document Preview for local files.')
     expect(tripwire.pageErrors).toEqual([])
     expect(tripwire.warnings).toEqual([])
     await mkdir(SNAPSHOT_DIR, { recursive: true })
@@ -99,9 +108,10 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => {
       '# Sidebar Browser', '',
       '- HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox',
       '- Permissions Policy: browser defaults',
-      '- Sandbox toggle: per-tab and temporary; disabling also allows loopback navigation',
+      '- Sandbox toggle: per-tab and temporary',
       '- Unknown navigation: marker shown; Back, Forward, and external-open disabled',
       '- HTTPS history: one -> two -> one -> two',
+      '- Loopback HTTP: loaded under the default sandbox',
       `- Invalid protocol: ${blocked}`,
     ].join('\n'), MODE)
     await assertFixtureInventory(SNAPSHOT_DIR, ['browser.expected.md'])

+ 1 - 1
packages/bundle/web-app/cordis.patch.yml

@@ -233,7 +233,7 @@
     - id: ui-sidebar-documentpreview
       name: '@deepseek-ai/dsh-client-ui-sidebar-documentpreview'
 
-    # Sandboxed HTTPS and opt-in loopback Browser tabs.
+    # Sandboxed HTTP(S) Browser tabs, including loopback targets.
     - id: ui-sidebar-browser
       name: '@deepseek-ai/dsh-client-ui-sidebar-browser'
 

+ 4 - 17
packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts

@@ -7,8 +7,7 @@ import type { BrowserFrame, BrowserFrameState } from './BrowserFrame.ts'
 import { BrowserNavigation } from './BrowserNavigation.ts'
 import type { BrowserTabState } from './BrowserNavigation.ts'
 import type { BrowserStore } from './store.ts'
-import { isLoopbackHostname, parseBrowserAddress } from './url.ts'
-import type { BrowserTarget } from './url.ts'
+import { parseBrowserAddress } from './url.ts'
 
 /** Construction dependencies for one tab-scoped Browser controller. */
 export interface BrowserControllerOptions {
@@ -50,11 +49,6 @@ export class BrowserController {
       this.publish()
       return
     }
-    if (this.loopbackBlocked(parsed.target)) {
-      this.navigation.addressFailed('loopback')
-      this.publish()
-      return
-    }
     const current = BrowserNavigation.current(this.navigation.snapshot)
     if (current?.url === parsed.target.url) {
       this.reload()
@@ -87,11 +81,6 @@ export class BrowserController {
   private start(request: NonNullable<BrowserTabState['request']>): void {
     this.publish()
     this.frame.clearDocument()
-    if (this.loopbackBlocked(request.target)) {
-      this.navigation.requestBlocked(request.revision, 'loopback')
-      this.publish()
-      return
-    }
     this.frame.setDocument({ target: request.target, src: request.target.url, revision: request.revision })
   }
 
@@ -102,11 +91,6 @@ export class BrowserController {
     if (this.navigation.snapshot !== previous) this.publish()
   }
 
-  private loopbackBlocked(target: BrowserTarget): boolean {
-    return this.frame.getSnapshot().sandboxed
-      && isLoopbackHostname(new URL(target.url).hostname)
-  }
-
   private publish(): void {
     this.options.actions.replace(this.options.tabId, this.navigation.snapshot)
   }
@@ -141,6 +125,8 @@ export interface BrowserInjected {
   toggleSandbox(tabId: TabId): void
   /** @param tabId - tab occurrence. @param revision - rendered document revision. */
   reportLoaded(tabId: TabId, revision: number): void
+  /** @param tabId - tab occurrence. @param revision - rendered document revision that emitted `error`. */
+  reportLoadFailed(tabId: TabId, revision: number): void
 }
 
 /**
@@ -175,5 +161,6 @@ export function createBrowserControllers(
     reload: (tabId) => { controller(tabId)?.reload() },
     toggleSandbox: (tabId) => { controller(tabId)?.frame.toggleSandbox() },
     reportLoaded: (tabId, revision) => { controller(tabId)?.frame.reportLoaded(revision) },
+    reportLoadFailed: (tabId, revision) => { controller(tabId)?.frame.reportLoadFailed(revision) },
   }
 }

+ 17 - 3
packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts

@@ -15,6 +15,7 @@ export interface BrowserDocument {
 export interface BrowserFrameState {
   readonly document: BrowserDocument | undefined
   readonly sandboxed: boolean
+  readonly loadFailed: boolean
 }
 
 /** Browser rendering operations shared by Web and future Electron implementations. */
@@ -27,6 +28,8 @@ export interface BrowserFrame extends HostObservable<BrowserFrameState> {
   clearDocument(): BrowserDocument | undefined
   /** @param revision - rendered document revision reported by the carrier. */
   reportLoaded(revision: number): void
+  /** @param revision - rendered document revision whose carrier reported an error. */
+  reportLoadFailed(revision: number): void
 }
 
 /** Owns transient iframe and sandbox-toggle state independently from URL navigation. */
@@ -34,6 +37,7 @@ export class IframeImpl implements BrowserFrame {
   private readonly store: SnapshotStore<BrowserFrameState> = createSnapshotStore({
     document: undefined,
     sandboxed: true,
+    loadFailed: false,
   })
 
   /**
@@ -69,7 +73,7 @@ export class IframeImpl implements BrowserFrame {
    * @internal
    */
   setDocument(document: BrowserDocument): void {
-    this.store.set({ ...this.store.getSnapshot(), document })
+    this.store.set({ ...this.store.getSnapshot(), document, loadFailed: false })
   }
 
   /**
@@ -78,8 +82,11 @@ export class IframeImpl implements BrowserFrame {
    * @internal
    */
   clearDocument(): BrowserDocument | undefined {
-    const document = this.store.getSnapshot().document
-    if (document !== undefined) this.store.set({ ...this.store.getSnapshot(), document: undefined })
+    const current = this.store.getSnapshot()
+    const { document } = current
+    if (document !== undefined) {
+      this.store.set({ ...current, document: undefined, loadFailed: false })
+    }
     return document
   }
 
@@ -87,4 +94,11 @@ export class IframeImpl implements BrowserFrame {
   reportLoaded(revision: number): void {
     this.documentLoaded(revision)
   }
+
+  /** @param revision - rendered document revision whose iframe emitted `error`. */
+  reportLoadFailed(revision: number): void {
+    const current = this.store.getSnapshot()
+    if (current.document?.revision !== revision || current.loadFailed) return
+    this.store.set({ ...current, loadFailed: true })
+  }
 }

+ 0 - 16
packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts

@@ -13,7 +13,6 @@ export type BrowserNavigationStatus =
   | { readonly status: 'loading'; readonly revision: number }
   | { readonly status: 'known'; readonly revision: number }
   | { readonly status: 'unknown'; readonly revision: number }
-  | { readonly status: 'failed'; readonly revision: number }
 
 /** Address-policy or loading failure shown below the toolbar. */
 export type BrowserFailure =
@@ -159,21 +158,6 @@ export class BrowserNavigation {
     }
   }
 
-  /**
-   * Refuse a current request after its target becomes disallowed by carrier policy.
-   * @param revision - blocked request revision.
-   * @param reason - address-policy refusal.
-   */
-  requestBlocked(revision: number, reason: BrowserAddressFailure): void {
-    const navigation = this.value.navigation
-    if (navigation.status !== 'loading' || navigation.revision !== revision) return
-    this.value = {
-      ...this.value,
-      navigation: { status: 'failed', revision },
-      failure: { kind: 'address', reason },
-    }
-  }
-
   private request(
     target: BrowserTarget,
     basis: BrowserTabState,

+ 3 - 15
packages/client/ui-sidebar-browser/src/client/browser/url.ts

@@ -1,4 +1,4 @@
-/** Address parsing for the Sidebar browser's HTTPS and loopback HTTP allowlist. */
+/** Address parsing for the Sidebar browser's HTTP(S) allowlist. */
 
 /** Maximum accepted address length; this bounds persisted navigation state. */
 export const MAX_BROWSER_URL_LENGTH = 16 * 1024
@@ -9,7 +9,7 @@ export type BrowserTarget =
   | { readonly kind: 'http'; readonly url: string; readonly title: string }
 
 /** Why an address was refused before navigation. */
-export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin' | 'loopback'
+export type BrowserAddressFailure = 'empty' | 'invalid' | 'protocol' | 'credentials' | 'application-origin'
 
 /** Result of parsing an address-bar value. */
 export type BrowserAddressResult =
@@ -32,7 +32,7 @@ export function parseBrowserAddress(input: string, applicationOrigin?: string):
   let url: URL
   try { url = new URL(candidate) } catch { return { ok: false, reason: 'invalid' } }
   if (url.username !== '' || url.password !== '') return { ok: false, reason: 'credentials' }
-  if (url.protocol === 'https:' || (url.protocol === 'http:' && isLoopbackHostname(url.hostname))) {
+  if (url.protocol === 'https:' || url.protocol === 'http:') {
     if (applicationOrigin !== undefined && applicationOrigin !== 'null') {
       try {
         if (url.origin === new URL(applicationOrigin).origin) return { ok: false, reason: 'application-origin' }
@@ -44,15 +44,3 @@ export function parseBrowserAddress(input: string, applicationOrigin?: string):
   }
   return { ok: false, reason: 'protocol' }
 }
-
-/**
- * Test whether a normalized URL host belongs to the local loopback interface.
- * @param hostname - URL hostname with IPv6 brackets retained.
- * @returns whether the hostname is localhost, IPv6 loopback, or IPv4 127/8.
- */
-export function isLoopbackHostname(hostname: string): boolean {
-  if (hostname === 'localhost' || hostname === '[::1]') return true
-  const octets = hostname.split('.')
-  return octets.length === 4 && octets[0] === '127'
-    && octets.every(part => /^\d{1,3}$/u.test(part) && Number(part) <= 255)
-}

+ 1 - 1
packages/client/ui-sidebar-browser/src/client/index.ts

@@ -1,4 +1,4 @@
-/** Register the HTTPS Browser tab type in the right Sidebar. */
+/** Register the HTTP(S) Browser tab type in the right Sidebar. */
 import type { Context } from '@deepseek-ai/cordis'
 import type {} from '@deepseek-ai/dsh-client-locale/client'
 import type {} from '@deepseek-ai/dsh-client-ui-renderer/client'

+ 14 - 14
packages/client/ui-sidebar-browser/src/client/locales.ts

@@ -2,25 +2,25 @@
 export const zh = {
   'type.label': '浏览器',
   'guide.title': '浏览器',
-  'guide.description': '浏览 HTTPS 网页或 loopback 服务',
-  'address.placeholder': '输入 HTTPS 或本地 HTTP 地址',
+  'guide.description': '浏览 HTTP(S) 网页',
+  'address.placeholder': '输入 HTTP(S) 地址',
   'address.changed': 'URL 已变化',
   back: '后退',
   forward: '前进',
   reload: '刷新',
   go: '前往',
   external: '在系统浏览器中打开',
-  'sandbox.disable': '关闭沙箱限制并允许 loopback',
-  'sandbox.enable': '恢复沙箱限制并禁止 loopback',
+  'sandbox.disable': '关闭沙箱限制',
+  'sandbox.enable': '恢复沙箱限制',
   'sandbox.warning': '沙箱限制已关闭;页面若到达 DSH 同源地址,可以访问该 origin 的网页数据。',
-  start: '输入 HTTPS 地址开始浏览',
+  start: '输入 HTTP(S) 地址开始浏览',
   loading: '正在打开…',
   'error.empty': '请输入地址。',
   'error.invalid': '这个地址无效或过长。',
-  'error.protocol': '只支持 HTTPSloopback HTTP 地址;本地文件请使用文档预览。',
+  'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。',
   'error.credentials': '地址不能包含用户名或密码。',
   'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。',
-  'error.loopback': '关闭沙箱限制后才能打开 loopback 地址。',
+  'web.loadFailed': '页面报告加载失败或可能禁止嵌入;可尝试在系统浏览器中打开。',
   'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。',
 } satisfies Record<string, string>
 
@@ -31,25 +31,25 @@ export type SidebarBrowserKey = keyof typeof zh
 export const en = {
   'type.label': 'Browser',
   'guide.title': 'Browser',
-  'guide.description': 'Browse HTTPS pages or loopback services',
-  'address.placeholder': 'Enter an HTTPS or local HTTP address',
+  'guide.description': 'Browse HTTP(S) pages',
+  'address.placeholder': 'Enter an HTTP(S) address',
   'address.changed': 'URL changed',
   back: 'Back',
   forward: 'Forward',
   reload: 'Reload',
   go: 'Go',
   external: 'Open in system browser',
-  'sandbox.disable': 'Disable sandbox and allow loopback',
-  'sandbox.enable': 'Restore sandbox and block loopback',
+  'sandbox.disable': 'Disable sandbox restrictions',
+  'sandbox.enable': 'Restore sandbox restrictions',
   'sandbox.warning': 'Sandbox restrictions are disabled; a page that reaches the DSH origin can access its Web data.',
-  start: 'Enter an HTTPS address to start browsing',
+  start: 'Enter an HTTP(S) address to start browsing',
   loading: 'Opening…',
   'error.empty': 'Enter an address.',
   'error.invalid': 'That address is invalid or too long.',
-  'error.protocol': 'Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.',
+  'error.protocol': 'Only HTTP and HTTPS addresses are supported; use Document Preview for local files.',
   'error.credentials': 'Addresses cannot contain a username or password.',
   'error.application-origin': 'The embedded browser cannot open the DSH application itself.',
-  'error.loopback': 'Disable sandbox restrictions before opening a loopback address.',
+  'web.loadFailed': 'The page reported a load failure or may block embedding; try opening it in the system browser.',
   'web.unknown': 'The page navigated inside the iframe; Web mode cannot read its current URL.',
 } satisfies Record<SidebarBrowserKey, string>
 

+ 21 - 8
packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx

@@ -7,7 +7,8 @@ import {
   IconLinkOutline14,
   IconRefreshOutline14,
   IconRightUpOutline16,
-  IconShieldOutline16,
+  SHIELD_OUTLINE_PATH,
+  SHIELD_OUTLINE_STROKE,
 } from '@deepseek-ai/dsh-client-ui-primitives'
 import type { InjectFace, PropsLocale, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots'
 import type { BrowserInjected } from '../browser/BrowserController.ts'
@@ -20,7 +21,18 @@ import css from './Browser.module.css'
 /** Fixed Web iframe sandbox; popups escape the sandbox while top navigation remains absent. */
 export const WEB_BROWSER_SANDBOX = 'allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox'
 
-const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true }
+const INITIAL_BROWSER_FRAME: BrowserFrameState = { document: undefined, sandboxed: true, loadFailed: false }
+
+function SandboxPolicyIcon({ sandboxed }: { readonly sandboxed: boolean }): ReactNode {
+  return (
+    <svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
+      <path d={SHIELD_OUTLINE_PATH} stroke="currentColor" strokeWidth={SHIELD_OUTLINE_STROKE} strokeLinejoin="round" />
+      {sandboxed
+        ? <path d="M12.1654 5.7552L8.9447 9.41475C8.73044 9.65816 8.53628 9.8804 8.35774 10.0423C8.1713 10.2114 7.94235 10.3717 7.64016 10.4254C7.48207 10.4535 7.32 10.4552 7.16151 10.4294C6.85843 10.3801 6.62728 10.2223 6.43836 10.0559C6.25752 9.89653 6.06037 9.67732 5.84264 9.43705L4.72925 8.20897L5.63557 7.38707L6.74897 8.61594C6.98603 8.87755 7.12974 9.03533 7.24673 9.13839C7.31033 9.19443 7.34485 9.21476 7.35823 9.22122C7.38068 9.22484 7.40352 9.22515 7.42593 9.22122C7.40522 9.22502 7.42893 9.23294 7.53583 9.136C7.65132 9.03126 7.79316 8.87139 8.02643 8.60638L11.2479 4.94763L12.1654 5.7552Z" fill="currentColor" />
+        : <path d="M10.6074 4.40278L8.00975 6.99973L10.6074 9.59739L9.59736 10.6074L6.9997 8.00978L4.40274 10.6074L3.3927 9.59739L5.98966 6.99973L3.3927 4.40278L4.40274 3.39273L6.9997 5.98969L9.59736 3.39273L10.6074 4.40278Z" fill="currentColor" transform="translate(1.2 0.8)" />}
+    </svg>
+  )
+}
 
 /** Browser body props assembled by the tab seat. */
 export type BrowserBodyProps = PropsRuntime<'sidebar.right.pane.tab'>
@@ -45,7 +57,7 @@ function useBrowserDraft(
 /** Browser tab renderer for a controller-owned URL state and Web iframe carrier. */
 export function BrowserBody(props: BrowserBodyProps): ReactNode {
   const {
-    goBack, goForward, loadUrl, mount, reload, reportLoaded, toggleSandbox,
+    goBack, goForward, loadUrl, mount, reload, reportLoaded, reportLoadFailed, toggleSandbox,
     useBrowserFrame, useStore, useTabInfo, t,
   } = props
   const { tab } = useTabInfo()
@@ -73,15 +85,13 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
   }, [loadUrl, mountCount, reload, tab.id])
 
   const frameState = useBrowserFrame(tab.id) ?? INITIAL_BROWSER_FRAME
-  const { document, sandboxed } = frameState
+  const { document, sandboxed, loadFailed } = frameState
 
   const navigationUnknown = state.navigation.status === 'unknown'
   const externalUrl = navigationUnknown ? undefined : current?.url
   const submit = (event: FormEvent): void => { event.preventDefault(); loadUrl(tab.id, draft) }
   const failure = state.failure === undefined ? undefined : failureText(state.failure.reason, t)
-  const placeholder = current === undefined
-    ? t('start')
-    : state.navigation.status === 'failed' ? undefined : t('loading')
+  const placeholder = current === undefined ? t('start') : t('loading')
 
   return (
     <div className={css.root}>
@@ -120,9 +130,10 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
           aria-pressed={!sandboxed}
           disabled={mountCount === 0}
           onClick={() => { toggleSandbox(tab.id) }}
-        ><IconShieldOutline16 size={15} /></button>
+        ><SandboxPolicyIcon sandboxed={sandboxed} /></button>
       </form>
       {!sandboxed && <div className={css.sandboxWarning} role="status">{t('sandbox.warning')}</div>}
+      {loadFailed && <div className={css.failure} role="status">{t('web.loadFailed')}</div>}
       {failure !== undefined && <div className={css.failure} role="alert">{failure}</div>}
       {document === undefined
         ? <div className={css.start}>{placeholder}</div>
@@ -134,6 +145,8 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
           referrerPolicy="no-referrer"
           title={document.target.title}
           onLoad={() => { reportLoaded(tab.id, document.revision) }}
+          /* v8 ignore next -- jsdom does not dispatch React iframe error events; BrowserFrame owns the tested behavior. */
+          onError={() => { reportLoadFailed(tab.id, document.revision) }}
           data-sidebar-browser-frame
         />}
       {navigationUnknown && <p className={css.limit}>{t('web.unknown')}</p>}

+ 30 - 7
packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx

@@ -1,6 +1,6 @@
 // @vitest-environment jsdom
 import { afterEach, describe, expect, it, vi } from 'vitest'
-import { cleanup, fireEvent, render, waitFor } from '@testing-library/react'
+import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
 import { useSyncExternalStore } from 'react'
 import type { SessionId } from '@deepseek-ai/dsh-session/types'
 import type { TabId } from '@deepseek-ai/dsh-client-ui-dockkit'
@@ -64,7 +64,7 @@ function mountBrowser(navigation?: { readonly url?: string }) {
   } as unknown as BrowserBodyProps
   const renderBody = () => render(<BrowserBody {...props} />)
   return {
-    view: renderBody(), remount: renderBody, store, lifetime,
+    view: renderBody(), remount: renderBody, store, lifetime, injected,
   }
 }
 
@@ -103,11 +103,15 @@ describe('BrowserBody', () => {
     expect(frame.getAttribute('allow')).toBeNull()
     expect(frame.getAttribute('referrerpolicy')).toBe('no-referrer')
     const disableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.disable'] })
+    const protectedMark = disableSandbox.querySelector('svg path:last-child')?.getAttribute('d')
     fireEvent.click(disableSandbox)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
     expect(mounted.view.getByRole('status').textContent).toBe(zh['sandbox.warning'])
-    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
+    const enableSandbox = mounted.view.getByRole('button', { name: zh['sandbox.enable'] })
+    expect(enableSandbox.querySelector('svg path:last-child')?.getAttribute('d')).not.toBe(protectedMark)
+    fireEvent.click(enableSandbox)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }).querySelector('svg path:last-child')?.getAttribute('d')).toBe(protectedMark)
 
     fireEvent.change(input, { target: { value: 'https://example.com/two' } })
     fireEvent.submit(input.closest('form')!)
@@ -159,17 +163,36 @@ describe('BrowserBody', () => {
     expect(mounted.store.getSnapshot().byTab[TAB]?.navigation).toEqual({ status: 'loading', revision: revision + 1 })
   })
 
-  it('shows a loopback policy failure without a loading placeholder', async () => {
+  it('shows a best-effort iframe error notice until the next controlled load', async () => {
+    const mounted = mountBrowser()
+    const input = mounted.view.getByRole('textbox')
+    fireEvent.change(input, { target: { value: 'https://example.com/one' } })
+    fireEvent.submit(input.closest('form')!)
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')).not.toBeNull() })
+    const failedRevision = mounted.store.getSnapshot().byTab[TAB]!.request!.revision
+
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.injected.keyedHooks.browserFrame(TAB)?.getSnapshot().loadFailed).toBe(true)
+    await waitFor(() => { expect(mounted.view.getByText(zh['web.loadFailed'])).toBeDefined() })
+    fireEvent.click(mounted.view.getByRole('button', { name: zh.reload }))
+    await waitFor(() => { expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull() })
+    act(() => { mounted.injected.reportLoadFailed(TAB, failedRevision) })
+    expect(mounted.view.queryByText(zh['web.loadFailed'])).toBeNull()
+  })
+
+  it('loads loopback under the default sandbox and keeps it across sandbox changes', async () => {
     const mounted = mountBrowser()
     const input = mounted.view.getByRole('textbox')
-    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
     fireEvent.change(input, { target: { value: 'http://localhost:5173/' } })
     fireEvent.submit(input.closest('form')!)
     await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/') })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX)
 
+    fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.disable'] }))
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBeNull() })
     fireEvent.click(mounted.view.getByRole('button', { name: zh['sandbox.enable'] }))
-    expect(mounted.view.getByRole('alert').textContent).toBe(zh['error.loopback'])
-    expect(mounted.view.queryByText(zh.loading)).toBeNull()
+    await waitFor(() => { expect(mounted.view.container.querySelector('iframe')?.getAttribute('sandbox')).toBe(WEB_BROWSER_SANDBOX) })
+    expect(mounted.view.container.querySelector('iframe')?.getAttribute('src')).toBe('http://localhost:5173/')
   })
 
   it('opens known Web targets externally and consumes an initial typed navigation', async () => {

+ 23 - 8
packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts

@@ -100,7 +100,7 @@ describe('BrowserController', () => {
     expect(face.keyedHooks.browserFrame(TAB)).toBe(replacement)
   })
 
-  it('allows loopback only while this tab has disabled its sandbox', () => {
+  it('loads loopback under sandbox and reloads it across sandbox changes', () => {
     const store = createBrowserStore().create('browser-controller-loopback-test')
     const face = createBrowserControllers(store.actions)
     const tabLifetime = lifetime()
@@ -108,20 +108,35 @@ describe('BrowserController', () => {
     const frame = face.keyedHooks.browserFrame(TAB)!
 
     face.loadUrl(TAB, 'http://localhost:5173/app')
-    expect(store.getSnapshot().byTab[TAB]).toMatchObject({
-      navigation: { status: 'empty' }, failure: { kind: 'address', reason: 'loopback' },
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
     })
-    expect(frame.getSnapshot().document).toBeUndefined()
+    const beforeToggle = store.getSnapshot().byTab[TAB]!.request!.revision
 
     face.toggleSandbox(TAB)
     expect(frame.getSnapshot().sandboxed).toBe(false)
-    face.loadUrl(TAB, 'http://localhost:5173/app')
     expect(frame.getSnapshot().document?.src).toBe('http://localhost:5173/app')
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 1)
 
     face.toggleSandbox(TAB)
-    expect(frame.getSnapshot()).toEqual({ document: undefined, sandboxed: true })
-    expect(store.getSnapshot().byTab[TAB]).toMatchObject({
-      navigation: { status: 'failed' }, failure: { kind: 'address', reason: 'loopback' },
+    expect(frame.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://localhost:5173/app' },
+    })
+    expect(store.getSnapshot().byTab[TAB]?.request?.revision).toBe(beforeToggle + 2)
+  })
+
+  it('loads public HTTP without changing the sandbox policy', () => {
+    const store = createBrowserStore().create('browser-controller-http-test')
+    const face = createBrowserControllers(store.actions)
+    const tabLifetime = lifetime()
+    face.mount(TAB, tabLifetime.signal, APP)
+
+    face.loadUrl(TAB, 'http://example.test/path')
+    expect(face.keyedHooks.browserFrame(TAB)?.getSnapshot()).toMatchObject({
+      sandboxed: true,
+      document: { src: 'http://example.test/path' },
     })
   })
 })

+ 12 - 1
packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts

@@ -18,10 +18,21 @@ describe('IframeImpl', () => {
       src: 'https://example.test/',
       revision: 4,
     }
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
     frame.setDocument(document)
+    frame.reportLoadFailed(3)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    frame.reportLoadFailed(4)
+    frame.reportLoadFailed(4)
+    expect(frame.getSnapshot().loadFailed).toBe(true)
     frame.reportLoaded(4)
     expect(loaded).toHaveBeenCalledWith(4)
-    expect(frame.clearDocument()).toBe(document)
+    frame.setDocument({ ...document, revision: 5 })
+    expect(frame.getSnapshot().loadFailed).toBe(false)
+    expect(frame.clearDocument()?.revision).toBe(5)
+    frame.reportLoadFailed(5)
+    expect(frame.getSnapshot().loadFailed).toBe(false)
     expect(frame.clearDocument()).toBeUndefined()
     expect(listener).toHaveBeenCalled()
     unsubscribe()

+ 2 - 11
packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts

@@ -51,7 +51,7 @@ describe('BrowserNavigation', () => {
     expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reload.revision })
   })
 
-  it('keeps blocked requests revision-scoped and leaves address failures outside navigation', () => {
+  it('leaves address failures outside navigation', () => {
     const navigation = new BrowserNavigation()
     navigation.frameLoaded(1)
     navigation.navigate(httpsTarget(1))
@@ -60,18 +60,9 @@ describe('BrowserNavigation', () => {
     navigation.frameLoaded(revision)
     expect(navigation.snapshot.navigation).toEqual({ status: 'known', revision })
 
-    navigation.reload()
-    const reloadRevision = navigation.snapshot.request!.revision
-    navigation.requestBlocked(reloadRevision - 1, 'loopback')
-    expect(navigation.snapshot.navigation).toEqual({ status: 'loading', revision: reloadRevision })
-    navigation.requestBlocked(reloadRevision, 'loopback')
-    expect(navigation.snapshot).toMatchObject({
-      navigation: { status: 'failed', revision: reloadRevision },
-      failure: { kind: 'address', reason: 'loopback' },
-    })
     navigation.addressFailed('invalid')
     expect(navigation.snapshot).toMatchObject({
-      navigation: { status: 'failed', revision: reloadRevision },
+      navigation: { status: 'known', revision },
       failure: { kind: 'address', reason: 'invalid' },
     })
   })

+ 5 - 3
packages/client/ui-sidebar-browser/tests/url.client.spec.ts

@@ -16,14 +16,16 @@ describe('Browser address policy', () => {
     })
   })
 
-  it('allows HTTP only on explicit loopback hosts', () => {
+  it('accepts HTTP including loopback hosts', () => {
     expect(parseBrowserAddress('http://localhost:5173/app', APP)).toEqual({
       ok: true, target: { kind: 'http', url: 'http://localhost:5173/app', title: 'localhost' },
     })
     expect(parseBrowserAddress('http://127.42.0.9/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
     expect(parseBrowserAddress('http://[::1]:8080/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
-    expect(parseBrowserAddress('http://example.com/', APP)).toEqual({ ok: false, reason: 'protocol' })
-    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toEqual({ ok: false, reason: 'protocol' })
+    expect(parseBrowserAddress('http://example.com/', APP)).toEqual({
+      ok: true, target: { kind: 'http', url: 'http://example.com/', title: 'example.com' },
+    })
+    expect(parseBrowserAddress('http://128.0.0.1/', APP)).toMatchObject({ ok: true, target: { kind: 'http' } })
   })
 
   it('rejects every undeclared or privileged form', () => {

+ 3 - 2
snapshots/web/sidebar-browser/browser.expected.md

@@ -2,7 +2,8 @@
 
 - HTTPS sandbox: allow-scripts allow-forms allow-same-origin allow-popups allow-popups-to-escape-sandbox
 - Permissions Policy: browser defaults
-- Sandbox toggle: per-tab and temporary; disabling also allows loopback navigation
+- Sandbox toggle: per-tab and temporary
 - Unknown navigation: marker shown; Back, Forward, and external-open disabled
 - HTTPS history: one -> two -> one -> two
-- Invalid protocol: Only HTTPS and loopback HTTP addresses are supported; use Document Preview for local files.
+- Loopback HTTP: loaded under the default sandbox
+- Invalid protocol: Only HTTP and HTTPS addresses are supported; use Document Preview for local files.