diff --git a/docs/images/video-preview-desktop.png b/docs/images/video-preview-desktop.png new file mode 100644 index 000000000..63421d624 Binary files /dev/null and b/docs/images/video-preview-desktop.png differ diff --git a/docs/images/video-preview-mobile.png b/docs/images/video-preview-mobile.png new file mode 100644 index 000000000..91bf0675c Binary files /dev/null and b/docs/images/video-preview-mobile.png differ diff --git a/src/web/middleware/auth.ts b/src/web/middleware/auth.ts index 7487b24c2..cd27483a8 100644 --- a/src/web/middleware/auth.ts +++ b/src/web/middleware/auth.ts @@ -831,7 +831,7 @@ export function registerSecurityHeaders(app: FastifyInstance, https: boolean, ba const workerSrc = "; worker-src 'self' blob:"; const csp = `default-src 'self'; ${scriptSrc}; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; ` + - `img-src 'self' data: blob:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`; + `img-src 'self' data: blob:; media-src 'self' blob: https: http:; ${connectSrc}; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'${workerSrc}`; app.addHook('onRequest', (req, reply, done) => { reply.header('X-Content-Type-Options', 'nosniff'); diff --git a/src/web/public/app.js b/src/web/public/app.js index bc1abc2a1..0a26226bc 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3645,6 +3645,12 @@ class CodemanApp { return; } + if (urlLink && !ev.ctrlKey && !ev.metaKey && !ev.shiftKey && !ev.altKey && this.openVideoUrlPreview?.(urlLink.href, body.dataset.rvSessionId || this.activeSessionId)) { + ev.preventDefault(); + ev.stopPropagation(); + return; + } + // One-click copy: lift the raw source from the sibling
, or —
       // COD-263 — the rendered text of the sibling 
. const copyBtn = ev.target.closest('.rv-copy-btn'); diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5e32a7a69..a5ee17017 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -5262,6 +5262,70 @@ Object.assign(CodemanApp.prototype, { } }, + /** Direct media loading stays in the browser; external URLs never become server fetches. */ + openVideoUrlPreview(value, sessionId = this.activeSessionId) { + if (typeof value !== 'string' || !/^https?:\/\//i.test(value)) return false; + let url; + try { + url = new URL(value); + if (url.username || url.password || !/\.(mp4|webm|mov|m4v|ogv)$/i.test(url.pathname)) return false; + } catch { return false; } + const overlay = this.$('filePreviewOverlay'); + const body = this.$('filePreviewBody'); + if (!overlay || !body) return false; + if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return true; + + this._stopFilePreviewPolling(); + this._filePreviewLoad = (this._filePreviewLoad || 0) + 1; + this._stopFilePreviewMedia(); + this._resetFilePreviewEdit?.(); + this._resetFilePreviewExternalAction?.(); + this._resetFilePreviewOriginalDownloadAction?.(); + this._teardownOfficePreviewResources?.(); + this._teardownOutlineScrollSpy?.(); + this._closeAnnotationPopover?.(); + this._closeAnnotationTray?.(); + this._hideFilePreviewNavigation(); + this._updateFolderPreviewNav?.(null); + if (this.folderGrid) this.folderGrid.index = null; + this.filePreviewText = null; + this.filePreviewContent = ''; + this.filePreviewPage = null; + this.filePreviewDetachUrl = ''; + this._annotations = []; + this._updateFilePreviewToolbar?.('none'); + const detach = this.$('filePreviewDetachBtn'); + if (detach) detach.hidden = true; + const folder = this.$('filePreviewFolderBtn'); + if (folder) folder.hidden = true; + const state = this.filePreviewState = { sessionId, filePath: url.href, previewKind: 'video', externalPreviewUrl: url.href }; + this._recordFilePreviewHistory(url.href, sessionId, null, {}); + this._bindFilePreviewChrome(); + this._restorePreviewSize(); + this._applyFilePreviewExpanded(); + overlay.classList.add('visible'); + let name = url.pathname.split('/').pop(); + try { name = decodeURIComponent(name); } catch { /* Keep an undecodable file name readable. */ } + this.$('filePreviewTitle').textContent = name; + const footer = this.$('filePreviewFooter'); + footer.textContent = `Video • ${url.host}`; + const external = this.$('filePreviewOpenNewTab'); + if (external) external.disabled = false; + const video = document.createElement('video'); + video.controls = true; + video.autoplay = true; + video.playsInline = true; + video.preload = 'metadata'; + video.style.width = '100%'; + video.setAttribute('aria-label', name); + video.addEventListener('error', () => { + if (this.filePreviewState === state) footer.textContent = 'Video could not play. Try “Open in new tab” in the toolbar.'; + }); + video.src = url.href; + body.replaceChildren(video); + return true; + }, + /** * @param {object} [options] * @param {{folderId: string, name: string}} [options.folderFile] A file of a @@ -5269,6 +5333,7 @@ Object.assign(CodemanApp.prototype, { * paging through 300 renders never touches the attachment registry. */ async openFilePreview(filePath, sessionId = this.activeSessionId, attachmentId = null, options = {}) { + if (!attachmentId && this.openVideoUrlPreview?.(filePath, sessionId)) return; if (!sessionId || !filePath) return; const folderFile = options.folderFile || null; // Prev/next only while browsing a folder grid; a preview opened from anywhere diff --git a/src/web/public/sw.js b/src/web/public/sw.js index dffb03fd5..a123134ea 100644 --- a/src/web/public/sw.js +++ b/src/web/public/sw.js @@ -100,6 +100,9 @@ self.addEventListener('fetch', (event) => { if (request.method !== 'GET') return; if (request.headers.get('upgrade') === 'websocket') return; if (request.headers.get('accept') === 'text/event-stream') return; + // Let the browser stream and seek media directly. Cloning/caching a video + // duplicates large downloads, and Cache.put cannot store partial (206) responses. + if (request.destination === 'video' || request.destination === 'audio' || request.headers.get('range')) return; if (request.url.includes('/api/')) return; // A rendered HTML page is served `no-store` behind a capability that logout // revokes; caching a popped-out page here would let the offline fallback diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index d5aa45e4d..635d9b53e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -613,7 +613,7 @@ function _debugEchoLogOuter() { } catch {} } -const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt']); +const TERMINAL_FILE_URI_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'pdf', 'docx', 'pptx', 'xlsx', 'md', 'txt', 'mp4', 'webm', 'mov', 'm4v', 'ogv']); Object.assign(CodemanApp.prototype, { @@ -2556,6 +2556,11 @@ Object.assign(CodemanApp.prototype, { 'docx', 'pptx', 'xlsx', + 'mp4', + 'webm', + 'mov', + 'm4v', + 'ogv', 'json', 'yaml', 'yml', @@ -2576,6 +2581,11 @@ Object.assign(CodemanApp.prototype, { 'docx', 'pptx', 'xlsx', + 'mp4', + 'webm', + 'mov', + 'm4v', + 'ogv', 'md', 'txt', ]); @@ -3662,6 +3672,8 @@ Object.assign(CodemanApp.prototype, { return { activate: (_event, text) => { if (/^https?:\/\/[^\s\x00-\x1f]+$/i.test(text)) { + const proxied = window.CodemanWebviewLinks?.linkNeedsWebTabProxy?.(text, window.location?.hostname); + if (!proxied && this.openVideoUrlPreview?.(text, getSessionId())) return; if (window.confirm(`Open this link?\n\n${text}`)) this._openTerminalUrl(text); return; } @@ -3679,9 +3691,10 @@ Object.assign(CodemanApp.prototype, { _openTerminalUrl(text) { // A `localhost` link tapped from another device can only work through the // server: route it into a proxied web tab (webview-tabs.js). Anything else - // opens as before. Lives here rather than in the link provider's activate() + // uses the video preview or opens normally. Lives here rather than in the link provider's activate() // so every caller of this one opener gets the same routing. if (this.openLinkThroughWebTabIfLoopback?.(text)) return; + if (this.openVideoUrlPreview?.(text)) return; window.open(text, '_blank', 'noopener,noreferrer'); }, @@ -3792,18 +3805,25 @@ Object.assign(CodemanApp.prototype, { const bufferLineNumber = (buffer.viewportY || 0) + cell.row; let activated = false; + // xterm's native OSC 8 provider owns labelled links, whose visible text + // need not contain the URL. Its normal mouse path never runs on touch. + const nativeProvider = this.terminal?._core?._linkProviderService?.linkProviders?.[0]; + const providers = nativeProvider && nativeProvider !== provider ? [nativeProvider, provider] : [provider]; try { - provider.provideLinks(bufferLineNumber, (links) => { - const link = links?.find((candidate) => { - const { start, end } = candidate.range; - const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x); - const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x); - return afterStart && beforeEnd; + for (const candidateProvider of providers) { + if (activated) break; + candidateProvider.provideLinks(bufferLineNumber, (links) => { + const link = links?.find((candidate) => { + const { start, end } = candidate.range; + const afterStart = bufferLineNumber > start.y || (bufferLineNumber === start.y && cell.col >= start.x); + const beforeEnd = bufferLineNumber < end.y || (bufferLineNumber === end.y && cell.col <= end.x); + return afterStart && beforeEnd; + }); + if (!link || typeof link.activate !== 'function') return; + link.activate(event, link.text); + activated = true; }); - if (!link || typeof link.activate !== 'function') return; - link.activate(event, link.text); - activated = true; - }); + } } catch (err) { console.warn('[LinkProvider] tap activation failed:', err); return false; diff --git a/test/fixtures/media/preview.mp4 b/test/fixtures/media/preview.mp4 new file mode 100644 index 000000000..785d9b01d Binary files /dev/null and b/test/fixtures/media/preview.mp4 differ diff --git a/test/routes/file-uri-preview.test.ts b/test/routes/file-uri-preview.test.ts index 0c1d5a98e..a392ae6d4 100644 --- a/test/routes/file-uri-preview.test.ts +++ b/test/routes/file-uri-preview.test.ts @@ -143,6 +143,23 @@ describe('POST /api/sessions/:id/file-uri-preview', () => { }); } + it('registers a generated MP4 and streams seekable video bytes through its guarded attachment route', async () => { + const filePath = join(workspaceDir, 'preview.mp4'); + await fs.writeFile(filePath, 'video-bytes'); + const registered = await postUri(pathToFileURL(filePath).href); + expect(registered.statusCode).toBe(200); + const id = registered.json().data.attachmentId; + const response = await app.inject({ + method: 'GET', + url: `/api/sessions/${ctx._sessionId}/attachments/${id}/raw`, + headers: { range: 'bytes=0-3' }, + }); + expect(response.statusCode).toBe(206); + expect(response.headers['content-type']).toContain('video/mp4'); + expect(response.headers['content-range']).toBe('bytes 0-3/11'); + expect(response.body).toBe('vide'); + }); + it('resolves a percent-encoded local artifact to sanitized opaque attachment metadata', async () => { const filePath = join(workspaceDir, 'mockup one.png'); await fs.writeFile(filePath, 'image-bytes'); diff --git a/test/sw-fetch-skip.test.ts b/test/sw-fetch-skip.test.ts index 2280960a3..515893b60 100644 --- a/test/sw-fetch-skip.test.ts +++ b/test/sw-fetch-skip.test.ts @@ -33,7 +33,11 @@ function loadFetchListener(): (event: unknown) => void { function fetchEvent(path: string) { return { - request: { method: 'GET', url: `http://localhost${path}`, headers: { get: () => null } }, + request: { + method: 'GET', + url: `http://localhost${path}`, + headers: { get: (_name: string): string | null => null }, + }, respondWith: vi.fn(), }; } @@ -48,6 +52,22 @@ describe('service worker fetch skip list', () => { } }); + it.each(['video', 'audio'])('does not cache %s streams', (destination) => { + const onFetch = loadFetchListener(); + const event = fetchEvent('/preview.mp4?token=private'); + Object.assign(event.request, { destination }); + onFetch(event); + expect(event.respondWith).not.toHaveBeenCalled(); + }); + + it('leaves range requests to the native network path even without a media destination', () => { + const onFetch = loadFetchListener(); + const event = fetchEvent('/download'); + event.request.headers.get = (name: string) => (name === 'range' ? 'bytes=0-1023' : null); + onFetch(event); + expect(event.respondWith).not.toHaveBeenCalled(); + }); + it('still answers an app asset', () => { const onFetch = loadFetchListener(); const event = fetchEvent('/app.js'); diff --git a/test/terminal-document-link-provider.test.ts b/test/terminal-document-link-provider.test.ts index f7193bdd0..0e70115c4 100644 --- a/test/terminal-document-link-provider.test.ts +++ b/test/terminal-document-link-provider.test.ts @@ -1263,3 +1263,15 @@ it('routes native Codex file hyperlinks through the guarded preview using the ow handler.activate(null, uri); expect(preview).toHaveBeenCalledTimes(1); }); + +it.each(['mp4', 'webm', 'mov', 'm4v', 'ogv'])( + 'routes generated %s file URI labels to the guarded video preview', + (extension) => { + const { proto } = loadTerminalUiPrototype(); + const host = { sessions: new Map([['video-pane', { mode: 'codex' }]]), openFileUriPreview: vi.fn() }; + const handler = proto._terminalOsc8LinkHandler.call(host, { getSessionId: () => 'video-pane' }); + const uri = `file:///Users/example/output/preview.${extension}`; + handler.activate(null, uri); + expect(host.openFileUriPreview).toHaveBeenCalledExactlyOnceWith(uri, 'video-pane'); + } +); diff --git a/test/video-url-preview.browser.test.ts b/test/video-url-preview.browser.test.ts new file mode 100644 index 000000000..fa474e22b --- /dev/null +++ b/test/video-url-preview.browser.test.ts @@ -0,0 +1,155 @@ +import { resolve } from 'node:path'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const clipPath = resolve(import.meta.dirname, 'fixtures/media/preview.mp4'); +const videoUrl = 'https://media.example.test/generated%20preview.MP4?token=a%2Bb&download=0#t=0.2'; +let server: WebServer; +let browser: Browser; +let base: string; + +async function openPage(mobile = false): Promise { + const page = await browser.newPage({ + viewport: mobile ? { width: 393, height: 852 } : { width: 1280, height: 850 }, + isMobile: mobile, + hasTouch: mobile, + }); + await page.route('https://media.example.test/**', async (route) => { + if (route.request().url().includes('broken.mp4')) { + await route.fulfill({ status: 404, body: 'Not found' }); + return; + } + await route.fulfill({ + status: 200, + contentType: 'video/mp4', + path: clipPath, + headers: { 'Accept-Ranges': 'bytes' }, + }); + }); + await page.goto(base, { waitUntil: 'domcontentloaded' }); + await page.waitForSelector('.xterm', { state: 'attached' }); + await page.waitForTimeout(3000); + await page.waitForFunction(() => !!navigator.serviceWorker.controller); + return page; +} + +async function expectPlaying(page: Page) { + await page.waitForFunction( + () => { + const video = document.querySelector('#filePreviewBody video'); + return video && (video.error || (video.readyState >= 1 && video.duration > 0)); + }, + undefined, + { timeout: 10000 } + ); + const error = await page + .locator('#filePreviewBody video') + .evaluate((el: HTMLVideoElement) => el.error?.message || null); + expect(error).toBeNull(); + await page.evaluate(`document.querySelector('#filePreviewBody video').play()`); + await page.waitForFunction(() => document.querySelector('#filePreviewBody video')!.currentTime > 0); +} + +describe('linked video previews', () => { + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + base = `http://127.0.0.1:${server.boundPort}`; + browser = await chromium.launch({ headless: true }); + }); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + for (const { mobile, labelled } of [ + { mobile: false, labelled: false }, + { mobile: true, labelled: false }, + { mobile: false, labelled: true }, + { mobile: true, labelled: true }, + ]) { + it(`plays a signed ${labelled ? 'labelled' : 'plain'} video link on ${mobile ? 'mobile' : 'desktop'} and unloads on close`, async () => { + const page = await openPage(mobile); + const popups: Page[] = []; + page.on('popup', (popup) => popups.push(popup)); + try { + await page.evaluate('window.__videoTestApp = app'); + const renderedLink = labelled ? `\x1b]8;;${videoUrl}\x07Watch preview\x1b]8;;\x07` : videoUrl; + const point = await page.evaluate(async (url) => { + // Exercise the terminal's real link provider and touch/click path. + const app = (window as any).__videoTestApp; + app.hideWelcome(); + app.terminal.reset(); + await new Promise((resolve) => app.terminal.write(url + '\r\n', resolve)); + const rect = app.terminal.element.querySelector('.xterm-screen').getBoundingClientRect(); + const cell = app.terminal._core._renderService.dimensions.css.cell; + return { x: rect.left + cell.width * 8.5, y: rect.top + cell.height * 0.5 }; + }, renderedLink); + if (mobile) await page.touchscreen.tap(point.x, point.y); + else { + await page.mouse.move(point.x, point.y); + await page.waitForTimeout(150); + await page.mouse.click(point.x, point.y); + } + await page.waitForSelector('#filePreviewOverlay.visible'); + expect(await page.locator('#filePreviewOverlay').isVisible()).toBe(true); + expect(await page.locator('#filePreviewTitle').textContent()).toBe('generated preview.MP4'); + expect(await page.locator('#filePreviewBody video').getAttribute('src')).toBe(videoUrl); + expect(await page.locator('#filePreviewBody video').getAttribute('controls')).not.toBeNull(); + expect(await page.locator('#filePreviewBody video').getAttribute('playsinline')).not.toBeNull(); + await expectPlaying(page); + await page.evaluate(`document.querySelector('#filePreviewBody video').currentTime = 1`); + await page.waitForFunction( + () => document.querySelector('#filePreviewBody video')!.currentTime >= 1 + ); + const viewport = page.viewportSize()!; + const box = await page.locator('#filePreviewBody video').boundingBox(); + expect(box!.width).toBeLessThanOrEqual(viewport.width); + expect(box!.height).toBeLessThanOrEqual(viewport.height); + expect(popups).toHaveLength(0); + await page.screenshot({ path: `/tmp/codeman-video-preview-${mobile ? 'mobile' : 'desktop'}.png` }); + await page.evaluate(`window.__oldVideo = document.querySelector('#filePreviewBody video')`); + await page.keyboard.press('Escape'); + expect(await page.locator('#filePreviewOverlay').isVisible()).toBe(false); + expect(await page.evaluate(`window.__oldVideo.paused && !window.__oldVideo.getAttribute('src')`)).toBe(true); + } finally { + await page.close(); + } + }); + } + + it('opens a response link on a real click, stops the prior video on replacement, and offers error fallback', async () => { + const page = await openPage(); + try { + await page.evaluate(`(() => { + const body = document.createElement('div'); body.id='video-link-test'; + body.innerHTML='Watch preview'; + document.body.append(body); app._bindResponseViewerInteractions(body); + })()`); + await page.locator('#video-link-test a').click(); + await expectPlaying(page); + await page.evaluate( + `window.__oldVideo = document.querySelector('#filePreviewBody video'); app._openTerminalUrl('https://media.example.test/broken.mp4')` + ); + expect(await page.evaluate(`window.__oldVideo.paused && !window.__oldVideo.getAttribute('src')`)).toBe(true); + await page.waitForFunction(() => + document.getElementById('filePreviewFooter')!.textContent!.includes('could not play') + ); + expect(await page.locator('#filePreviewOpenNewTab').isEnabled()).toBe(true); + expect(await page.evaluate(`app.filePreviewState.externalPreviewUrl`)).toBe( + 'https://media.example.test/broken.mp4' + ); + } finally { + await page.close(); + } + }); + + it('permits external media in CSP while retaining script and frame restrictions', async () => { + const response = await fetch(base); + const csp = response.headers.get('content-security-policy')!; + expect(csp).toContain("media-src 'self' blob: https: http:"); + expect(csp).toContain("script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net;"); + expect(csp).toContain("frame-ancestors 'self'"); + }); +}); diff --git a/test/video-url-preview.test.ts b/test/video-url-preview.test.ts new file mode 100644 index 000000000..f1293e0a8 --- /dev/null +++ b/test/video-url-preview.test.ts @@ -0,0 +1,80 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +function method(file: string, name: string) { + const source = readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8'); + const tree = ts.createSourceFile(file, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + let result = ''; + function visit(node: ts.Node) { + if (ts.isMethodDeclaration(node) && node.name.getText(tree) === name) result = node.getText(tree); + ts.forEachChild(node, visit); + } + visit(tree); + if (!result) throw new Error(`Missing ${name}`); + return result; +} + +describe('direct video link routing', () => { + it.each([ + 'javascript:alert(1)', + 'data:video/mp4;base64,AAAA', + 'file:///tmp/movie.mp4', + 'ftp://example.test/movie.mp4', + 'https://user:password@example.test/movie.mp4', + 'https://example.test/page?file=movie.mp4', + 'https://example.test/movie.mp4.html', + 'https://example.test/movie', + 'https://', + null, + '/workspace/movie.mp4', + ])('does not embed an unsupported or credential-bearing URL: %s', (url) => { + const open = vm.runInNewContext(`({${method('panels-ui.js', 'openVideoUrlPreview')}})`, { URL }); + const host = { $: vi.fn(), activeSessionId: null }; + expect(open.openVideoUrlPreview.call(host, url)).toBe(false); + expect(host.$).not.toHaveBeenCalled(); + }); + + it('consumes a video link without opening a new browser tab', () => { + const external = vi.fn(); + const host = { + openLinkThroughWebTabIfLoopback: vi.fn(() => false), + openVideoUrlPreview: vi.fn(() => true), + }; + const opener = vm.runInNewContext(`({${method('terminal-ui.js', '_openTerminalUrl')}})`, { + window: { open: external }, + }); + opener._openTerminalUrl.call(host, 'https://example.test/movie.mp4'); + expect(host.openVideoUrlPreview).toHaveBeenCalledWith('https://example.test/movie.mp4'); + expect(external).not.toHaveBeenCalled(); + }); + + it('opens a labelled OSC 8 video directly with the originating session and no external-link prompt', () => { + const confirm = vi.fn(); + const host = { openVideoUrlPreview: vi.fn(() => true), _openTerminalUrl: vi.fn() }; + const factory = vm.runInNewContext(`({${method('terminal-ui.js', '_terminalOsc8LinkHandler')}})`, { + window: { confirm }, + }); + factory._terminalOsc8LinkHandler + .call(host, { getSessionId: () => 'source-session' }) + .activate({}, 'https://example.test/movie.mp4'); + expect(host.openVideoUrlPreview).toHaveBeenCalledWith('https://example.test/movie.mp4', 'source-session'); + expect(confirm).not.toHaveBeenCalled(); + expect(host._openTerminalUrl).not.toHaveBeenCalled(); + }); + + it('preserves the loopback proxy and ordinary external-link behavior', () => { + const external = vi.fn(); + const host = { openLinkThroughWebTabIfLoopback: vi.fn(() => true), openVideoUrlPreview: vi.fn(() => false) }; + const opener = vm.runInNewContext(`({${method('terminal-ui.js', '_openTerminalUrl')}})`, { + window: { open: external }, + }); + opener._openTerminalUrl.call(host, 'http://localhost:8080/movie.mp4'); + expect(host.openVideoUrlPreview).not.toHaveBeenCalled(); + host.openLinkThroughWebTabIfLoopback.mockReturnValue(false); + opener._openTerminalUrl.call(host, 'https://example.test/docs'); + expect(external).toHaveBeenCalledWith('https://example.test/docs', '_blank', 'noopener,noreferrer'); + }); +});