@@ -120,9 +130,10 @@ export function BrowserBody(props: BrowserBodyProps): ReactNode {
aria-pressed={!sandboxed}
disabled={mountCount === 0}
onClick={() => { toggleSandbox(tab.id) }}
- >
+ >
{!sandboxed &&
{t('sandbox.warning')}
}
+ {loadFailed &&
{t('web.loadFailed')}
}
{failure !== undefined &&
{failure}
}
{document === undefined
?
{placeholder}
@@ -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 &&
{t('web.unknown')}
}
diff --git a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx b/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx
index aaa52b13ef..514974cd92 100644
--- a/packages/client/ui-sidebar-browser/tests/browser-body.client.spec.tsx
+++ b/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(
)
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 () => {
diff --git a/packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts b/packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts
index 787ec0ba68..3801351adb 100644
--- a/packages/client/ui-sidebar-browser/tests/browser-controller.client.spec.ts
+++ b/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' },
})
})
})
diff --git a/packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts b/packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts
index 595a0a14aa..c21b38c856 100644
--- a/packages/client/ui-sidebar-browser/tests/browser-frame.client.spec.ts
+++ b/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()
diff --git a/packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts b/packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts
index ebb63c84a7..cce1f9a0a1 100644
--- a/packages/client/ui-sidebar-browser/tests/browser-navigation.client.spec.ts
+++ b/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' },
})
})
diff --git a/packages/client/ui-sidebar-browser/tests/url.client.spec.ts b/packages/client/ui-sidebar-browser/tests/url.client.spec.ts
index 89e460c052..7e5e5f31bb 100644
--- a/packages/client/ui-sidebar-browser/tests/url.client.spec.ts
+++ b/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', () => {
diff --git a/snapshots/web/sidebar-browser/browser.expected.md b/snapshots/web/sidebar-browser/browser.expected.md
index b63cbe245f..b455f680f4 100644
--- a/snapshots/web/sidebar-browser/browser.expected.md
+++ b/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.