Path: blob/main/extensions/copilot/test/e2e/search.stest.ts
13389 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/4import assert from 'assert';5import * as fs from 'fs';6import * as glob from 'glob';7import * as path from 'path';8import type { Command } from 'vscode';9import { Turn } from '../../src/extension/prompt/common/conversation';10import { ITestingServicesAccessor } from '../../src/platform/test/node/services';11import { ssuite, stest } from '../base/stest';12import { generateScenarioTestRunner } from './scenarioTest';1314const NUM_SCENARIOS = 26;1516/**17* Configuration object for a search test.18*/19interface ISearchTestConfig {2021/**22* The question to ask the AI.23* ie: question: "find all links"24*/25question: string;26/**27* Whether the search query is a regular expression.28*/29isRegex: boolean;30/**31* Whether to search only in open editors.32*/33onlyOpenEditors?: boolean;34/**35* 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.36* ie:37* ```38* exampleIncludeGlobs: ["*.md", "*.html"]39* exampleExcludeGlobs: ["*.ts"]40* ```41*/42exampleIncludeGlobs?: string[];43exampleExcludeGlobs?: string[];44/**45* 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.46* Might not include all files that gets searched.47* 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.48* ie:49* queryShouldFind: "foo.md" -> ["http://google.ca", "http://github.com"]50* where the file "foo.md" matches the text "http://google.ca" and "http://github.com"51*52* or53*54* queryShouldFind: "foo.md" -> [["http://google.ca", "http://github.com"],["http://google.ca"]]55* where the file "foo.md" matches the text "http://google.ca" and "http://github.com" OR only "http://google.ca"56*/57queryShouldFind: Map<string, string[] | string[][]>;58/**59* A map of original file names to the file name of the file containing the expected replace result460* ie:61* replaceResult: "foo.md" -> "foo.replaced.md"62*/63replaceResult?: Map<string, string>; // fileName original -> filename expected6465/**66* Whether or not the answer should fail to give a query. Will be false by default.67* Would be true for an answer that says something like "I don't know what you're talking about, please clarify".68*/69shouldFail?: boolean;70}717273interface ISearchArg {74filesToInclude?: string;75filesToExclude?: string;76query: string;77replace?: string;78isCaseSensitive?: boolean;79isRegex?: boolean;80matchWholeWord?: boolean;81onlyOpenEditors?: boolean;82preserveCase?: boolean;83}8485interface ISimplifiedSearchArg {86filesToInclude: string;87filesToExclude: string;88query: string;89replace: string;90isRegex: boolean;91preserveCase: boolean;92onlyOpenEditors: boolean;93}9495const scenarioFolder = path.join(__dirname, '..', 'test/scenarios/test-scenario-search/');96const exampleFolder = path.join(scenarioFolder, 'example-files');97const replaceSamples = path.join(scenarioFolder, 'replace-samples');9899(function () {100ssuite({ title: 'search', location: 'panel' }, () => {101// Dynamically create a test case per each entry102for (let i = 0; i < NUM_SCENARIOS; i++) {103const testCase = getTestInfoFromFile(`search${i}.testArgs.json`);104const testName = testCase.question;105stest({ description: testName }, generateScenarioTestRunner(106[{ question: '@vscode /search ' + testCase.question, name: testName, scenarioFolderPath: scenarioFolder }],107generateEvaluate(testCase)108));109}110});111})();112113function getTestInfoFromFile(fileName: string): ISearchTestConfig {114const file = path.join(scenarioFolder, fileName);115const fileContents = fs.readFileSync(file, 'utf8');116const json = JSON.parse(fileContents);117if (!json.queryShouldFind && !json.shouldFail) {118throw Error('Missing queryShouldFind field');119}120121json.queryShouldFind = new Map(json.queryShouldFind);122123if (json.replaceResult) {124json.replaceResult = new Map(json.replaceResult);125}126127return json;128}129130function generateEvaluate(testInfo: ISearchTestConfig) {131return async function evaluate(accessor: ITestingServicesAccessor, question: string, answer: string, _rawResponse: string, turn: Turn | undefined, _scenarioIndex: number, commands: Command[]): Promise<{ success: boolean; errorMessage?: string }> {132try {133let args: ISimplifiedSearchArg | undefined;134try {135args = createSimplifiedSearchArgs(await testArgs(commands));136} catch (e) {137if (testInfo.shouldFail) {138return Promise.resolve({ success: true, errorMessage: '' });139} else {140return Promise.resolve({ success: false, errorMessage: 'Parsing the search query failed.' });141}142}143144if (testInfo.shouldFail) {145return Promise.resolve({ success: false, errorMessage: 'Parsing the search query should have failed.' });146}147148assert(testInfo.isRegex === undefined || args.isRegex === testInfo.isRegex);149if (testInfo.onlyOpenEditors !== undefined) {150assert(args.onlyOpenEditors === testInfo.onlyOpenEditors);151}152153if (!testInfo.replaceResult || testInfo.replaceResult.size === 0) {154assert(!args.replace);155}156157const actualTargets = getTargetFiles(args.filesToInclude, args.filesToExclude);158const expectedTargets = getTargetFiles(testInfo.exampleIncludeGlobs ?? ['*'], testInfo.exampleExcludeGlobs ?? []);159160assert.deepEqual(actualTargets, expectedTargets);161162if (!args?.query) {163return Promise.resolve({ success: false, errorMessage: 'No query field on args' });164}165166const query = args.query;167const preserveCase = args.preserveCase;168const replace = args.replace;169170testInfo.queryShouldFind.forEach((expected, fileName) => {171const file = path.join(exampleFolder, fileName);172const results = testOnlyQueryOnFiles(file, query, preserveCase);173assert(resultMatchesQuery(results, expected));174});175176testInfo.replaceResult?.forEach((fileNameExpected, fileName) => {177const file = path.join(exampleFolder, fileName);178const result = getStringFromReplace(file, query, replace, preserveCase);179const expected = fs.readFileSync(path.join(replaceSamples, fileNameExpected), 'utf8');180assert(result === expected);181});182} catch (e) {183const msg = (<any>e).message ?? 'Error: ' + e;184return Promise.resolve({ success: false, errorMessage: msg });185}186187return Promise.resolve({ success: true, errorMessage: '' });188};189}190191192function getTargetFiles(fileGlobs: string | string[], ignoreGlobs: string | string[]): string[] {193if (!Array.isArray(fileGlobs)) {194fileGlobs = (fileGlobs.length === 0) ? ['*'] : fileGlobs.split(',');195}196197if (!Array.isArray(ignoreGlobs)) {198ignoreGlobs = (ignoreGlobs.length === 0) ? [] : ignoreGlobs.split(',');199}200201const included: string[] = [];202fileGlobs.forEach((fileGlob) => {203const matches = glob.sync(fileGlob, { cwd: exampleFolder, ignore: ignoreGlobs }).filter((file) =>204(!included.includes(file))205);206included.push(...matches);207});208return included;209210}211212function createSimplifiedSearchArgs(args: ISearchArg): ISimplifiedSearchArg {213return {214filesToInclude: args.filesToInclude ?? '',215filesToExclude: args.filesToExclude ?? '',216query: args.query,217replace: args.replace ?? '',218isRegex: args.isRegex ?? false,219preserveCase: args.preserveCase ?? false,220onlyOpenEditors: args.onlyOpenEditors ?? false221};222}223224async function testArgs(commands: Command[]): Promise<ISearchArg> {225for (const c of commands) {226if (c.command === 'github.copilot.executeSearch') {227assert(c.title === 'Search');228return c.arguments?.[0];229}230}231throw Error('No search command found');232}233234function getFunctionFromQuery(query: string, isCaseSensitive: boolean): RegExp {235const flags = isCaseSensitive ? 'gm' : 'gmi';236return new RegExp(query, flags);237}238239function testOnlyQueryOnFiles(fileName: string, query: string, isCaseSensitive: boolean): string[] {240const file = fs.readFileSync(fileName, 'utf8');241const re = getFunctionFromQuery(query, isCaseSensitive);242const results = file.match(re)?.values();243return results ? Array.from(results) : [];244}245246function getStringFromReplace(fileName: string, query: string, replace: string, isCaseSensitive: boolean): string {247const file = fs.readFileSync(fileName, 'utf8');248const re = getFunctionFromQuery(query, isCaseSensitive);249const str = file.replace(re, replace);250return str;251}252253function resultMatchesQuery(actual: string[], expected: string[] | string[][]): boolean {254if (expected.length === 0) {255return (actual.length === 0);256}257258const possibilitiesOfExpected: string[][] = (Array.isArray(expected[0]) ? expected : [expected]) as string[][];259260const resultMatchesQuerySingle = (possibleExpected: string[]) => {261if (actual.length !== possibleExpected.length) {262return false;263}264for (let i = 0; i < actual.length; i++) {265if (!actual[i].includes(possibleExpected[i])) {266return false;267}268}269return true;270};271272for (const expected of possibilitiesOfExpected) {273if (resultMatchesQuerySingle(expected)) {274return true;275}276}277return false;278}279280281