UNPKG

chrome-devtools-frontend

Version:
245 lines (222 loc) • 9.71 kB
// Copyright 2024 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 Host from '../../../core/host/host.js'; import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js'; import {describeWithEnvironment, getGetHostConfigStub} from '../../../testing/EnvironmentHelpers.js'; import * as Marked from '../../../third_party/marked/marked.js'; import * as MarkdownView from '../../../ui/components/markdown_view/markdown_view.js'; import * as Freestyler from '../ai_assistance.js'; const {MarkdownRendererWithCodeBlock} = Freestyler.FOR_TEST; describeWithEnvironment('ChatView', () => { describe('MarkdownRendererWithCodeBlock', () => { it('should transform code token for multiline code blocks with `css` language written in the first line', () => { const renderer = new MarkdownRendererWithCodeBlock(); const templateForTokenStub = sinon.stub(MarkdownView.MarkdownView.MarkdownInsightRenderer.prototype, 'templateForToken'); const codeBlock = `\`\`\` css * { color: red; } \`\`\``; const codeToken = Marked.Marked.lexer(codeBlock)[0] as Marked.Marked.Tokens.Code; assert.isEmpty(codeToken.lang); renderer.renderToken(codeToken); sinon.assert.calledWith(templateForTokenStub, sinon.match({ lang: 'css', text: `* { color: red; }`, })); }); describe('link/image stripping', () => { const linkCases = [ '[link text](https://z.com)', 'A response with [link text](https://z.com).', '[*link text*](https://z.com)', '[**text** `with code`](https://z.com).', 'plain link https://z.com .', 'link in quotes \'https://z.com\' .', ]; const renderToElem = (string: string, renderer: MarkdownView.MarkdownView.MarkdownLitRenderer): Element => { const component = new MarkdownView.MarkdownView.MarkdownView(); renderElementIntoDOM(component, {allowMultipleChildren: true}); component.data = {tokens: Marked.Marked.lexer(string), renderer}; assert.exists(component.shadowRoot?.firstElementChild); return component.shadowRoot.firstElementChild; }; it('strips links if stripLinks true', () => { const linklessRenderer = new MarkdownRendererWithCodeBlock({stripLinks: true}); for (const linkCase of linkCases) { const elem = renderToElem(linkCase, linklessRenderer); assert.lengthOf(elem.querySelectorAll('a, x-link, devtools-link'), 0); assert.isFalse(['<a', '<x-link', '<devtools-link'].some(tagName => elem.outerHTML.includes(tagName))); assert.isOk(elem.textContent?.includes('( https://z.com )'), linkCase); } }); it('leaves links intact by default', () => { const linkfulRenderer = new MarkdownRendererWithCodeBlock(); for (const linkCase of linkCases) { const elem = renderToElem(linkCase, linkfulRenderer); assert.lengthOf(elem.querySelectorAll('a, x-link, devtools-link'), 1); assert.isTrue(['<a', '<x-link', '<devtools-link'].some(tagName => elem.outerHTML.includes(tagName))); assert.isFalse(elem.textContent?.includes('( https://z.com )')); } }); const imageCases = [ '![image alt](https://z.com/i.png)', 'A response with ![image alt](https://z.com/i.png).', '![*image alt*](https://z.com/i.png)', '![**text** `with code`](https://z.com/i.png).', 'plain image href https://z.com/i.png .', 'link in quotes \'https://z.com/i.png\' .', ]; it('strips images if stripLinks true', () => { const linklessRenderer = new MarkdownRendererWithCodeBlock({stripLinks: true}); for (const imageCase of imageCases) { const elem = renderToElem(imageCase, linklessRenderer); assert.lengthOf(elem.querySelectorAll('a, x-link, devtools-link, img, devtools-markdown-image'), 0); assert.isFalse(['<a', '<x-link', '<devtools-link', '<img', '<devtools-markdown-image'].some( tagName => elem.outerHTML.includes(tagName))); assert.isOk(elem.textContent?.includes('( https://z.com/i.png )'), imageCase); } }); }); }); function getProp(options: Partial<Freestyler.Props>): Freestyler.Props { const noop = () => {}; const messages: Freestyler.ChatMessage[] = options.messages ?? []; const selectedContext = sinon.createStubInstance(Freestyler.NodeContext); selectedContext.getTitle.returns(''); return { onTextSubmit: noop, onInspectElementClick: noop, onFeedbackSubmit: noop, onCancelClick: noop, onContextClick: noop, onNewConversation: noop, inspectElementToggled: false, state: Freestyler.State.CHAT_VIEW, agentType: Freestyler.AgentType.STYLING, aidaAvailability: Host.AidaClient.AidaAccessPreconditions.AVAILABLE, messages, selectedContext, isLoading: false, canShowFeedbackForm: false, userInfo: {}, blockedByCrossOrigin: false, stripLinks: false, isReadOnly: false, ...options, }; } describe('SideEffects', () => { it('should show SideEffects when the step contains "sideEffect" object', async () => { const props = getProp({ messages: [ { entity: Freestyler.ChatMessageEntity.MODEL, steps: [ { isLoading: false, title: 'Updating element styles', thought: 'Updating element styles', code: '$0.style.background = "blue";', sideEffect: { onAnswer: () => {}, }, }, ], }, ], }); const chat = new Freestyler.ChatView(props); renderElementIntoDOM(chat); const sideEffect = chat.shadowRoot!.querySelector('.side-effect-confirmation'); assert.exists(sideEffect); }); it('shows the disabled view when the state is CONSENT_VIEW', async () => { const props = getProp({ state: Freestyler.State.CONSENT_VIEW, }); const chat = new Freestyler.ChatView(props); renderElementIntoDOM(chat); const optIn = chat.shadowRoot?.querySelector('.disabled-view'); assert.strictEqual( optIn?.textContent?.trim(), 'Turn on AI assistance in Settings to get help with understanding CSS styles'); const chatInput = chat.shadowRoot?.querySelector('.chat-input') as HTMLTextAreaElement; assert.isTrue(chatInput.disabled); assert.strictEqual(chatInput.placeholder, 'Follow the steps above to ask a question'); }); it('shows the disabled view when the AIDA is not available', async () => { const props = getProp({ state: Freestyler.State.CHAT_VIEW, aidaAvailability: Host.AidaClient.AidaAccessPreconditions.NO_INTERNET, }); const chat = new Freestyler.ChatView(props); renderElementIntoDOM(chat); const optIn = chat.shadowRoot?.querySelector('.disabled-view'); assert.strictEqual(optIn?.textContent?.trim(), 'Check your internet connection and try again'); const chatInput = chat.shadowRoot?.querySelector('.chat-input') as HTMLTextAreaElement; assert.isTrue(chatInput.disabled); assert.strictEqual(chatInput.placeholder, 'Ask a question about the selected element'); }); describe('no agent empty state', () => { it('should show feature cards for enabled features', () => { const stub = getGetHostConfigStub({ devToolsFreestyler: { enabled: true, }, devToolsAiAssistanceNetworkAgent: { enabled: true, }, devToolsAiAssistanceFileAgent: { enabled: true, }, devToolsAiAssistancePerformanceAgent: { enabled: true, }, }); const props = getProp({ agentType: undefined, }); const chat = new Freestyler.ChatView(props); renderElementIntoDOM(chat); const featureCards = chat.shadowRoot?.querySelectorAll('.feature-card'); assert.isDefined(featureCards); assert.strictEqual(featureCards?.length, 4); assert.strictEqual(featureCards[0].querySelector('.feature-card-content h3')?.textContent, 'CSS styles'); assert.strictEqual(featureCards[1].querySelector('.feature-card-content h3')?.textContent, 'Network'); assert.strictEqual(featureCards[2].querySelector('.feature-card-content h3')?.textContent, 'Files'); assert.strictEqual(featureCards[3].querySelector('.feature-card-content h3')?.textContent, 'Performance'); stub.restore(); }); it('should not show any feature cards if none of the entrypoints are available', () => { const stub = getGetHostConfigStub({ devToolsFreestyler: { enabled: false, }, devToolsAiAssistanceNetworkAgent: { enabled: false, }, devToolsAiAssistanceFileAgent: { enabled: false, }, devToolsAiAssistancePerformanceAgent: { enabled: false, }, }); const props = getProp({ agentType: undefined, }); const chat = new Freestyler.ChatView(props); renderElementIntoDOM(chat); const featureCards = chat.shadowRoot?.querySelectorAll('.feature-card'); assert.isDefined(featureCards); assert.strictEqual(featureCards?.length, 0); stub.restore(); }); }); }); });