Path: blob/main/extensions/copilot/test/simulation/stestUtil.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 path from 'path';7import { IFile, IQualifiedFile, IRelativeFile } from '../../src/platform/test/node/simulationWorkspace';8import { timeout } from '../../src/util/vs/base/common/async';9import { URI } from '../../src/util/vs/base/common/uri';10import { generateUuid } from '../../src/util/vs/base/common/uuid';11import { SIMULATION_FOLDER_NAME } from './shared/sharedTypes';12import { IConversationalOutcome, IEmptyOutcome, IInlineEditOutcome, IOutcome, IWorkspaceEditOutcome } from './types';1314export function forEachModel(models: readonly string[], func: (model: string) => void) {15return () => models.forEach(func);16}1718interface FixtureFileInfo {19readonly kind: 'relativeFile';20readonly fileName: string;21readonly fileContents: string;22}2324/** See https://github.com/microsoft/vscode-ts-file-path-support */25type RelativeFilePath<T extends string> = string & { baseDir?: T };2627/** This function allows [tools](https://github.com/microsoft/vscode-ts-file-path-support/tree/main) to inline/extract the file content. */28export function toFile(data: { filePath: string | FixtureFileInfo } | { fileName: string; fileContents: string } | { uri: URI; fileContents: string }): IFile {29if ('filePath' in data) {30if (typeof data.filePath === 'string') {31return fromFixture(data.filePath);32} else {33return data.filePath;34}35} else if ('fileName' in data) {36return {37kind: 'relativeFile',38fileName: data.fileName,39fileContents: data.fileContents,40} satisfies IRelativeFile;41} else {42return {43kind: 'qualifiedFile',44uri: data.uri,45fileContents: data.fileContents,46} satisfies IQualifiedFile;47}48}4950let _fixturesDir: string | undefined;5152export function getFixturesDir() {53if (!_fixturesDir) {54_fixturesDir = [55path.join(__dirname, '../test/simulation/fixtures'), // after bundling with esbuild56path.join(__dirname, './fixtures'), // when running from sources57].filter(p => fs.existsSync(p))[0];58if (!_fixturesDir) {59throw new Error('Could not find fixtures directory');60}61}62return _fixturesDir;63}6465export function fromFixture(pathWithinFixturesDir: RelativeFilePath<'$dir/fixtures'>): FixtureFileInfo;66export function fromFixture(dirnameWithinFixturesDir: string, relativePathWithinBaseDir: string): FixtureFileInfo;67export function fromFixture(pathOrDirnameWithinFixturesDir: string, relativePathWithinBaseDir?: string): FixtureFileInfo {6869let filePath: string;70let baseDirname: string;71if (relativePathWithinBaseDir === undefined) {72filePath = path.join(getFixturesDir(), pathOrDirnameWithinFixturesDir);73baseDirname = path.dirname(filePath);74} else {75baseDirname = path.join(getFixturesDir(), pathOrDirnameWithinFixturesDir);76filePath = path.join(baseDirname, relativePathWithinBaseDir);77}7879const fileName = path.relative(baseDirname, filePath);80const fileContents = fs.readFileSync(filePath).toString();81return { kind: 'relativeFile' as const, fileName, fileContents };82}8384export function fromFixtureDir(dirnameWithinFixturesDir: string, dirnameWithinDir?: string): FixtureFileInfo[] {85const files = fs.readdirSync(86path.join(getFixturesDir(),87dirnameWithinFixturesDir,88dirnameWithinDir ?? '',89),90{ withFileTypes: true },91);9293const out: FixtureFileInfo[] = [];94for (const file of files) {95const nested = path.join(dirnameWithinDir ?? '', file.name);96if (file.isFile()) {97out.push(fromFixture(dirnameWithinFixturesDir, nested));98} else if (file.isDirectory()) {99out.push(...fromFixtureDir(dirnameWithinFixturesDir, nested));100}101}102103return out;104}105106/**107* Asserts that one of the given assertions passes.108*109* @template T - The type of the value returned by the assertions.110* @param assertions - An array of functions that represent the assertions to be checked.111* @returns - The value returned by the assertion that passes.112* @throws {assert.AssertionError} - If none of the assertions pass.113*/114export function assertOneOf<T>(assertions: (() => T)[]): T {115for (const assertion of assertions) {116try {117return assertion();118} catch (e) {119if (!(e instanceof assert.AssertionError)) {120throw e; // surface unexpected errors121}122}123}124throw new assert.AssertionError({ message: 'none of the assertions passed' });125}126127export interface IInlineReplaceEdit {128kind: 'replaceEdit';129originalStartLine: number;130originalEndLine: number;131modifiedStartLine: number;132modifiedEndLine: number;133changedOriginalLines: string[];134changedModifiedLines: string[];135allOriginalLines: string[];136allModifiedLines: string[];137}138139export function assertInlineEdit(outcome: IOutcome): asserts outcome is IInlineEditOutcome {140assert.strictEqual(outcome.type, 'inlineEdit', `'${outcome.type}' === 'inlineEdit'`);141}142143export function assertNoErrorOutcome(outcome: IOutcome): asserts outcome is IInlineEditOutcome | IWorkspaceEditOutcome | IConversationalOutcome | IEmptyOutcome {144assert.notEqual(outcome.type, 'error', `no error outcome expected`);145}146147export function assertConversationalOutcome(outcome: IOutcome): asserts outcome is IConversationalOutcome {148assert.strictEqual(outcome.type, 'conversational', `'${outcome.type}' === 'conversational'`);149}150151export function assertWorkspaceEdit(outcome: IOutcome): asserts outcome is IWorkspaceEditOutcome {152assert.strictEqual(outcome.type, 'workspaceEdit', `'${outcome.type}' === 'workspaceEdit'`);153}154155/**156* returns null if the files are identical157*/158export function extractInlineReplaceEdits(outcome: IInlineEditOutcome): IInlineReplaceEdit | null {159const originalLines = outcome.originalFileContents.split(/\r\n|\r|\n/g);160const modifiedLines = outcome.fileContents.split(/\r\n|\r|\n/g);161162let ostart = 0;163let mstart = 0;164while (ostart < originalLines.length && mstart < modifiedLines.length && originalLines[ostart] === modifiedLines[mstart]) {165ostart++;166mstart++;167}168169if (ostart === originalLines.length && mstart === modifiedLines.length) {170// identical files171return null;172}173174let ostop = originalLines.length - 1;175let mstop = modifiedLines.length - 1;176while (ostop >= ostart && mstop >= mstart && originalLines[ostop] === modifiedLines[mstop]) {177ostop--;178mstop--;179}180181const changedOriginalLines = originalLines.slice(ostart, ostop + 1);182const changedModifiedLines = modifiedLines.slice(mstart, mstop + 1);183184return {185kind: 'replaceEdit',186originalStartLine: ostart,187originalEndLine: ostop,188modifiedStartLine: mstart,189modifiedEndLine: mstop,190changedOriginalLines,191changedModifiedLines,192allOriginalLines: originalLines,193allModifiedLines: modifiedLines,194};195}196197export interface IInlineEditShape {198line: number;199originalLength: number;200modifiedLength: number | undefined;201}202203export function assertInlineEditShape(outcome: IOutcome, _expected: IInlineEditShape | IInlineEditShape[]): IInlineReplaceEdit {204assertInlineEdit(outcome);205const actual = extractInlineReplaceEdits(outcome);206assert.ok(actual, 'unexpected identical files');207const actualLines = {208line: actual.originalStartLine,209originalLength: actual.originalEndLine - actual.originalStartLine + 1,210modifiedLength: actual.modifiedEndLine - actual.modifiedStartLine + 1,211};212const originalLineCount = outcome.originalFileContents.split(/\r\n|\r|\n/g).length;213const _expectedArr = Array.isArray(_expected) ? _expected : [_expected];214const expectedArr = _expectedArr.map((expected) => {215const line = (216expected.line < 0 ? actual.allOriginalLines.length - ~expected.line : expected.line217);218const originalLength = expected.originalLength;219const modifiedLength = (220typeof expected.modifiedLength === 'undefined'221? (actual.allModifiedLines.length + originalLength - originalLineCount)222: expected.modifiedLength223);224return { line, originalLength, modifiedLength };225});226let err: Error | undefined;227for (const expected of expectedArr) {228try {229assert.deepStrictEqual(actualLines, expected);230return actual;231} catch (e) {232// Let's try the next one233err = e;234}235}236// No options matched237// console.log(`\n`, JSON.stringify(actualLines), '\n', JSON.stringify(expectedArr));238throw err;239}240241export function assertQualifiedFile(file: IFile | { srcUri: string; post: string }): asserts file is IQualifiedFile {242if ('srcUri' in file && 'post' in file) {243// New format - nothing to assert, it's already a qualified file equivalent244return;245}246// Old format - check the kind247assert.strictEqual(file.kind, 'qualifiedFile', `'${file.kind}' === 'qualifiedFile'`);248}249250251/**252* Asserts that at least `n` out of `expected.length` strings are present in `actual` string.253*254* If `n` is not given, `n = Math.floor(1, expected.length / 2)` is used.255*/256export function assertSomeStrings(actual: string, expected: string[], n?: number) {257assert.ok(expected.length > 0, 'Need to expect at least one string');258259if (n === undefined) {260n = Math.max(1, Math.floor(expected.length / 2));261}262263let seen = 0;264for (const item of expected) {265if (actual.includes(item)) {266seen++;267}268}269270assert.ok(seen >= n, `Expected to see at least ${n} of ${expected.join(',')}, but only saw ${seen} in ${actual}`);271}272273export function assertNoStrings(actual: string, expected: string[],) {274assertSomeStrings(actual, expected, 0);275}276277export function assertOccursOnce(hay: string, needle: string) {278const firstOccurrence = hay.indexOf(needle);279assert(firstOccurrence > -1, `assertOccursOnce: no occurrence\n${JSON.stringify({ hay, needle }, null, '\t')}`);280assert(hay.indexOf(needle, firstOccurrence + needle.length) === -1, `assertOccursOnce: more than 1 occurrence\n${JSON.stringify({ hay, needle }, null, '\t')}`);281}282283export function assertNoOccurrence(hay: string, needles: string | string[]): void {284needles = Array.isArray(needles) ? needles : [needles];285for (const needle of needles) {286assert(hay.indexOf(needle) === -1, `assertDoesNotOccur: occurrence\n${JSON.stringify({ hay, needle }, null, '\t')}`);287}288}289290function generateTempDirPath(): string {291return path.join(__dirname, `../${SIMULATION_FOLDER_NAME}/tmp-${generateUuid()}`);292}293294export async function createTempDir(): Promise<string> {295const folderPath = generateTempDirPath();296await fs.promises.mkdir(folderPath, { recursive: true });297return folderPath;298}299300export async function cleanTempDir(folderPath: string): Promise<void> {301await fs.promises.rm(folderPath, { recursive: true, force: true });302}303304export async function cleanTempDirWithRetry(path: string, retry = 3): Promise<void> {305// On windows, sometimes the tsc process holds locks on the directory even after it exits.306// This tries to delete the folder a few times with a delay in between.307let err = null;308for (let i = 0; i < retry; i++) {309try {310await cleanTempDir(path);311return;312} catch (e) {313err = e;314await timeout(1000);315// Ignore error316}317}318319console.error(`Failed to delete ${path} after ${retry} attempts.`, err);320}321322323