Path: blob/main/src/vs/workbench/api/test/browser/extHostBrowsers.test.ts
13401 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import type * as vscode from 'vscode';6import assert from 'assert';7import { mock } from '../../../../base/test/common/mock.js';8import { BrowserTabDto, MainThreadBrowsersShape } from '../../common/extHost.protocol.js';9import { ExtHostBrowsers } from '../../common/extHostBrowsers.js';10import { SingleProxyRPCProtocol } from '../common/testRPCProtocol.js';11import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';1213suite('ExtHostBrowsers', () => {1415const store = ensureNoDisposablesAreLeakedInTestSuite();1617const defaultDto: BrowserTabDto = {18id: 'browser-1',19url: 'https://example.com',20title: 'Example',21favicon: undefined,22};2324function createDto(overrides?: Partial<BrowserTabDto>): BrowserTabDto {25return { ...defaultDto, ...overrides };26}2728function createExtHostBrowsers(overrides?: Partial<MainThreadBrowsersShape>): ExtHostBrowsers {29const proxy = new class extends mock<MainThreadBrowsersShape>() {30override $openBrowserTab(): Promise<BrowserTabDto> { return Promise.resolve(createDto()); }31override $startCDPSession(): Promise<void> { return Promise.resolve(); }32override $closeCDPSession(): Promise<void> { return Promise.resolve(); }33override $sendCDPMessage(): Promise<void> { return Promise.resolve(); }34override $closeBrowserTab(): Promise<void> { return Promise.resolve(); }35};36if (overrides) {37Object.assign(proxy, overrides);38}39return store.add(new ExtHostBrowsers(SingleProxyRPCProtocol(proxy)));40}4142// #region browserTabs4344test('browserTabs populates from $onDidOpenBrowserTab', () => {45const extHost = createExtHostBrowsers();46extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com', title: 'One' }));47extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com', title: 'Two' }));4849const tabs = extHost.browserTabs;50assert.strictEqual(tabs.length, 2);51assert.strictEqual(tabs[0].url, 'https://one.com');52assert.strictEqual(tabs[1].url, 'https://two.com');53});5455test('browserTabs returns a snapshot, not a live array', () => {56const extHost = createExtHostBrowsers();57extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));58const snapshot1 = extHost.browserTabs;5960extHost.$onDidOpenBrowserTab(createDto({ id: 'b2' }));61const snapshot2 = extHost.browserTabs;6263assert.notStrictEqual(snapshot1, snapshot2);64assert.strictEqual(snapshot1.length, 1);65assert.strictEqual(snapshot2.length, 2);66});6768// #endregion6970// #region activeBrowserTab7172test('activeBrowserTab updates via $onDidChangeActiveBrowserTab', () => {73const extHost = createExtHostBrowsers();74const dto = createDto({ id: 'b1', url: 'https://active.com' });75extHost.$onDidOpenBrowserTab(dto);76extHost.$onDidChangeActiveBrowserTab('b1');7778assert.strictEqual(extHost.activeBrowserTab?.url, 'https://active.com');79});8081test('activeBrowserTab becomes undefined when cleared', () => {82const extHost = createExtHostBrowsers();83const dto = createDto({ id: 'b1' });84extHost.$onDidOpenBrowserTab(dto);85extHost.$onDidChangeActiveBrowserTab('b1');86assert.ok(extHost.activeBrowserTab);8788extHost.$onDidChangeActiveBrowserTab(undefined);89assert.strictEqual(extHost.activeBrowserTab, undefined);90});9192test('$onDidChangeActiveBrowserTab with unknown tab returns undefined', () => {93const extHost = createExtHostBrowsers();9495extHost.$onDidChangeActiveBrowserTab('non-existent');9697assert.strictEqual(extHost.activeBrowserTab, undefined);98});99100// #endregion101102// #region openBrowserTab103104test('openBrowserTab returns a BrowserTab with correct properties', async () => {105const dto = createDto({ id: 'opened', url: 'https://opened.com', title: 'Opened' });106const extHost = createExtHostBrowsers({107$openBrowserTab: () => Promise.resolve(dto),108});109110const tab = await extHost.openBrowserTab('https://opened.com');111assert.strictEqual(tab.url, 'https://opened.com');112assert.strictEqual(tab.title, 'Opened');113});114115test('openBrowserTab fires onDidOpenBrowserTab for new tabs', async () => {116const extHost = createExtHostBrowsers({117$openBrowserTab: () => Promise.resolve(createDto({ id: 'new-tab' })),118});119const opened: vscode.BrowserTab[] = [];120store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab)));121122await extHost.openBrowserTab('https://example.com');123124assert.strictEqual(opened.length, 1);125assert.strictEqual(opened[0].url, 'https://example.com');126});127128test('openBrowserTab reuses existing tab when IDs match', async () => {129const extHost = createExtHostBrowsers({130$openBrowserTab: () => Promise.resolve(createDto({ id: 'same', url: 'https://updated.com' })),131});132133extHost.$onDidOpenBrowserTab(createDto({ id: 'same', url: 'https://original.com' }));134const tab = await extHost.openBrowserTab('https://updated.com');135136assert.strictEqual(extHost.browserTabs.length, 1);137assert.strictEqual(tab.url, 'https://updated.com');138});139140test('openBrowserTab forwards options to proxy', async () => {141let capturedViewColumn: number | undefined;142let capturedOptions: { preserveFocus?: boolean; inactive?: boolean } | undefined;143const extHost = createExtHostBrowsers({144$openBrowserTab: (_url: string, viewColumn?: number, options?: { preserveFocus?: boolean; inactive?: boolean }) => {145capturedViewColumn = viewColumn;146capturedOptions = options;147return Promise.resolve(createDto({ id: 'opts' }));148},149});150151await extHost.openBrowserTab('https://example.com', { viewColumn: 2, preserveFocus: true, background: true });152153// ViewColumn.from converts API viewColumn (1-based) to EditorGroupColumn (0-based)154assert.strictEqual(capturedViewColumn, 1);155assert.strictEqual(capturedOptions?.preserveFocus, true);156assert.strictEqual(capturedOptions?.inactive, true);157});158159// #endregion160161// #region $onDidOpenBrowserTab162163test('$onDidOpenBrowserTab fires event', () => {164const extHost = createExtHostBrowsers();165const opened: vscode.BrowserTab[] = [];166store.add(extHost.onDidOpenBrowserTab(tab => opened.push(tab)));167168extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://opened.com' }));169170assert.strictEqual(opened.length, 1);171assert.strictEqual(opened[0].url, 'https://opened.com');172});173174// #endregion175176// #region $onDidCloseBrowserTab177178test('$onDidCloseBrowserTab removes tab and fires event', () => {179const extHost = createExtHostBrowsers();180const changes: vscode.BrowserTab[] = [];181store.add(extHost.onDidChangeBrowserTabState(tab => changes.push(tab)));182183extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com' }));184extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com' }));185186assert.strictEqual(changes.length, 1);187assert.strictEqual(changes[0].url, 'https://new.com');188});189190test('$onDidChangeBrowserTabState does not fire when data is unchanged', () => {191const extHost = createExtHostBrowsers();192extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Old Title' }));193194extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://example.com', title: 'New Title' }));195196assert.strictEqual(extHost.browserTabs[0].url, 'https://example.com');197assert.strictEqual(extHost.browserTabs[0].title, 'New Title');198});199200// #endregion201202// #region $onDidChangeActiveBrowserTab event203204test('$onDidChangeActiveBrowserTab fires event', () => {205const extHost = createExtHostBrowsers();206const activeChanges: (string | undefined)[] = [];207store.add(extHost.onDidChangeActiveBrowserTab(tab => activeChanges.push(tab?.url)));208209const dto = createDto({ id: 'b1' });210extHost.$onDidOpenBrowserTab(dto);211extHost.$onDidChangeActiveBrowserTab('b1');212extHost.$onDidChangeActiveBrowserTab(undefined);213214assert.deepStrictEqual(activeChanges, ['https://example.com', undefined]);215});216217// #endregion218219// #region BrowserTab icon220221test('icon is globe ThemeIcon when no favicon', () => {222const extHost = createExtHostBrowsers();223extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined }));224225assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe');226});227228test('icon is URI when favicon is provided', () => {229const extHost = createExtHostBrowsers();230extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/favicon.ico' }));231232assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/favicon.ico');233});234235test('icon updates when favicon changes', () => {236const extHost = createExtHostBrowsers();237extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: undefined }));238assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe');239240extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: 'https://example.com/new.ico' }));241assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/new.ico');242});243244test('icon reverts to globe when favicon is cleared', () => {245const extHost = createExtHostBrowsers();246extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', favicon: 'https://example.com/icon.ico' }));247assert.strictEqual(String(extHost.browserTabs[0].icon), 'https://example.com/icon.ico');248249extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', favicon: undefined }));250assert.strictEqual((extHost.browserTabs[0].icon as { id: string }).id, 'globe');251});252253// #endregion254255// #region BrowserTab readonly properties256257test('tab properties are not directly writable', () => {258const extHost = createExtHostBrowsers();259extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://example.com', title: 'Title' }));260const tab = extHost.browserTabs[0];261262// Attempting to assign to getter-only properties should either throw or be silently ignored263assert.throws(() => { (tab as unknown as Record<string, unknown>).url = 'https://hacked.com'; });264assert.throws(() => { (tab as unknown as Record<string, unknown>).title = 'Hacked'; });265assert.strictEqual(tab.url, 'https://example.com');266assert.strictEqual(tab.title, 'Title');267});268269test('startCDPSession calls $startCDPSession on proxy', async () => {270let capturedBrowserId: string | undefined;271const extHost = createExtHostBrowsers({272$startCDPSession: (_sessionId: string, browserId: string) => {273capturedBrowserId = browserId;274return Promise.resolve();275},276});277278extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));279const session = await extHost.browserTabs[0].startCDPSession();280281assert.ok(session);282assert.strictEqual(capturedBrowserId, 'b1');283});284285test('sendMessage validates message structure', async () => {286const extHost = createExtHostBrowsers();287extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));288const session = await extHost.browserTabs[0].startCDPSession();289290// Valid message succeeds291await session.sendMessage({ id: 1, method: 'Page.enable' });292293// Invalid messages are rejected294await assert.rejects(Promise.resolve().then(() => session.sendMessage(null as never)), /must be an object/);295await assert.rejects(Promise.resolve().then(() => session.sendMessage({ method: 'Foo' } as never)), /numeric id/);296await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1 } as never)), /method string/);297});298299test('sendMessage forwards valid message to proxy', async () => {300const sentMessages: unknown[] = [];301const extHost = createExtHostBrowsers({302$sendCDPMessage: (_sid: string, message: unknown) => {303sentMessages.push(message);304return Promise.resolve();305},306});307308extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));309const session = await extHost.browserTabs[0].startCDPSession();310await session.sendMessage({ id: 1, method: 'Page.enable', params: {} });311312assert.strictEqual(sentMessages.length, 1);313assert.deepStrictEqual(sentMessages[0], { id: 1, method: 'Page.enable', params: {}, sessionId: undefined });314});315316test('sendMessage rejects after session is closed', async () => {317const extHost = createExtHostBrowsers();318extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));319const session = await extHost.browserTabs[0].startCDPSession();320321await session.close();322await assert.rejects(Promise.resolve().then(() => session.sendMessage({ id: 1, method: 'Foo' })), /closed/);323});324325test('$onCDPSessionMessage delivers to correct session', async () => {326const capturedIds: string[] = [];327const extHost = createExtHostBrowsers({328$startCDPSession: (sessionId: string) => {329capturedIds.push(sessionId);330return Promise.resolve();331},332});333334extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));335const session1 = await extHost.browserTabs[0].startCDPSession();336const session2 = await extHost.browserTabs[0].startCDPSession();337338const received1: unknown[] = [];339const received2: unknown[] = [];340store.add(session1.onDidReceiveMessage(m => received1.push(m)));341store.add(session2.onDidReceiveMessage(m => received2.push(m)));342343extHost.$onCDPSessionMessage(capturedIds[1], { id: 1, result: { data: 'hello' } });344345assert.deepStrictEqual(received1, []);346assert.deepStrictEqual(received2, [{ id: 1, result: { data: 'hello' } }]);347});348349test('$onCDPSessionClosed fires onDidClose', async () => {350const capturedIds: string[] = [];351const extHost = createExtHostBrowsers({352$startCDPSession: (sessionId: string) => {353capturedIds.push(sessionId);354return Promise.resolve();355},356});357358extHost.$onDidOpenBrowserTab(createDto({ id: 'b1' }));359const session = await extHost.browserTabs[0].startCDPSession();360361let closeFired = false;362store.add(session.onDidClose(() => { closeFired = true; }));363364extHost.$onCDPSessionClosed(capturedIds[0]);365assert.ok(closeFired);366});367368// #endregion369370// #region Reference stability371372test('tab object reference is stable across updates', () => {373const extHost = createExtHostBrowsers();374extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://old.com', title: 'Old' }));375const tabBefore = extHost.browserTabs[0];376377extHost.$onDidChangeBrowserTabState(createDto({ id: 'b1', url: 'https://new.com', title: 'New' }));378const tabAfter = extHost.browserTabs[0];379380assert.strictEqual(tabBefore, tabAfter);381assert.strictEqual(tabAfter.url, 'https://new.com');382});383384test('openBrowserTab returns same reference as browserTabs entry', async () => {385const extHost = createExtHostBrowsers({386$openBrowserTab: () => Promise.resolve(createDto({ id: 'ref-test' })),387});388389const returned = await extHost.openBrowserTab('https://example.com');390const fromArray = extHost.browserTabs[0];391392assert.strictEqual(returned, fromArray);393});394395// #endregion396397// #region Multiple tabs tracked independently398399test('closing one tab does not affect others', () => {400const extHost = createExtHostBrowsers();401extHost.$onDidOpenBrowserTab(createDto({ id: 'b1', url: 'https://one.com' }));402extHost.$onDidOpenBrowserTab(createDto({ id: 'b2', url: 'https://two.com' }));403extHost.$onDidOpenBrowserTab(createDto({ id: 'b3', url: 'https://three.com' }));404405extHost.$onDidCloseBrowserTab('b2');406407assert.strictEqual(extHost.browserTabs.length, 2);408assert.deepStrictEqual(extHost.browserTabs.map(t => t.url), ['https://one.com', 'https://three.com']);409});410411test('closing active tab clears activeBrowserTab', () => {412const extHost = createExtHostBrowsers();413const dto = createDto({ id: 'b1' });414extHost.$onDidOpenBrowserTab(dto);415extHost.$onDidChangeActiveBrowserTab('b1');416assert.ok(extHost.activeBrowserTab);417418extHost.$onDidCloseBrowserTab('b1');419assert.strictEqual(extHost.activeBrowserTab, undefined);420});421422// #endregion423});424425426