Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/e2e/search.stest.ts
13389 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
import assert from 'assert';
6
import * as fs from 'fs';
7
import * as glob from 'glob';
8
import * as path from 'path';
9
import type { Command } from 'vscode';
10
import { Turn } from '../../src/extension/prompt/common/conversation';
11
import { ITestingServicesAccessor } from '../../src/platform/test/node/services';
12
import { ssuite, stest } from '../base/stest';
13
import { generateScenarioTestRunner } from './scenarioTest';
14
15
const NUM_SCENARIOS = 26;
16
17
/**
18
* Configuration object for a search test.
19
*/
20
interface ISearchTestConfig {
21
22
/**
23
* The question to ask the AI.
24
* ie: question: "find all links"
25
*/
26
question: string;
27
/**
28
* Whether the search query is a regular expression.
29
*/
30
isRegex: boolean;
31
/**
32
* Whether to search only in open editors.
33
*/
34
onlyOpenEditors?: boolean;
35
/**
36
* An array of expected include/exclude globs to use to target search files. This will be used to look for files to search, which will be compared to the list from the actual glob's include/exclude.
37
* ie:
38
* ```
39
* exampleIncludeGlobs: ["*.md", "*.html"]
40
* exampleExcludeGlobs: ["*.ts"]
41
* ```
42
*/
43
exampleIncludeGlobs?: string[];
44
exampleExcludeGlobs?: string[];
45
/**
46
* A map of search queries to the expected text results. If multiple are specified (2D string array), the test can match any of the possibilities.
47
* Might not include all files that gets searched.
48
* The actual results will be tested such that they CONTAIN the expected result. Therefore, the expected result should be the minimum-length possible result from the query.
49
* ie:
50
* queryShouldFind: "foo.md" -> ["http://google.ca", "http://github.com"]
51
* where the file "foo.md" matches the text "http://google.ca" and "http://github.com"
52
*
53
* or
54
*
55
* queryShouldFind: "foo.md" -> [["http://google.ca", "http://github.com"],["http://google.ca"]]
56
* where the file "foo.md" matches the text "http://google.ca" and "http://github.com" OR only "http://google.ca"
57
*/
58
queryShouldFind: Map<string, string[] | string[][]>;
59
/**
60
* A map of original file names to the file name of the file containing the expected replace result4
61
* ie:
62
* replaceResult: "foo.md" -> "foo.replaced.md"
63
*/
64
replaceResult?: Map<string, string>; // fileName original -> filename expected
65
66
/**
67
* Whether or not the answer should fail to give a query. Will be false by default.
68
* Would be true for an answer that says something like "I don't know what you're talking about, please clarify".
69
*/
70
shouldFail?: boolean;
71
}
72
73
74
interface ISearchArg {
75
filesToInclude?: string;
76
filesToExclude?: string;
77
query: string;
78
replace?: string;
79
isCaseSensitive?: boolean;
80
isRegex?: boolean;
81
matchWholeWord?: boolean;
82
onlyOpenEditors?: boolean;
83
preserveCase?: boolean;
84
}
85
86
interface ISimplifiedSearchArg {
87
filesToInclude: string;
88
filesToExclude: string;
89
query: string;
90
replace: string;
91
isRegex: boolean;
92
preserveCase: boolean;
93
onlyOpenEditors: boolean;
94
}
95
96
const scenarioFolder = path.join(__dirname, '..', 'test/scenarios/test-scenario-search/');
97
const exampleFolder = path.join(scenarioFolder, 'example-files');
98
const replaceSamples = path.join(scenarioFolder, 'replace-samples');
99
100
(function () {
101
ssuite({ title: 'search', location: 'panel' }, () => {
102
// Dynamically create a test case per each entry
103
for (let i = 0; i < NUM_SCENARIOS; i++) {
104
const testCase = getTestInfoFromFile(`search${i}.testArgs.json`);
105
const testName = testCase.question;
106
stest({ description: testName }, generateScenarioTestRunner(
107
[{ question: '@vscode /search ' + testCase.question, name: testName, scenarioFolderPath: scenarioFolder }],
108
generateEvaluate(testCase)
109
));
110
}
111
});
112
})();
113
114
function getTestInfoFromFile(fileName: string): ISearchTestConfig {
115
const file = path.join(scenarioFolder, fileName);
116
const fileContents = fs.readFileSync(file, 'utf8');
117
const json = JSON.parse(fileContents);
118
if (!json.queryShouldFind && !json.shouldFail) {
119
throw Error('Missing queryShouldFind field');
120
}
121
122
json.queryShouldFind = new Map(json.queryShouldFind);
123
124
if (json.replaceResult) {
125
json.replaceResult = new Map(json.replaceResult);
126
}
127
128
return json;
129
}
130
131
function generateEvaluate(testInfo: ISearchTestConfig) {
132
return async function evaluate(accessor: ITestingServicesAccessor, question: string, answer: string, _rawResponse: string, turn: Turn | undefined, _scenarioIndex: number, commands: Command[]): Promise<{ success: boolean; errorMessage?: string }> {
133
try {
134
let args: ISimplifiedSearchArg | undefined;
135
try {
136
args = createSimplifiedSearchArgs(await testArgs(commands));
137
} catch (e) {
138
if (testInfo.shouldFail) {
139
return Promise.resolve({ success: true, errorMessage: '' });
140
} else {
141
return Promise.resolve({ success: false, errorMessage: 'Parsing the search query failed.' });
142
}
143
}
144
145
if (testInfo.shouldFail) {
146
return Promise.resolve({ success: false, errorMessage: 'Parsing the search query should have failed.' });
147
}
148
149
assert(testInfo.isRegex === undefined || args.isRegex === testInfo.isRegex);
150
if (testInfo.onlyOpenEditors !== undefined) {
151
assert(args.onlyOpenEditors === testInfo.onlyOpenEditors);
152
}
153
154
if (!testInfo.replaceResult || testInfo.replaceResult.size === 0) {
155
assert(!args.replace);
156
}
157
158
const actualTargets = getTargetFiles(args.filesToInclude, args.filesToExclude);
159
const expectedTargets = getTargetFiles(testInfo.exampleIncludeGlobs ?? ['*'], testInfo.exampleExcludeGlobs ?? []);
160
161
assert.deepEqual(actualTargets, expectedTargets);
162
163
if (!args?.query) {
164
return Promise.resolve({ success: false, errorMessage: 'No query field on args' });
165
}
166
167
const query = args.query;
168
const preserveCase = args.preserveCase;
169
const replace = args.replace;
170
171
testInfo.queryShouldFind.forEach((expected, fileName) => {
172
const file = path.join(exampleFolder, fileName);
173
const results = testOnlyQueryOnFiles(file, query, preserveCase);
174
assert(resultMatchesQuery(results, expected));
175
});
176
177
testInfo.replaceResult?.forEach((fileNameExpected, fileName) => {
178
const file = path.join(exampleFolder, fileName);
179
const result = getStringFromReplace(file, query, replace, preserveCase);
180
const expected = fs.readFileSync(path.join(replaceSamples, fileNameExpected), 'utf8');
181
assert(result === expected);
182
});
183
} catch (e) {
184
const msg = (<any>e).message ?? 'Error: ' + e;
185
return Promise.resolve({ success: false, errorMessage: msg });
186
}
187
188
return Promise.resolve({ success: true, errorMessage: '' });
189
};
190
}
191
192
193
function getTargetFiles(fileGlobs: string | string[], ignoreGlobs: string | string[]): string[] {
194
if (!Array.isArray(fileGlobs)) {
195
fileGlobs = (fileGlobs.length === 0) ? ['*'] : fileGlobs.split(',');
196
}
197
198
if (!Array.isArray(ignoreGlobs)) {
199
ignoreGlobs = (ignoreGlobs.length === 0) ? [] : ignoreGlobs.split(',');
200
}
201
202
const included: string[] = [];
203
fileGlobs.forEach((fileGlob) => {
204
const matches = glob.sync(fileGlob, { cwd: exampleFolder, ignore: ignoreGlobs }).filter((file) =>
205
(!included.includes(file))
206
);
207
included.push(...matches);
208
});
209
return included;
210
211
}
212
213
function createSimplifiedSearchArgs(args: ISearchArg): ISimplifiedSearchArg {
214
return {
215
filesToInclude: args.filesToInclude ?? '',
216
filesToExclude: args.filesToExclude ?? '',
217
query: args.query,
218
replace: args.replace ?? '',
219
isRegex: args.isRegex ?? false,
220
preserveCase: args.preserveCase ?? false,
221
onlyOpenEditors: args.onlyOpenEditors ?? false
222
};
223
}
224
225
async function testArgs(commands: Command[]): Promise<ISearchArg> {
226
for (const c of commands) {
227
if (c.command === 'github.copilot.executeSearch') {
228
assert(c.title === 'Search');
229
return c.arguments?.[0];
230
}
231
}
232
throw Error('No search command found');
233
}
234
235
function getFunctionFromQuery(query: string, isCaseSensitive: boolean): RegExp {
236
const flags = isCaseSensitive ? 'gm' : 'gmi';
237
return new RegExp(query, flags);
238
}
239
240
function testOnlyQueryOnFiles(fileName: string, query: string, isCaseSensitive: boolean): string[] {
241
const file = fs.readFileSync(fileName, 'utf8');
242
const re = getFunctionFromQuery(query, isCaseSensitive);
243
const results = file.match(re)?.values();
244
return results ? Array.from(results) : [];
245
}
246
247
function getStringFromReplace(fileName: string, query: string, replace: string, isCaseSensitive: boolean): string {
248
const file = fs.readFileSync(fileName, 'utf8');
249
const re = getFunctionFromQuery(query, isCaseSensitive);
250
const str = file.replace(re, replace);
251
return str;
252
}
253
254
function resultMatchesQuery(actual: string[], expected: string[] | string[][]): boolean {
255
if (expected.length === 0) {
256
return (actual.length === 0);
257
}
258
259
const possibilitiesOfExpected: string[][] = (Array.isArray(expected[0]) ? expected : [expected]) as string[][];
260
261
const resultMatchesQuerySingle = (possibleExpected: string[]) => {
262
if (actual.length !== possibleExpected.length) {
263
return false;
264
}
265
for (let i = 0; i < actual.length; i++) {
266
if (!actual[i].includes(possibleExpected[i])) {
267
return false;
268
}
269
}
270
return true;
271
};
272
273
for (const expected of possibilitiesOfExpected) {
274
if (resultMatchesQuerySingle(expected)) {
275
return true;
276
}
277
}
278
return false;
279
}
280
281