Path: blob/main/extensions/copilot/test/e2e/terminal.stest.ts
13388 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*--------------------------------------------------------------------------------------------*/45import { ok } from 'assert';6import { join } from 'path';7import { deserializeWorkbenchState } from '../../src/platform/test/node/promptContextModel';8import { ITestingServicesAccessor } from '../../src/platform/test/node/services';9import { extractCodeBlocks, extractInlineCode } from '../../src/util/common/markdown';10import { ssuite, stest } from '../base/stest';11import { ScenarioEvaluator } from './scenarioLoader';12import { generateScenarioTestRunner } from './scenarioTest';1314const scenarioFolder = join(__dirname, '..', 'test/scenarios/test-terminal/');1516type SupportedShellType = 'bash' | 'fish' | 'powershell' | 'zsh';1718type TerminalTestCaseSingleAnswer = (string | RegExp)[];19type TerminalTestCaseShellSpecificAnswer = (20// Shell-specific answers21Partial<Record<SupportedShellType, TerminalTestCaseSingleAnswer>>22&23// Negated shell-specific answers24Partial<Record<`!${SupportedShellType}`, TerminalTestCaseSingleAnswer>>25&26(27{28// Answers that apply to any shell29any?: TerminalTestCaseSingleAnswer;30// The fallback default answer when no shell-specific answer exists31default: TerminalTestCaseSingleAnswer;32}33|34{35// Answers that apply to any shell36any: TerminalTestCaseSingleAnswer;37// The fallback default answer when no shell-specific answer exists38default?: TerminalTestCaseSingleAnswer;39}40)41);4243interface ITerminalTestCase {44/**45* The question being asked.46*/47question: string;48/**49* The shell type context.50*/51shellType: SupportedShellType;52/**53* The ideal answer(s) to the question.54*/55bestAnswer: TerminalTestCaseSingleAnswer | TerminalTestCaseShellSpecificAnswer;56/**57* Answers that are acceptable but not ideal, for example they may do what asked but in a more58* complicated way than necessary or require slight user tweaks.59*/60acceptableAnswers?: TerminalTestCaseSingleAnswer | Partial<TerminalTestCaseShellSpecificAnswer>;61}6263const supportedShells = [64'bash',65'fish',66'powershell',67'zsh'68] as const;6970function getShellSpecificAnswer(answerObject: TerminalTestCaseSingleAnswer | TerminalTestCaseShellSpecificAnswer | Partial<TerminalTestCaseShellSpecificAnswer>, shellType: SupportedShellType): TerminalTestCaseSingleAnswer {71// No shell-specific answers72if (Array.isArray(answerObject)) {73return answerObject;74}7576let answer: TerminalTestCaseSingleAnswer;77if (shellType in answerObject) {78answer = [...answerObject[shellType]!];79} else {80answer = answerObject.default ? [...answerObject.default] : [];81}82if ('any' in answerObject) {83answer.push(...answerObject.any!);84}8586for (const negatedShellType in supportedShells) {87if (shellType === negatedShellType) {88continue;89}90if (`!${negatedShellType}` in answerObject) {91answer.push(...answerObject[`!${negatedShellType as SupportedShellType}`]!);92}93}9495return answer;96}9798const generalTestCases: ITerminalTestCase[] = [];99100for (const shellType of supportedShells) {101generalTestCases.push(...([102{103shellType, question: 'go to the foo dir',104bestAnswer: {105powershell: [106/Set-Location( -Path)? (\.\\foo\\|(\.\/)?foo\/?)/,107/cd (\.\\foo\\|(\.\/)?foo\/?)/108],109default: [110'cd foo'111]112},113},114{115shellType, question: 'print the directory',116bestAnswer: {117powershell: [118'Get-Location'119],120default: [121'pwd'122]123},124acceptableAnswers: {125powershell: [126'pwd'127]128}129},130{131shellType, question: 'print README.md',132bestAnswer: {133powershell: [134'Get-Content README.md',135],136any: [137'cat README.md',138]139},140},141{142shellType, question: 'list files in directory',143bestAnswer: {144powershell: [145'Get-ChildItem',146/Get-ChildItem -Path .\\?/147],148any: [149'ls'150]151},152acceptableAnswers: {153powershell: [154/Get-ChildItem -Path {.+}/155]156},157},158{159shellType, question: 'create a file called foo',160bestAnswer: {161powershell: [162/New-Item( -ItemType File)? -Name "?foo"?/163],164default: [165'touch foo'166]167},168},169{170shellType, question: 'delete the foo.txt file',171bestAnswer: {172powershell: [173/Remove-Item (\.[\\/])?foo.txt/174],175default: [176'rm foo.txt'177]178},179},180{181shellType, question: 'delete the foo/ dir',182bestAnswer: {183powershell: [184/Remove-Item( -Recurse| -Force)*( -Path)? (\.\\foo\\|(\.\/)?foo\/?)( -Recurse| -Force)*/185],186default: [187/rm -rf? foo\/?/188]189},190},191{192shellType, question: 'create a symlink',193bestAnswer: {194powershell: [195/New-Item -ItemType SymbolicLink -Path "?{.+}"? -(Target|Value) "?{.+}"?/196],197default: [198/ln -s {.+} {.+}/199]200},201},202{203shellType, question: 'print "hello world"',204bestAnswer: {205powershell: [206/(echo|Write-(Host|Output)) "[hH]ello [wW]orld"/207],208default: [209/echo "[hH]ello [wW]orld"/210]211},212},213{214shellType, question: 'kill the process using port 8123',215bestAnswer: {216powershell: [217/Stop-Process -Id \(Get-NetTCPConnection -LocalPort 8123\).OwningProcess( -Force)?/,218/Get-NetTCPConnection -LocalPort 8123 \| ForEach-Object { Stop-Process -Id \$_.OwningProcess -Force }/219],220fish: [221/fuser -k 8123\/tcp/,222/(sudo )?kill (-9 )?\((sudo )?lsof -t( -i|i) ?(tcp)?:8123\)/,223],224default: [225/fuser -k 8123\/tcp/,226/(sudo )?kill (-9 )?\$\((sudo )?lsof -t -i:8123\)/,227/(sudo )?lsof -t -i:8123 \| xargs kill -9/,228/(sudo )?lsof -ti:8123 \| xargs kill -9/,229/(sudo )?lsof -i :8123 \| awk 'NR!=1 {print \$2}/,230]231},232acceptableAnswers: {233powershell: [234/Stop-Process -Id \(Get-NetTCPConnection -LocalPort {.+}\).OwningProcess -Force/235],236fish: [237/kill (-9 )?\(lsof -t -i\s?:{.+}\)/,238],239default: [240/(sudo )?kill (-9 )?\$\(lsof -t -i:{.+}\)/,241/(sudo )?lsof -ti:{.+} \| xargs kill -9/,242]243}244},245{246shellType, question: 'kill process using port',247bestAnswer: {248powershell: [249/Get-NetTCPConnection \| Where-Object LocalPort -eq {.+} \| ForEach-Object { Stop-Process -Id \$_.OwningProcess -Force }/,250/Get-NetTCPConnection -LocalPort {.+} \| ForEach-Object { Stop-Process -Id \$_.OwningProcess/,251/Stop-Process -Id \(Get-NetTCPConnection -LocalPort {.+}\).OwningProcess( -Force)?/252],253fish: [254/fuser -k {.+}\/tcp/,255/(sudo )?kill (-9 )?\(lsof -t(i)?\s?:{.+}\)/,256/(sudo )?kill (-9 )?\(lsof -t -i\s?:{.+}\)/257],258default: [259/fuser -k {.+}\/tcp/,260/(sudo )?kill (-9 )?\$\((sudo )?lsof -t -i:{.+}\)/,261/lsof -ti:{.+} \| xargs kill -9/,262]263},264},265{266shellType, question: 'extract a tar file',267bestAnswer: [268/tar -xv?f {.+}/,269],270},271{272shellType, question: 'extract foo.tar',273bestAnswer: {274powershell: [275/Expand-Archive( -Path)? ['"]?foo.tar['"]? -DestinationPath ['"]?(\.|{.+})[\\\/]?['"]?/276],277default: [278/tar -xv?f foo.tar/,279]280},281acceptableAnswers: {282powershell: [283/tar -xv?f foo.tar/,284]285}286},287{288shellType, question: 'extract a zip file',289bestAnswer: {290powershell: [291/Expand-Archive( -Path)? ['"]?{.+}['"]? -DestinationPath ['"]?(\.|{.+})[\\\/]?['"]?/292],293default: [294/unzip {.+}/295]296},297},298{299shellType, question: 'extract foo.zip',300bestAnswer: {301powershell: [302/Expand-Archive( -Path)? (\.[\\/])?foo.zip -DestinationPath (\.|{.+})[\\\/]?/303],304default: [305'unzip foo.zip'306]307},308},309{310shellType, question: 'extract foo.tar to bar/',311bestAnswer: {312powershell: [313/Expand-Archive( -Path)? foo.tar -DestinationPath bar/314],315default: [316/tar -xv?f foo.tar -C bar\//,317]318},319acceptableAnswers: {320powershell: [321'tar -xf foo.tar -C bar/'322]323}324},325{326shellType, question: 'make a directory',327bestAnswer: {328powershell: [329/New-Item (-Path \. )?-Name "{.+}" -ItemType Directory/,330/New-Item -ItemType Directory (-Path \. )?-Name "?{.+}"?/331],332any: [333/mkdir {.+}/334]335},336},337{338shellType, question: 'make a directory called foo',339bestAnswer: {340powershell: [341/New-Item (-Path \. )?-Name foo -ItemType Directory/,342/New-Item -ItemType Directory (-Path \. )?-Name foo/343],344default: [345/mkdir foo/346]347},348acceptableAnswers: {349powershell: [350/mkdir foo/,351]352}353},354{355shellType, question: 'copy file foo to bar/',356bestAnswer: {357powershell: [358/Copy-Item (-Path )?(\.\\bar|(\.\/)?foo) (-Destination )?(\.\\bar\\|(\.\/)?bar\/?)/,359/cp (\.\\bar|(\.\/)?foo) (\.\\bar\\|(\.\/)?bar\/?)/,360],361default: [362'cp foo bar/'363]364},365},366{367shellType, question: 'move file foo to bar/',368bestAnswer: {369powershell: [370/Move-Item (-Path )?(\.[\\/])?foo (-Destination )?(\.\\bar\\|(\.\/)?bar\/?)/,371/mv (\.[\\/])?foo (\.\\bar\\|(\.\/)?bar\/?)/372],373default: [374'mv foo bar/'375]376},377},378{379shellType, question: 'kill the visual studio code process',380bestAnswer: {381powershell: [382/Stop-Process -Name "?[cC]ode"?/383],384fish: [385/pkill( -f)? "?code"?/,386'kill (pidof code)',387/kill \(pgrep [cC]ode\)/,388/killall (vscode|[cC]ode|["']Visual Studio Code["'])/,389],390default: [391/pkill( -f)? "?code"?/,392/pkill -f ["']Visual Studio Code["']/,393/killall (vscode|[cC]ode|["']Visual Studio Code["'])/,394'kill $(pgrep code)',395/kill \$\(pgrep -f ["']Visual Studio Code["']\)/,396]397},398acceptableAnswers: {399'!powershell': [400/pkill( -f)? "?code"?/,401]402},403},404{405shellType, question: 'how do i download a file',406bestAnswer: {407powershell: [408/Invoke-WebRequest -Uri "?{.+}"? -OutFile "?{.+}"?/409],410default: [411/wget {.+}/,412/curl -O {.+}/,413]414},415},416{417shellType, question: 'how do i download a file using curl',418bestAnswer: [419/curl -O {.+}/,420/curl -o {.+} {.+}/,421],422},423] satisfies ITerminalTestCase[]));424}425426// zsh-specific427generalTestCases.push(...([428{429shellType: 'zsh',430question: 'turn off the zsh git plugin',431bestAnswer: [432/\.zshrc/ // The answer must include a reference to .zshrc433]434},435] satisfies ITerminalTestCase[]));436437// Git test cases438const gitTestCases: ITerminalTestCase[] = [];439for (const shellType of supportedShells) {440gitTestCases.push(...[441{442shellType,443question: 'show last git commit details',444bestAnswer: [445'git show',446'git show HEAD',447'git show --summary',448'git show -1',449/git show --oneline( -1| -s HEAD)/,450],451acceptableAnswers: [452'git show --stat',453'git log -1',454],455},456{457shellType,458question: 'list all git commits by Daniel',459bestAnswer: [460'git log --author=Daniel',461'git log --author="Daniel"',462],463},464{465shellType,466question: 'enable colors in the git cli',467bestAnswer: [468'git config --global color.ui auto',469'git config --global color.ui true',470]471},472{473shellType,474question: 'checkout the foo branch',475bestAnswer: [476'git checkout foo',477]478},479{480shellType,481question: 'create and checkout the foo branch',482bestAnswer: [483'git checkout -b foo',484]485},486{487shellType,488question: 'merge the branch foo into this branch',489bestAnswer: [490'git merge foo',491]492},493{494shellType,495question: 'delete the foo branch',496bestAnswer: [497'git branch -d foo',498]499},500{501shellType,502question: 'create a git repo in this folder',503bestAnswer: [504'git init',505]506},507{508shellType,509question: 'add a git remote',510bestAnswer: [511/git remote add {.+} {.+}/,512]513},514]);515}516517for (const { title, testCases } of [518{ title: 'general', testCases: generalTestCases },519{ title: 'git', testCases: gitTestCases },520]) {521ssuite({ title: `terminal (${title})`, location: 'panel' }, () => {522for (const testCase of testCases) {523// Non-strict tests verify _any expected_ answer was given in _any_ code block524stest(525{526description: testCase.question,527language: testCase.shellType,528},529generateScenarioTestRunner(530[{531question: `@terminal ${testCase.question}`,532name: testCase.question,533scenarioFolderPath: scenarioFolder,534getState: () => deserializeWorkbenchState(scenarioFolder, join(scenarioFolder, `${testCase.shellType ?? 'bash'}.state.json`)),535}],536generateEvaluate(testCase)537)538);539// Strict tests verify the _best expected_ answer was given and the _first_ code block540stest(541{542description: `${testCase.question} (strict)`,543language: testCase.shellType,544},545generateScenarioTestRunner(546[{547question: `@terminal ${testCase.question}`,548name: `${testCase.question} (strict)`,549scenarioFolderPath: scenarioFolder,550getState: () => deserializeWorkbenchState(scenarioFolder, join(scenarioFolder, `${testCase.shellType ?? 'bash'}.state.json`)),551}],552generateEvaluate(testCase, { strict: true })553)554);555}556});557}558559interface IGenerateEvaluateOptions {560strict?: boolean;561}562563function generateEvaluate(testCase: ITerminalTestCase, options: IGenerateEvaluateOptions = {}): ScenarioEvaluator {564return async function evaluate(accessor: ITestingServicesAccessor, question: string, answer: string): Promise<{ success: boolean; errorMessage?: string }> {565const inlineCode = extractInlineCode(answer);566const codeBlocks = extractCodeBlocks(answer);567const commandSuggestions = codeBlocks.map(e => e.code);568// Only include inline code suggestions in strict tests as it's harder to action inline code569if (!options.strict) {570commandSuggestions.concat(inlineCode);571}572if (options.strict) {573const firstSuggestion = commandSuggestions[0];574const bestAnswer = getShellSpecificAnswer(testCase.bestAnswer, testCase.shellType);575// Uncomment for quickly checking failed assertions with full answers576// if (bestAnswer.every(e => {577// return (typeof e === 'string'578// ? e !== firstSuggestion579// : !firstSuggestion.match(e)580// );581// })) {582// console.log(`\n\x1b[31mFAILURE:\x1b[0m\n The _first_ code block\n \`${commandSuggestions[0]}\`\nshould _equal_ the expected answer\n \`${bestAnswer.join(',')}\`)`);583// console.log('\x1b[31m\nQUESTION:\n\x1b[0;2m' + question + '\n\x1b[0m');584// console.log('\x1b[31m\nANSWER:\n\x1b[0;2m' + answer + '\n\x1b[0m');585// }586ok(587bestAnswer.some(e => {588return (typeof e === 'string'589? e === firstSuggestion590: firstSuggestion.match(e)591);592}),593`The _first_ code block (\`${commandSuggestions[0]}\`) should _equal_ the expected answer (\`${bestAnswer.join(',')}\`)`594);595} else {596const bestAnswer = getShellSpecificAnswer(testCase.bestAnswer, testCase.shellType);597const acceptableAnswers = [...bestAnswer];598if (testCase.acceptableAnswers) {599acceptableAnswers.push(...getShellSpecificAnswer(testCase.acceptableAnswers, testCase.shellType));600}601ok(602commandSuggestions.some(e => {603return acceptableAnswers.some(expected => {604return (typeof expected === 'string'605? e.includes(expected)606: e.match(expected)607);608});609}),610`Any code block or inline code should _include_ an expected answer (\`${acceptableAnswers.join(',')}\`)`611);612}613return Promise.resolve({ success: true, errorMessage: '' });614};615}616617618