| // Copyright 2025 The Chromium Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style license that can be |
| // found in the LICENSE file. |
| |
| import * as Common from '../core/common/common.js'; |
| import * as Host from '../core/host/host.js'; |
| import * as Platform from '../core/platform/platform.js'; |
| import * as SDK from '../core/sdk/sdk.js'; |
| import type * as Protocol from '../generated/protocol.js'; |
| import * as Bindings from '../models/bindings/bindings.js'; |
| import * as Breakpoints from '../models/breakpoints/breakpoints.js'; |
| import * as Logs from '../models/logs/logs.js'; |
| import * as Persistence from '../models/persistence/persistence.js'; |
| import * as ProjectSettings from '../models/project_settings/project_settings.js'; |
| import * as Workspace from '../models/workspace/workspace.js'; |
| import * as WorkspaceDiff from '../models/workspace_diff/workspace_diff.js'; |
| import * as AiAssistancePanel from '../panels/ai_assistance/ai_assistance.js'; |
| import * as UI from '../ui/legacy/legacy.js'; |
| |
| import {findMenuItemWithLabel} from './ContextMenuHelpers.js'; |
| import { |
| createTarget, |
| } from './EnvironmentHelpers.js'; |
| import {createContentProviderUISourceCodes, createFileSystemUISourceCode} from './UISourceCodeHelpers.js'; |
| import {createViewFunctionStub} from './ViewFunctionHelpers.js'; |
| |
| function createMockAidaClient(fetch: Host.AidaClient.AidaClient['fetch']): Host.AidaClient.AidaClient { |
| const fetchStub = sinon.stub(); |
| const registerClientEventStub = sinon.stub(); |
| return { |
| fetch: fetchStub.callsFake(fetch), |
| registerClientEvent: registerClientEventStub, |
| }; |
| } |
| |
| export const MockAidaAbortError = { |
| abortError: true, |
| } as const; |
| |
| export const MockAidaFetchError = { |
| fetchError: true, |
| } as const; |
| |
| export type MockAidaResponse = Omit<Host.AidaClient.AidaResponse, 'completed'|'metadata'>& |
| {metadata?: Host.AidaClient.AidaResponseMetadata}|typeof MockAidaAbortError|typeof MockAidaFetchError; |
| |
| /** |
| * Creates a mock AIDA client that responds using `data`. |
| * |
| * Each first-level item of `data` is a single response. |
| * Each second-level item of `data` is a chunk of a response. |
| * The last chunk sets completed flag to true; |
| */ |
| export function mockAidaClient(data: Array<[MockAidaResponse, ...MockAidaResponse[]]> = []): |
| Host.AidaClient.AidaClient { |
| let callId = 0; |
| async function* provideAnswer(_: Host.AidaClient.AidaRequest, options?: {signal?: AbortSignal}) { |
| if (!data[callId]) { |
| throw new Error('No data provided to the mock client'); |
| } |
| |
| for (const [idx, chunk] of data[callId].entries()) { |
| if (options?.signal?.aborted || ('abortError' in chunk)) { |
| throw new Host.AidaClient.AidaAbortError(); |
| } |
| if ('fetchError' in chunk) { |
| throw new Error('Fetch error'); |
| } |
| const metadata = chunk.metadata ?? {}; |
| if (metadata?.attributionMetadata?.attributionAction === Host.AidaClient.RecitationAction.BLOCK) { |
| throw new Host.AidaClient.AidaBlockError(); |
| } |
| if (chunk.functionCalls?.length) { |
| callId++; |
| yield {...chunk, metadata, completed: true}; |
| break; |
| } |
| const completed = idx === data[callId].length - 1; |
| if (completed) { |
| callId++; |
| } |
| yield { |
| ...chunk, |
| metadata, |
| completed, |
| }; |
| } |
| } |
| |
| return createMockAidaClient(provideAnswer); |
| } |
| |
| export async function createUISourceCode(options?: { |
| content?: string, |
| mimeType?: string, |
| url?: Platform.DevToolsPath.UrlString, |
| resourceType?: Common.ResourceType.ResourceType, |
| requestContentData?: boolean, |
| }): Promise<Workspace.UISourceCode.UISourceCode> { |
| const url = options?.url ?? Platform.DevToolsPath.urlString`http://example.test/script.js`; |
| const {project} = createContentProviderUISourceCodes({ |
| items: [ |
| { |
| url, |
| mimeType: options?.mimeType ?? 'application/javascript', |
| resourceType: options?.resourceType ?? Common.ResourceType.resourceTypes.Script, |
| content: options?.content ?? undefined, |
| }, |
| ], |
| target: createTarget(), |
| }); |
| |
| const uiSourceCode = project.uiSourceCodeForURL(url); |
| if (!uiSourceCode) { |
| throw new Error('Failed to create a test uiSourceCode'); |
| } |
| if (!uiSourceCode.contentType().isTextType()) { |
| uiSourceCode?.setContent('binary', true); |
| } |
| if (options?.requestContentData) { |
| await uiSourceCode.requestContentData(); |
| } |
| return uiSourceCode; |
| } |
| |
| export function createNetworkRequest(opts?: { |
| url?: Platform.DevToolsPath.UrlString, |
| includeInitiators?: boolean, |
| }): SDK.NetworkRequest.NetworkRequest { |
| const networkRequest = SDK.NetworkRequest.NetworkRequest.create( |
| 'requestId-0' as Protocol.Network.RequestId, |
| opts?.url ?? Platform.DevToolsPath.urlString`https://www.example.com/script.js`, |
| Platform.DevToolsPath.urlString``, null, null, null); |
| networkRequest.statusCode = 200; |
| networkRequest.setRequestHeaders([{name: 'content-type', value: 'bar1'}]); |
| networkRequest.responseHeaders = [{name: 'content-type', value: 'bar2'}, {name: 'x-forwarded-for', value: 'bar3'}]; |
| |
| if (opts?.includeInitiators) { |
| const initiatorNetworkRequest = SDK.NetworkRequest.NetworkRequest.create( |
| 'requestId-1' as Protocol.Network.RequestId, Platform.DevToolsPath.urlString`https://www.initiator.com`, |
| Platform.DevToolsPath.urlString``, null, null, null); |
| const initiatedNetworkRequest1 = SDK.NetworkRequest.NetworkRequest.create( |
| 'requestId-2' as Protocol.Network.RequestId, Platform.DevToolsPath.urlString`https://www.example.com/1`, |
| Platform.DevToolsPath.urlString``, null, null, null); |
| const initiatedNetworkRequest2 = SDK.NetworkRequest.NetworkRequest.create( |
| 'requestId-3' as Protocol.Network.RequestId, Platform.DevToolsPath.urlString`https://www.example.com/2`, |
| Platform.DevToolsPath.urlString``, null, null, null); |
| |
| sinon.stub(Logs.NetworkLog.NetworkLog.instance(), 'initiatorGraphForRequest') |
| .withArgs(networkRequest) |
| .returns({ |
| initiators: new Set([networkRequest, initiatorNetworkRequest]), |
| initiated: new Map([ |
| [networkRequest, initiatorNetworkRequest], |
| [initiatedNetworkRequest1, networkRequest], |
| [initiatedNetworkRequest2, networkRequest], |
| ]), |
| }) |
| .withArgs(initiatedNetworkRequest1) |
| .returns({ |
| initiators: new Set([]), |
| initiated: new Map([ |
| [initiatedNetworkRequest1, networkRequest], |
| ]), |
| }) |
| .withArgs(initiatedNetworkRequest2) |
| .returns({ |
| initiators: new Set([]), |
| initiated: new Map([ |
| [initiatedNetworkRequest2, networkRequest], |
| ]), |
| }); |
| } |
| |
| return networkRequest; |
| } |
| |
| let panels: AiAssistancePanel.AiAssistancePanel[] = []; |
| /** |
| * Creates and shows an AiAssistancePanel instance returning the view |
| * stubs and the initial view input caused by Widget.show(). |
| */ |
| export async function createAiAssistancePanel(options?: { |
| aidaClient?: Host.AidaClient.AidaClient, |
| aidaAvailability?: Host.AidaClient.AidaAccessPreconditions, |
| syncInfo?: Host.InspectorFrontendHostAPI.SyncInformation, |
| chatView?: AiAssistancePanel.ChatView, |
| }) { |
| let aidaAvailabilityForStub = options?.aidaAvailability ?? Host.AidaClient.AidaAccessPreconditions.AVAILABLE; |
| |
| const view = createViewFunctionStub(AiAssistancePanel.AiAssistancePanel, {chatView: options?.chatView}); |
| const aidaClient = options?.aidaClient ?? mockAidaClient(); |
| const checkAccessPreconditionsStub = |
| sinon.stub(Host.AidaClient.AidaClient, 'checkAccessPreconditions').callsFake(() => { |
| return Promise.resolve(aidaAvailabilityForStub); |
| }); |
| const panel = new AiAssistancePanel.AiAssistancePanel(view, { |
| aidaClient, |
| aidaAvailability: aidaAvailabilityForStub, |
| syncInfo: options?.syncInfo ?? {isSyncActive: true}, |
| }); |
| panels.push(panel); |
| |
| panel.markAsRoot(); |
| panel.show(document.body); |
| await view.nextInput; |
| |
| const stubAidaCheckAccessPreconditions = (aidaAvailability: Host.AidaClient.AidaAccessPreconditions) => { |
| aidaAvailabilityForStub = aidaAvailability; |
| return checkAccessPreconditionsStub; |
| }; |
| |
| return { |
| panel, |
| view, |
| aidaClient, |
| stubAidaCheckAccessPreconditions, |
| }; |
| } |
| |
| export const setupAutomaticFileSystem = (options: {hasFileSystem: boolean} = { |
| hasFileSystem: false |
| }): void => { |
| const root = '/path/to/my-automatic-file-system'; |
| const uuid = '549bbf9b-48b2-4af7-aebd-d3ba68993094'; |
| const hostConfig = {devToolsAutomaticFileSystems: {enabled: true}}; |
| const inspectorFrontendHost = sinon.createStubInstance(Host.InspectorFrontendHost.InspectorFrontendHostStub); |
| inspectorFrontendHost.events = sinon.createStubInstance(Common.ObjectWrapper.ObjectWrapper); |
| const projectSettingsModel = sinon.createStubInstance(ProjectSettings.ProjectSettingsModel.ProjectSettingsModel); |
| sinon.stub(projectSettingsModel, 'availability').value('available'); |
| sinon.stub(projectSettingsModel, 'projectSettings').value(options.hasFileSystem ? {workspace: {root, uuid}} : {}); |
| |
| const manager = Persistence.AutomaticFileSystemManager.AutomaticFileSystemManager.instance({ |
| forceNew: true, |
| hostConfig, |
| inspectorFrontendHost, |
| projectSettingsModel, |
| }); |
| sinon.stub(manager, 'connectAutomaticFileSystem').resolves(true); |
| }; |
| |
| let patchWidgets: AiAssistancePanel.PatchWidget.PatchWidget[] = []; |
| /** |
| * Creates and shows an AiAssistancePanel instance returning the view |
| * stubs and the initial view input caused by Widget.show(). |
| */ |
| export async function createPatchWidget(options?: { |
| aidaClient?: Host.AidaClient.AidaClient, |
| }) { |
| const view = createViewFunctionStub(AiAssistancePanel.PatchWidget.PatchWidget); |
| const aidaClient = options?.aidaClient ?? mockAidaClient(); |
| const widget = new AiAssistancePanel.PatchWidget.PatchWidget(undefined, view, { |
| aidaClient, |
| }); |
| patchWidgets.push(widget); |
| |
| widget.markAsRoot(); |
| widget.show(document.body); |
| await view.nextInput; |
| |
| return { |
| widget, |
| view, |
| aidaClient, |
| }; |
| } |
| |
| export async function createPatchWidgetWithDiffView(options?: { |
| aidaClient?: Host.AidaClient.AidaClient, |
| }) { |
| const aidaClient = options?.aidaClient ?? mockAidaClient([[{explanation: 'patch applied'}]]); |
| const {view, widget} = await createPatchWidget({aidaClient}); |
| widget.changeSummary = 'body { background-color: red; }'; |
| view.input.onApplyToWorkspace(); |
| assert.strictEqual( |
| (await view.nextInput).patchSuggestionState, AiAssistancePanel.PatchWidget.PatchSuggestionState.SUCCESS); |
| |
| return {widget, view, aidaClient}; |
| } |
| |
| export function initializePersistenceImplForTests(): void { |
| const workspace = Workspace.Workspace.WorkspaceImpl.instance({forceNew: true}); |
| const debuggerWorkspaceBinding = Bindings.DebuggerWorkspaceBinding.DebuggerWorkspaceBinding.instance({ |
| forceNew: true, |
| targetManager: SDK.TargetManager.TargetManager.instance(), |
| resourceMapping: |
| new Bindings.ResourceMapping.ResourceMapping(SDK.TargetManager.TargetManager.instance(), workspace), |
| }); |
| const breakpointManager = Breakpoints.BreakpointManager.BreakpointManager.instance({ |
| forceNew: true, |
| targetManager: SDK.TargetManager.TargetManager.instance(), |
| workspace, |
| debuggerWorkspaceBinding, |
| }); |
| Persistence.Persistence.PersistenceImpl.instance({forceNew: true, workspace, breakpointManager}); |
| WorkspaceDiff.WorkspaceDiff.workspaceDiff({forceNew: true}); |
| } |
| |
| export function cleanup() { |
| for (const panel of panels) { |
| panel.detach(); |
| } |
| panels = []; |
| for (const widget of patchWidgets) { |
| widget.detach(); |
| } |
| patchWidgets = []; |
| } |
| |
| export function openHistoryContextMenu( |
| lastUpdate: AiAssistancePanel.ViewInput, |
| item: string, |
| ) { |
| const contextMenu = new UI.ContextMenu.ContextMenu(new MouseEvent('click')); |
| lastUpdate.populateHistoryMenu(contextMenu); |
| |
| const freestylerEntry = findMenuItemWithLabel(contextMenu.defaultSection(), item); |
| return { |
| contextMenu, |
| id: freestylerEntry?.id(), |
| }; |
| } |
| |
| export function createTestFilesystem(fileSystemPath: string, files?: Array<{ |
| path: string, |
| content: string, |
| }>) { |
| const {project, uiSourceCode} = createFileSystemUISourceCode({ |
| url: Platform.DevToolsPath.urlString`${fileSystemPath}/index.html`, |
| mimeType: 'text/html', |
| content: 'content', |
| fileSystemPath, |
| }); |
| |
| uiSourceCode.setWorkingCopy('content'); |
| |
| for (const file of files ?? []) { |
| const uiSourceCode = project.createUISourceCode( |
| Platform.DevToolsPath.urlString`${fileSystemPath}/${file.path}`, Common.ResourceType.resourceTypes.Script); |
| project.addUISourceCode(uiSourceCode); |
| uiSourceCode.setWorkingCopy(file.content); |
| } |
| |
| return {project, uiSourceCode}; |
| } |