blob: 65722b8ac8ab64ca88b8869b0bf5cf8094b1177f [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 {renderElementIntoDOM} from '../../../testing/DOMHelpers.js';
import {describeWithEnvironment, updateHostConfig} from '../../../testing/EnvironmentHelpers.js';
import * as CodeMirror from '../../../third_party/codemirror.next/codemirror.next.js';
import {AiCodeCompletionProvider, Config, TextEditor} from './text_editor.js';
function createEditorWithProvider(doc: string, config: AiCodeCompletionProvider.AiCodeCompletionConfig = {
completionContext: {},
generationContext: {},
onFeatureEnabled: () => {},
onFeatureDisabled: () => {},
onSuggestionAccepted: () => {},
onRequestTriggered: () => {},
onResponseReceived: () => {},
panel: AiCodeCompletion.AiCodeCompletion.ContextFlavor.CONSOLE,
}): {editor: TextEditor.TextEditor, provider: AiCodeCompletionProvider.AiCodeCompletionProvider} {
const provider = AiCodeCompletionProvider.AiCodeCompletionProvider.createInstance(config);
const editor = new TextEditor.TextEditor(
CodeMirror.EditorState.create({
doc,
extensions: [
provider.extension(),
],
}),
);
renderElementIntoDOM(editor);
provider.editorInitialized(editor);
return {editor, provider};
}
describeWithEnvironment('AiCodeCompletionProvider', () => {
let clock: sinon.SinonFakeTimers;
let checkAccessPreconditionsStub: sinon.SinonStub;
beforeEach(() => {
clock = sinon.useFakeTimers();
updateHostConfig({
devToolsAiCodeCompletion: {
enabled: true,
},
aidaAvailability: {
enabled: true,
blockedByAge: false,
blockedByGeo: false,
}
});
checkAccessPreconditionsStub = sinon.stub(Host.AidaClient.AidaClient, 'checkAccessPreconditions');
});
afterEach(() => {
Common.Settings.Settings.instance().settingForTest('ai-code-completion-teaser-dismissed').set(false);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(false);
clock.restore();
});
it('does not create a provider when the feature is disabled', () => {
updateHostConfig({
devToolsAiCodeCompletion: {
enabled: false,
},
});
assert.throws(() => createEditorWithProvider(''), 'AI code completion feature is not enabled.');
});
describe('Teaser decoration', () => {
beforeEach(() => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
});
it('shows teaser when mode is ON', async () => {
const {editor, provider} = createEditorWithProvider('');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ON),
});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when mode is ON and cursor is at the end of the line', async () => {
const {editor, provider} = createEditorWithProvider('Hello');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ON),
});
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
await clock.tickAsync(
AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when mode is ON and cursor is not at the end of the line', async () => {
const {editor, provider} = createEditorWithProvider('Hello');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ON),
});
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
await clock.tickAsync(
AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({changes: {from: 0, insert: '!'}, selection: {anchor: 1}});
await clock.tickAsync(
AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when mode is ON and text is selected', async () => {
const {editor, provider} = createEditorWithProvider('Hello');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ON),
});
editor.dispatch({changes: {from: 5, insert: 'W'}, selection: {anchor: 6}});
await clock.tickAsync(
AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS +
AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({selection: {anchor: 2, head: 4}});
assert.isNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when mode is OFF', async () => {
const {editor, provider} = createEditorWithProvider('');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ON),
});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.OFF),
});
await clock.tickAsync(0);
assert.isNull(editor.querySelector('.cm-placeholder'));
provider.dispose();
});
it('shows teaser when mode is ONLY_SHOW_ON_EMPTY and editor is empty', async () => {
const {editor, provider} = createEditorWithProvider('');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ONLY_SHOW_ON_EMPTY),
});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
provider.dispose();
});
it('hides teaser when mode is ONLY_SHOW_ON_EMPTY and editor is not empty', async () => {
const {editor, provider} = createEditorWithProvider('');
editor.dispatch({
effects: AiCodeCompletionProvider.setAiCodeCompletionTeaserMode.of(
AiCodeCompletionProvider.AiCodeCompletionTeaserMode.ONLY_SHOW_ON_EMPTY),
});
await clock.tickAsync(0);
assert.isNotNull(editor.editor.dom.querySelector('.cm-placeholder'));
editor.dispatch({changes: {from: 0, insert: 'H'}});
await clock.tickAsync(0);
assert.isNull(editor.querySelector('.cm-placeholder'));
provider.dispose();
});
});
describe('Triggers code completion', () => {
it('triggers code completion on text change', async () => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'Hello'}, selection: {anchor: 5}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.called(completeCodeStub);
assert.deepEqual(completeCodeStub.firstCall.args, ['Hello', '', 5, undefined, undefined]);
provider.dispose();
});
it('triggers code completion when AIDA becomes available', async () => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.NO_ACCOUNT_EMAIL);
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode');
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'Hello'}, selection: {anchor: 5}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.notCalled(completeCodeStub);
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
await Host.AidaClient.HostConfigTracker.instance().dispatchEventToListeners(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED);
await clock.tickAsync(0);
editor.dispatch({changes: {from: 5, insert: 'Bye'}, selection: {anchor: 8}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.calledOnce(completeCodeStub);
assert.deepEqual(completeCodeStub.firstCall.args, ['HelloBye', '', 8, undefined, undefined]);
provider.dispose();
});
it('does not trigger code completion when AIDA becomes unavailable', async () => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode');
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'Hello'}, selection: {anchor: 5}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.calledOnce(completeCodeStub);
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.NO_ACCOUNT_EMAIL);
await Host.AidaClient.HostConfigTracker.instance().dispatchEventToListeners(
Host.AidaClient.Events.AIDA_AVAILABILITY_CHANGED);
await clock.tickAsync(0);
editor.dispatch({changes: {from: 5, insert: 'Bye'}, selection: {anchor: 8}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.calledOnce(completeCodeStub);
assert.deepEqual(completeCodeStub.firstCall.args, ['Hello', '', 5, undefined, undefined]);
provider.dispose();
});
it('debounces requests for code completion', async () => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode');
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'p'}, selection: {anchor: 1}});
editor.dispatch({changes: {from: 1, insert: 'r'}, selection: {anchor: 2}});
editor.dispatch({changes: {from: 2, insert: 'e'}, selection: {anchor: 3}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS);
sinon.assert.calledOnce(completeCodeStub);
assert.deepEqual(completeCodeStub.firstCall.args, ['pre', '', 3, undefined, undefined]);
provider.dispose();
});
});
describe('Dispatches', () => {
beforeEach(() => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
});
it('dispatches a suggestion to the editor when AIDA returns one', async () => {
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode')
.returns(Promise.resolve({
response: {
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {rpcGlobalId: 1},
},
fromCache: false,
}));
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'prefix'}, selection: {anchor: 6}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(completeCodeStub);
const dispatchSpy = sinon.spy(editor, 'dispatch');
await clock.tickAsync(AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'suggestion');
assert.strictEqual(suggestion?.from, 6);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
sinon.assert.calledOnce(dispatchSpy);
provider.dispose();
});
it('trims a suggestion with suffix overlap and dispatches it to the editor', async () => {
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode')
.returns(Promise.resolve({
response: {
generatedSamples: [{
generationString: 'Hello World");',
sampleId: 1,
score: 1,
}],
metadata: {rpcGlobalId: 1},
},
fromCache: false,
}));
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'console.log("");\n'}, selection: {anchor: 13}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(completeCodeStub);
const dispatchSpy = sinon.spy(editor, 'dispatch');
await clock.tickAsync(AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
const suggestion = editor.editor.state.field(Config.aiAutoCompleteSuggestionState);
assert.strictEqual(suggestion?.text, 'Hello World');
assert.strictEqual(suggestion?.from, 13);
assert.strictEqual(suggestion?.sampleId, 1);
assert.strictEqual(suggestion?.rpcGlobalId, 1);
sinon.assert.calledOnce(dispatchSpy);
provider.dispose();
});
it('does not dispatch suggestion or citation if recitation action is BLOCK', async () => {
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode')
.returns(Promise.resolve({
response: {
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
attributionMetadata: {
attributionAction: Host.AidaClient.RecitationAction.BLOCK,
citations: [{uri: 'https://www.example.com'}],
}
}],
metadata: {},
},
fromCache: false,
}));
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'prefix'}, selection: {anchor: 6}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(completeCodeStub);
const dispatchSpy = sinon.spy(editor, 'dispatch');
await clock.tickAsync(AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
provider.dispose();
});
it('does not dispatch suggestion or citation if generated suggestion repeats existing text', async () => {
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode')
.returns(Promise.resolve({
response: {
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
},
fromCache: false,
}));
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'prefix suggestion'}, selection: {anchor: 17}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(completeCodeStub);
const dispatchSpy = sinon.spy(editor, 'dispatch');
await clock.tickAsync(AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
provider.dispose();
});
it('does not dispatch if cursor position changes', async () => {
const completeCodeStub = sinon.stub(AiCodeCompletion.AiCodeCompletion.AiCodeCompletion.prototype, 'completeCode')
.returns(Promise.resolve({
response: {
generatedSamples: [{
generationString: 'suggestion',
sampleId: 1,
score: 1,
}],
metadata: {},
},
fromCache: false,
}));
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({changes: {from: 0, insert: 'prefix'}, selection: {anchor: 6}});
await clock.tickAsync(AiCodeCompletionProvider.AIDA_REQUEST_DEBOUNCE_TIMEOUT_MS + 1);
sinon.assert.calledOnce(completeCodeStub);
editor.editor.dispatch({
selection: CodeMirror.EditorSelection.cursor(1),
});
const dispatchSpy = sinon.spy(editor, 'dispatch');
await clock.tickAsync(AiCodeCompletionProvider.DELAY_BEFORE_SHOWING_RESPONSE_MS + 1);
sinon.assert.notCalled(dispatchSpy);
provider.dispose();
});
});
describe('Editor keymap', () => {
beforeEach(() => {
checkAccessPreconditionsStub.resolves(Host.AidaClient.AidaAccessPreconditions.AVAILABLE);
});
it('accepts suggestion on Tab', async () => {
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({
effects: Config.setAiAutoCompleteSuggestion.of({
text: 'suggestion',
from: 0,
rpcGlobalId: 1,
sampleId: 1,
startTime: performance.now(),
clearCachedRequest: () => {},
onImpression: () => {},
source: Config.AiSuggestionSource.COMPLETION,
}),
});
editor.editor.contentDOM.dispatchEvent(new KeyboardEvent('keydown', {key: 'Tab'}));
assert.strictEqual(editor.state.doc.toString(), 'suggestion');
provider.dispose();
});
it('dismisses suggestion on Escape', async () => {
Common.Settings.Settings.instance().settingForTest('ai-code-completion-enabled').set(true);
const {editor, provider} = createEditorWithProvider('');
await clock.tickAsync(0); // for the initial onAidaAvailabilityChange call
editor.dispatch({
effects: Config.setAiAutoCompleteSuggestion.of({
text: 'suggestion',
from: 0,
rpcGlobalId: 1,
sampleId: 1,
startTime: performance.now(),
clearCachedRequest: () => {},
onImpression: () => {},
source: Config.AiSuggestionSource.COMPLETION,
}),
});
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();
});
});
});