chrome-devtools-frontend
Version:
Chrome DevTools UI
245 lines (222 loc) • 9.71 kB
text/typescript
// 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 = [
'',
'A response with .',
'',
'.',
'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();
});
});
});
});