diff --git a/apps/web/tests/sidebar-browser.e2e.ts b/apps/web/tests/sidebar-browser.e2e.ts index a6fb7c477c..7642d65d65 100644 --- a/apps/web/tests/sidebar-browser.e2e.ts +++ b/apps/web/tests/sidebar-browser.e2e.ts @@ -39,6 +39,10 @@ describe.skipIf(MODE === 'record')('web e2e: Sidebar Browser', () => { body: `

${name}

Inside navigation

isolated HTTPS fixture

`, }) }) + await page.route('http://127.0.0.1:3080/**', async route => route.fulfill({ + contentType: 'text/html', + body: '

loopback

', + })) 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']) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index ba7e9a012d..9fd0effbc5 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/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' diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts index 83134b7c96..e00088728d 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/BrowserController.ts +++ b/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): 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) }, } } diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts index 2d2b1a9c0e..8aac956193 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/BrowserFrame.ts +++ b/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 { 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 = 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 }) + } } diff --git a/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts b/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts index 751eb24197..d8f8508a0e 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/BrowserNavigation.ts +++ b/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, diff --git a/packages/client/ui-sidebar-browser/src/client/browser/url.ts b/packages/client/ui-sidebar-browser/src/client/browser/url.ts index 34cc74a6c4..a882fa68b2 100644 --- a/packages/client/ui-sidebar-browser/src/client/browser/url.ts +++ b/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) -} diff --git a/packages/client/ui-sidebar-browser/src/client/index.ts b/packages/client/ui-sidebar-browser/src/client/index.ts index 8af71de477..8a301255ba 100644 --- a/packages/client/ui-sidebar-browser/src/client/index.ts +++ b/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' diff --git a/packages/client/ui-sidebar-browser/src/client/locales.ts b/packages/client/ui-sidebar-browser/src/client/locales.ts index 7d22b4c5da..d4fc96daf0 100644 --- a/packages/client/ui-sidebar-browser/src/client/locales.ts +++ b/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': '只支持 HTTPS 和 loopback HTTP 地址;本地文件请使用文档预览。', + 'error.protocol': '只支持 HTTP 和 HTTPS 地址;本地文件请使用文档预览。', 'error.credentials': '地址不能包含用户名或密码。', 'error.application-origin': '不能在嵌入浏览器中打开 DSH 应用自身。', - 'error.loopback': '关闭沙箱限制后才能打开 loopback 地址。', + 'web.loadFailed': '页面报告加载失败或可能禁止嵌入;可尝试在系统浏览器中打开。', 'web.unknown': '页面已在 iframe 内跳转;Web 模式无法读取当前 URL。', } satisfies Record @@ -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 diff --git a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx b/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx index ddd31cc8b5..022104e4b7 100644 --- a/packages/client/ui-sidebar-browser/src/client/view/BrowserBody.tsx +++ b/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 ( + + + {sandboxed + ? + : } + + ) +} /** 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 (
@@ -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.