blob: bc57dac7ca58ef10899e1732d0a9f68f16a2c3f7 [file] [log] [blame]
// Copyright 2025 The Chromium Authors
// 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 AiCodeCompletion from '../../../models/ai_code_completion/ai_code_completion.js';
import * as AiCodeGeneration from '../../../models/ai_code_generation/ai_code_generation.js';
import * as PanelCommon from '../../../panels/common/common.js';
import {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
import {describeWithEnvironment, updateHostConfig} from '../../../testing/EnvironmentHelpers.js';
import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js';
import {AiCodeGenerationProvider, Config, TextEditor} from './text_editor.js';
function createEditorWithProvider(doc: string, config: AiCodeGenerationProvider.AiCodeGenerationConfig = {
panel: AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
generationContext: {},
onSuggestionAccepted: () => {},
onRequestTriggered: () => {},
onResponseReceived: () => {},
}): {editor: TextEditor.TextEditor, provider: AiCodeGenerationProvider.AiCodeGenerationProvider} {
const provider = AiCodeGenerationProvider.AiCodeGenerationProvider.createInstance(config);
const editor = new TextEditor.TextEditor(
CodeMirror.EditorState.create({
doc,
extensions: [
CodeMirror.javascript.javascriptLanguage,
provider.extension(),
],
}),
);
renderElementIntoDOM(editor);
provider.editorInitialized(editor);
return {editor, provider};
}
function dispatchCtrlI(editor: TextEditor.TextEditor) {
const event = new KeyboardEvent('keydown', {
key: 'i',
ctrlKey: Host.Platform.isMac() ? false : true,
metaKey: Host.Platform.isMac() ? true : false,
});
editor.editor.contentDOM.dispatchEvent(event);
}
describeWithEnvironment('AiCodeGenerationProvider', () => {
let clock: sinon.SinonFakeTimers;
let checkAccessPreconditionsStub: sinon.SinonStub;
let generateCodeStub: sinon.SinonStub;
beforeEach(() => {
clock = sinon.useFakeTimers();
updateHostConfig({
devToolsAiCodeGeneration: {
enabled: true,
},
aidaAvailability: {
enabled: true,
blockedByAge: false,
blockedByGeo: false,
}
});
checkAccessPreconditionsStub = sinon.stub(Host.AidaClient.AidaClient, 'checkAccessPreconditions');
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
sinon.stub(Host.AidaClient.HostConfigTracker, 'instance').returns({
addEventListener: () => {},
removeEventListener: () => {},
dispose: () => {},
} as unknown as Host.AidaClient.HostConfigTracker);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
Common.Settings.Settings.instance().createSetting('ai-code-generation-onboarding-completed', true);
generateCodeStub = sinon.stub(AiCodeGeneration.AiCodeGeneration.AiCodeGeneration.prototype, 'generateCode');
sinon.stub(PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype, 'displayState').set(_ => {});
});
afterEach(() => {
clock.restore();
});
it('does not create a provider when the feature is disabled', () => {
updateHostConfig({
devToolsAiCodeGeneration: {
enabled: false,
},
});
assert.throws(() => createEditorWithProvider(''), 'AI code generation feature is not enabled.');
});
describe('Teaser decoration', () => {
it('shows teaser when cursor is in empty line', async () => {
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when cursor is at the end of an inline comment', async () => {
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when cursor is at the end of a block comment', async () => {
const {editor, provider} = createEditorWithProvider(`/**
* Hello
*/`);
editor.dispatch({selection: {anchor: 18}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when cursor is not at the end of a block comment', async () => {
const {editor, provider} = createEditorWithProvider(`/**
* Hello
*/`);
editor.dispatch({selection: {anchor: 13}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when block comment syntax is incomplete', async () => {
const {editor, provider} = createEditorWithProvider(`/**
* Hello`);
editor.dispatch({selection: {anchor: 13}});
await clock.tickAsync(0);
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when cursor is not at the end of the line', async () => {
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 5}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when the line is not a comment', async () => {
const {editor, provider} = createEditorWithProvider('console');
editor.dispatch({selection: {anchor: 7}});
await clock.tickAsync(0);
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when mode is DISMISSED', async () => {
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({
effects: AiCodeGenerationProvider.setAiCodeGenerationTeaserMode.of(
AiCodeGenerationProvider.AiCodeGenerationTeaserMode.DISMISSED)
});
await clock.tickAsync(0);
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser again after a document change', async () => {
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({
effects: AiCodeGenerationProvider.setAiCodeGenerationTeaserMode.of(
AiCodeGenerationProvider.AiCodeGenerationTeaserMode.DISMISSED)
});
await clock.tickAsync(0);
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({changes: {from: 8, insert: 'W'}, selection: {anchor: 9}});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
});
describe('Editor keymap', () => {
it('accepts suggestion on Tab', async () => {
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({
effects: Config.setAiAutoCompleteSuggestion.of({
text: 'code suggestion',
from: 0,
rpcGlobalId: 1,
sampleId: 1,
startTime: performance.now(),
onImpression: () => {},
source: Config.AiSuggestionSource.GENERATION,
}),
});
editor.editor.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
assert.strictEqual(editor.state.doc.toString(), 'code suggestion');
provider.dispose();
});
it('dismisses suggestion on Escape', async () => {
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({
effects: Config.setAiAutoCompleteSuggestion.of({
text: 'code suggestion',
from: 0,
rpcGlobalId: 1,
sampleId: 1,
startTime: performance.now(),
onImpression: () => {},
source: Config.AiSuggestionSource.GENERATION,
}),
});
assert.isNotNull(editor.editor.state.field(Config.aiAutoCompleteSuggestionState));
editor.editor.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
assert.isNull(editor.editor.state.field(Config.aiAutoCompleteSuggestionState));
provider.dispose();
});
it('dismisses teaser on Escape when loading', async () => {
generateCodeStub.returns(new Promise(() => {}));
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
const loadingSetter = sinon.spy(generationTeaser, 'displayState', ['set']);
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
// Explicitly set display state to LOADING, so that loading state can be cancelled as expected
sinon.stub(generationTeaser, 'displayState')
.get(() => PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING);
await clock.tickAsync(0);
assert.deepEqual(
loadingSetter.set.lastCall.args[0],
PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING);
sinon.assert.calledOnce(generateCodeStub);
const dispatchSpy = sinon.spy(editor, 'dispatch');
editor.editor.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
await clock.tickAsync(0);
sinon.assert.calledOnce(dispatchSpy);
sinon.assert.calledWith(dispatchSpy, {
effects: [
AiCodeGenerationProvider.setAiCodeGenerationTeaserMode.of(
AiCodeGenerationProvider.AiCodeGenerationTeaserMode.DISMISSED),
Config.setAiAutoCompleteSuggestion.of(null),
]
});
assert.deepEqual(
loadingSetter.set.lastCall.args[0],
PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.TRIGGER);
provider.dispose();
});
describe('Triggers code generation on Ctrl+I', () => {
beforeEach(() => {
generateCodeStub.returns(Promise.resolve({samples: [], metadata: {rpcGlobalId: 1}}));
});
it('for inline comments', async () => {
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
assert.deepEqual(generateCodeStub.firstCall.args[0], 'Hello');
provider.dispose();
});
it('for block comments', async () => {
const {editor, provider} = createEditorWithProvider(`/**
* Create a helper function
* that adds two numbers
*/`);
editor.dispatch({selection: {anchor: 63}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
assert.deepEqual(generateCodeStub.firstCall.args[0], 'Create a helper function\nthat adds two numbers');
provider.dispose();
});
});
it('triggers loading state on Ctrl+I', async () => {
generateCodeStub.returns(Promise.resolve({samples: [], metadata: {rpcGlobalId: 1}}));
const {editor, provider} = createEditorWithProvider('// Hello');
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
sinon.stub(PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype, 'isShowing').returns(true);
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
const event = new KeyboardEvent('keydown', {
key: 'i',
ctrlKey: Host.Platform.isMac() ? false : true,
metaKey: Host.Platform.isMac() ? true : false,
});
const loadingSetter = sinon.spy(generationTeaser, 'displayState', ['set']);
editor.editor.contentDOM.dispatchEvent(event);
await clock.tickAsync(0);
assert.deepEqual(
loadingSetter.set.firstCall.args[0],
PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING);
provider.dispose();
});
it('aborts code generation request when Escape is pressed while loading', async () => {
generateCodeStub.returns(new Promise(() => {}));
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
const abortSpy = sinon.spy(AbortController.prototype, 'abort');
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
// Explicitly set display state to LOADING, so that abort is called as expected
sinon.stub(generationTeaser, 'displayState')
.get(() => PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
editor.editor.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape'}));
await clock.tickAsync(0);
sinon.assert.calledOnce(abortSpy);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.notExists(suggestion);
provider.dispose();
});
});
it('aborts code generation request when user starts typing again', async () => {
generateCodeStub.returns(new Promise(() => {}));
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
const abortSpy = sinon.spy(AbortController.prototype, 'abort');
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
// Explicitly set display state to LOADING, so that abort is called as expected
sinon.stub(generationTeaser, 'displayState')
.get(() => PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.LOADING);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
editor.dispatch({changes: {from: 8, insert: '!'}});
await clock.tickAsync(0);
sinon.assert.calledOnce(abortSpy);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.notExists(suggestion);
provider.dispose();
});
describe('Dispatches', () => {
it('dispatches a suggestion to the editor and updates teaser state when AIDA returns suggestion', async () => {
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
const loadingSetter = sinon.spy(generationTeaser, 'displayState', ['set']);
generateCodeStub.returns(Promise.resolve({
samples: [{
generationString: '```javascript\nconsole.log(\'suggestion\');\n```',
sampleId: 1,
score: 1,
}],
metadata: {rpcGlobalId: 1},
}));
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.exists(suggestion);
assert.strictEqual(suggestion.text, '\nconsole.log(\'suggestion\');\n');
assert.strictEqual(suggestion.from, 8);
assert.strictEqual(suggestion.sampleId, 1);
assert.strictEqual(suggestion.rpcGlobalId, 1);
sinon.assert.calledWith(
loadingSetter.set, PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.GENERATED);
provider.dispose();
});
it('does not dispatch suggestion or citation if recitation action is BLOCK', async () => {
generateCodeStub.returns(Promise.resolve({
samples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
attributionMetadata: {
attributionAction: Host.AidaClient.RecitationAction.BLOCK,
citations: [{uri: 'https://www.example.com'}],
}
}],
metadata: {},
}));
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.notExists(suggestion);
provider.dispose();
});
});
it('logs error and dismisses teaser when generateCode rejects', async () => {
generateCodeStub.rejects(new Error('AIDA Error'));
const actionTakenStub = sinon.stub(Host.userMetrics, 'actionTaken');
const {editor, provider} = createEditorWithProvider('// Hello');
const generationTeaser = PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaser.prototype;
sinon.stub(generationTeaser, 'isShowing').returns(true);
const loadingSetter = sinon.spy(generationTeaser, 'displayState', ['set']);
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
const event = new KeyboardEvent('keydown', {
key: 'i',
ctrlKey: Host.Platform.isMac() ? false : true,
metaKey: Host.Platform.isMac() ? true : false,
});
const dispatchSpy = sinon.spy(editor, 'dispatch');
editor.editor.contentDOM.dispatchEvent(event);
await clock.tickAsync(0);
sinon.assert.calledOnce(generateCodeStub);
sinon.assert.calledWith(actionTakenStub, Host.UserMetrics.Action.AiCodeGenerationError);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.notExists(suggestion);
sinon.assert.calledWith(
loadingSetter.set, PanelCommon.AiCodeGenerationTeaser.AiCodeGenerationTeaserDisplayState.TRIGGER);
sinon.assert.calledWith(dispatchSpy, {
effects: [
AiCodeGenerationProvider.setAiCodeGenerationTeaserMode.of(
AiCodeGenerationProvider.AiCodeGenerationTeaserMode.DISMISSED),
Config.setAiAutoCompleteSuggestion.of(null),
]
});
provider.dispose();
});
it('shows the upgrade dialog if the user opts when code generation is not enabled', async () => {
// To simulate this, `ai-code-completion-enabled` setting is already set to true and then we are creating provider.
Common.Settings.Settings.instance().settingForTest('ai-code-generation-onboarding-completed').set(false);
const showDialogSpy = sinon.spy(PanelCommon.AiCodeGenerationUpgradeDialog, 'show');
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
assert.isFalse(Common.Settings.Settings.instance().settingForTest('ai-code-generation-onboarding-completed').get());
sinon.assert.called(showDialogSpy);
provider.dispose();
});
it('does not show the upgrade dialog if the user opts when code generation is already enabled', async () => {
// To simulate this, `ai-code-completion-enabled` setting is set to false.
// After creating the provider, we set the `ai-code-completion-enabled` setting to true.
Common.Settings.Settings.instance().settingForTest('ai-code-generation-onboarding-completed').set(false);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(false);
const showDialogSpy = sinon.spy(PanelCommon.AiCodeGenerationUpgradeDialog, 'show');
const {editor, provider} = createEditorWithProvider('// Hello');
editor.dispatch({selection: {anchor: 8}});
await clock.tickAsync(0);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
await clock.tickAsync(0);
dispatchCtrlI(editor);
await clock.tickAsync(0);
assert.isTrue(Common.Settings.Settings.instance().settingForTest('ai-code-generation-onboarding-completed').get());
sinon.assert.notCalled(showDialogSpy);
sinon.assert.calledOnce(generateCodeStub);
provider.dispose();
});
});