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