Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/extensions/copilot/test/simulation/workbench/stores/simulationRunner.ts
13399 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 { ipcRenderer } from 'electron';
7
import * as fs from 'fs';
8
import minimist from 'minimist';
9
import * as mobx from 'mobx';
10
import * as path from 'path';
11
import { Result } from '../../../../src/util/common/result';
12
import { AsyncIterableObject } from '../../../../src/util/vs/base/common/async';
13
import { CancellationTokenSource } from '../../../../src/util/vs/base/common/cancellation';
14
import { Disposable } from '../../../../src/util/vs/base/common/lifecycle';
15
import { IInitialTestSummaryOutput, OutputType, RunOutput, SIMULATION_FOLDER_NAME, STDOUT_FILENAME, generateOutputFolderName } from '../../shared/sharedTypes';
16
import { spawnSimulationFromMainProcess } from '../utils/simulationExec';
17
import { ObservablePromise, REPO_ROOT } from '../utils/utils';
18
import { CacheMode, RunnerOptions } from './runnerOptions';
19
import { RunnerTestStatus } from './runnerTestStatus';
20
import { SimulationStorage, SimulationStorageValue } from './simulationStorage';
21
import { TestRun } from './testRun';
22
23
const SIMULATION_FOLDER_PATH = path.join(REPO_ROOT, SIMULATION_FOLDER_NAME);
24
25
export interface RunConfig {
26
grep: string;
27
cacheMode: CacheMode;
28
n: number;
29
noFetch: boolean;
30
additionalArgs: string;
31
/** NES external scenarios path. When set, `--nes=external` and `--external-scenarios` are added. */
32
nesExternalScenariosPath?: string;
33
}
34
35
export const enum StateKind {
36
Initializing,
37
Running,
38
Stopped
39
}
40
41
type State =
42
| { kind: StateKind.Initializing }
43
| { kind: StateKind.Running }
44
| { kind: StateKind.Stopped };
45
46
/** Constructors for {@link State} discriminated union */
47
const State = {
48
Initializing: () => ({ kind: StateKind.Initializing }),
49
Running: () => ({ kind: StateKind.Running }),
50
Stopped: () => ({ kind: StateKind.Stopped }),
51
};
52
53
export class TestRuns {
54
constructor(
55
public readonly name: string,
56
public readonly runs: TestRun[],
57
public readonly simulationInputPath?: string,
58
public activeEditorLanguageId?: string,
59
) { }
60
}
61
62
class DeserialisedTestRuns {
63
64
constructor(
65
public readonly name: string,
66
public readonly expectedRuns: number,
67
public readonly runs: TestRun[] = []
68
) { }
69
70
public addRun(run: TestRun) {
71
this.runs.push(run);
72
this.runs.sort((a, b) => {
73
return (a.runNumber ?? 0) - (b.runNumber ?? 0);
74
});
75
}
76
}
77
78
export class SimulationRunner extends Disposable {
79
80
public static async readFromPreviousRun(outputFolderName: string): Promise<TestRuns[]> {
81
const outputFolder = path.join(SIMULATION_FOLDER_PATH, outputFolderName);
82
const stdoutFilePath = path.join(outputFolder, STDOUT_FILENAME);
83
return SimulationRunner.readFromStdoutJSON(stdoutFilePath);
84
}
85
86
public static async readFromStdoutJSON(stdoutFilePath: string, simulationInputPath?: string): Promise<TestRuns[]> {
87
const entries = JSON.parse(await fs.promises.readFile(stdoutFilePath, 'utf8')) as RunOutput[];
88
const testRuns = SimulationRunner.createFromRunOutput(stdoutFilePath, entries);
89
return testRuns.map(tr => new TestRuns(tr.name, tr.runs));
90
}
91
92
public static createFromRunOutput(stdoutFilePath: string, runOutput: RunOutput[],): DeserialisedTestRuns[] {
93
const summaryEntry = findInitialTestSummary(runOutput);
94
const nRuns = summaryEntry?.nRuns ?? 1;
95
const allTestRuns = new Map<string, DeserialisedTestRuns>();
96
for (const entry of runOutput) {
97
if (entry.type !== OutputType.testRunEnd) {
98
continue;
99
}
100
let testRuns = allTestRuns.get(entry.name);
101
if (!testRuns) {
102
testRuns = new DeserialisedTestRuns(entry.name, nRuns);
103
allTestRuns.set(entry.name, testRuns);
104
}
105
106
testRuns.addRun(new TestRun(
107
entry.runNumber,
108
entry.pass,
109
entry.explicitScore,
110
entry.error,
111
entry.duration,
112
path.dirname(stdoutFilePath),
113
entry.writtenFiles,
114
entry.averageRequestDuration,
115
entry.requestCount,
116
entry.hasCacheMiss,
117
entry.annotations,
118
));
119
}
120
return Array.from(allTestRuns.values());
121
}
122
123
private _selectedRun: SelectedRun;
124
private _diskSelectedRun: DiskSelectedRun;
125
private _simulationExecutor: SimulationExecutor;
126
127
@mobx.computed
128
public get selectedRun(): string {
129
return this._selectedRun.name;
130
}
131
132
@mobx.computed
133
public get state(): State {
134
return this._simulationExecutor.state;
135
}
136
137
@mobx.computed
138
public get maybeTestStatus(): Result<readonly RunnerTestStatus[], Error> {
139
return (
140
this._selectedRun.isFromDisk
141
? this._diskSelectedRun.testStatus
142
: this._simulationExecutor.testStatus
143
);
144
}
145
146
@mobx.computed
147
public get testStatus(): readonly RunnerTestStatus[] {
148
if (this.maybeTestStatus.isOk()) {
149
return this.maybeTestStatus.val;
150
}
151
return [];
152
}
153
154
@mobx.computed
155
public get terminationReason(): string | undefined {
156
return (
157
this._selectedRun.isFromDisk
158
? this._diskSelectedRun.terminationReason
159
: this._simulationExecutor.terminationReason
160
);
161
}
162
163
constructor(storage: SimulationStorage, private readonly runnerOptions: RunnerOptions) {
164
super();
165
166
// TODO: add support for init args (parseInitEventArgs)
167
this._selectedRun = new SelectedRun(storage);
168
this._diskSelectedRun = new DiskSelectedRun(this._selectedRun);
169
this._simulationExecutor = new SimulationExecutor(this._selectedRun);
170
171
mobx.makeObservable(this);
172
}
173
174
public setSelectedRunFromDisk(name: string) {
175
mobx.runInAction(() => {
176
this._selectedRun.set(name, true);
177
});
178
}
179
180
public startRunningFromRunnerOptions(): Result<string, 'AlreadyRunning'> {
181
return this._simulationExecutor.startRunning({
182
grep: this.runnerOptions.grep.value,
183
cacheMode: this.runnerOptions.cacheMode.value,
184
n: parseInt(this.runnerOptions.n.value),
185
noFetch: this.runnerOptions.noFetch.value,
186
additionalArgs: this.runnerOptions.additionalArgs.value,
187
});
188
}
189
190
public startRunning(runConfig: RunConfig): Result<string, 'AlreadyRunning'> {
191
return this._simulationExecutor.startRunning(runConfig);
192
}
193
194
public stopRunning(): void {
195
this._simulationExecutor.stopRunning();
196
}
197
198
public async renameRun(oldName: string, newName: string): Promise<boolean> {
199
if (oldName === '' || newName === '') {
200
console.log('Cannot rename: old or new name is empty', { oldName, newName });
201
return false;
202
}
203
204
const oldPath = path.join(SIMULATION_FOLDER_PATH, oldName);
205
const newPath = path.join(SIMULATION_FOLDER_PATH, newName);
206
207
try {
208
// Check if old path exists and new path doesn't
209
const oldExists = await fs.promises.stat(oldPath).then(() => true).catch(() => false);
210
const newExists = await fs.promises.stat(newPath).then(() => true).catch(() => false);
211
212
if (!oldExists) {
213
console.log('Cannot rename: old path does not exist', oldPath);
214
return false;
215
}
216
if (newExists) {
217
console.log('Cannot rename: new path already exists', newPath);
218
return false;
219
}
220
221
// Rename the directory
222
console.log('Renaming directory from', oldPath, 'to', newPath);
223
await fs.promises.rename(oldPath, newPath);
224
console.log('Successfully renamed directory');
225
226
// Update selected run if it was the renamed one
227
if (this._selectedRun.name === oldName) {
228
console.log('Updating selected run name from', oldName, 'to', newName);
229
mobx.runInAction(() => {
230
this._selectedRun.set(newName, true);
231
});
232
}
233
234
return true;
235
} catch (e) {
236
console.error('Failed to rename run:', e);
237
return false;
238
}
239
}
240
}
241
242
class SelectedRun {
243
private _name: SimulationStorageValue<string>;
244
245
@mobx.observable
246
public isFromDisk: boolean = true;
247
248
@mobx.computed
249
public get name(): string {
250
return this._name.value;
251
}
252
253
constructor(storage: SimulationStorage) {
254
// TODO: add support for init args (parseInitEventArgs)
255
this._name = new SimulationStorageValue(storage, 'selectedRun', '');
256
257
mobx.makeObservable(this);
258
}
259
260
/**
261
* Should be called in a MobX action.
262
*/
263
set(name: string, isFromDisk: boolean): void {
264
this._name.value = name;
265
this.isFromDisk = isFromDisk;
266
}
267
}
268
269
class DiskSelectedRun {
270
271
@mobx.computed
272
public get runOutput(): ObservablePromise<Result<RunOutput[], Error>> {
273
return new ObservablePromise((async () => {
274
if (!this._selectedRun.isFromDisk) {
275
return Result.fromString(`This run is not from disk!`);
276
}
277
if (this._selectedRun.name === '') {
278
return Result.ok([]);
279
}
280
const outputFolderPath = path.join(SIMULATION_FOLDER_PATH, this._selectedRun.name);
281
const stdoutFile = path.join(outputFolderPath, STDOUT_FILENAME);
282
try {
283
const stdoutFileContents = await fs.promises.readFile(stdoutFile, 'utf8');
284
return Result.ok(JSON.parse(stdoutFileContents) as RunOutput[]);
285
} catch (e) {
286
return Result.error(e);
287
}
288
})(), Result.ok([]));
289
}
290
291
@mobx.computed
292
public get testStatus(): Result<readonly RunnerTestStatus[], Error> {
293
294
if (!this._selectedRun.isFromDisk) {
295
return Result.fromString(`This run is not from disk!`);
296
}
297
298
if (!this.runOutput.value.isOk()) {
299
return this.runOutput.value;
300
}
301
302
const entries = this.runOutput.value.val;
303
for (const entry of entries) {
304
if (entry.type === OutputType.terminated) {
305
return Result.fromString(`Terminated: ${entry.reason}`);
306
}
307
}
308
309
const outputFolderPath = path.join(SIMULATION_FOLDER_PATH, this._selectedRun.name);
310
const stdoutFilePath = path.join(outputFolderPath, STDOUT_FILENAME);
311
const testRuns = SimulationRunner.createFromRunOutput(stdoutFilePath, entries);
312
const testStatus = testRuns.map(tr => new RunnerTestStatus(tr.name, tr.expectedRuns, tr.runs));
313
return Result.ok(testStatus);
314
}
315
316
@mobx.computed
317
public get terminationReason(): string | undefined {
318
if (!this.testStatus.isOk()) {
319
return this.testStatus.err.stack;
320
}
321
return undefined;
322
}
323
324
constructor(
325
private readonly _selectedRun: SelectedRun
326
) {
327
mobx.makeObservable(this);
328
}
329
}
330
331
class SimulationExecutor {
332
333
private currentCancellationTokenSource: CancellationTokenSource | undefined;
334
335
@mobx.observable
336
public state: State = State.Initializing();
337
338
@mobx.observable
339
public terminationReason: string | undefined = undefined;
340
341
@mobx.observable
342
public runningTestStatus: Map<string, RunnerTestStatus> = new Map<string, RunnerTestStatus>();
343
344
/** Tests registered for the current run via `initialTestSummary`. Used to scope incompleteness checks. */
345
private currentRunTests: Set<string> = new Set();
346
347
@mobx.computed
348
public get testStatus(): Result<readonly RunnerTestStatus[], Error> {
349
return Result.ok(Array.from(this.runningTestStatus.values()));
350
}
351
352
constructor(
353
private readonly _selectedRun: SelectedRun
354
) {
355
mobx.makeObservable(this);
356
}
357
358
public startRunning(runConfig: RunConfig): Result<string, 'AlreadyRunning'> {
359
if (this.state.kind === StateKind.Running) {
360
return Result.error('AlreadyRunning');
361
}
362
const isNesExternal = !!runConfig.nesExternalScenariosPath;
363
const outputFolder = path.join(REPO_ROOT, SIMULATION_FOLDER_NAME, generateOutputFolderName(isNesExternal ? 'external' : undefined));
364
const stdoutFile = path.join(outputFolder, STDOUT_FILENAME);
365
366
this.currentCancellationTokenSource = new CancellationTokenSource();
367
mobx.runInAction(() => {
368
this.state = State.Running();
369
this.terminationReason = undefined;
370
this.currentRunTests = new Set();
371
this._selectedRun.set(path.basename(outputFolder), false);
372
});
373
374
const args: string[] = ['--json'];
375
if (runConfig.grep) {
376
args.push(`--grep=${runConfig.grep}`);
377
}
378
switch (runConfig.cacheMode) {
379
case CacheMode.Disable:
380
args.push(`--skip-cache`);
381
break;
382
case CacheMode.Regenerate:
383
args.push(`--regenerate-cache`);
384
break;
385
case CacheMode.Require:
386
args.push(`--require-cache`);
387
break;
388
}
389
if (runConfig.n) {
390
args.push(`--n=${runConfig.n}`);
391
}
392
if (runConfig.noFetch) {
393
args.push(`--no-fetch`);
394
}
395
args.push(`--output=${outputFolder}`);
396
if (runConfig.nesExternalScenariosPath) {
397
args.push(`--nes=external`);
398
args.push(`--external-scenarios=${runConfig.nesExternalScenariosPath}`);
399
}
400
Object.entries(minimist(runConfig.additionalArgs.split(' '))).filter(([k]) => k !== '_' && k !== '--').forEach(([k, v]) => {
401
args.push(v !== undefined ? `--${k}=${v}` : `--${k}`);
402
});
403
const stream = spawnSimulationFromMainProcess<RunOutput>({ args }, this.currentCancellationTokenSource.token);
404
this.interpretOutput(stream, stdoutFile);
405
406
return Result.ok(outputFolder);
407
}
408
409
public stopRunning(): void {
410
if (this.state.kind !== StateKind.Running) {
411
return;
412
}
413
if (this.currentCancellationTokenSource === undefined) {
414
console.warn('currentCancellationTokenSource is undefined');
415
return;
416
}
417
try { this.currentCancellationTokenSource!.cancel(); } catch (_) { } // to avoid unhandled promise rejection
418
mobx.runInAction(() => {
419
this.state = State.Stopped();
420
for (const [_, status] of this.runningTestStatus) {
421
if (status.runs.length < status.expectedRuns) {
422
status.isCancelled = true;
423
}
424
}
425
});
426
this.currentCancellationTokenSource = undefined;
427
}
428
429
private async interpretOutput(stream: AsyncIterableObject<RunOutput>, stdoutFile: string): Promise<void> {
430
const writtenFilesBaseDir = path.dirname(stdoutFile);
431
const entries: RunOutput[] = [];
432
try {
433
for await (const entry of stream) {
434
entries.push(entry);
435
mobx.runInAction(() => this.interpretOutputEntry(writtenFilesBaseDir, entry)); // TODO@ulugbekna: we should batch updates
436
}
437
} catch (e) {
438
console.error('interpretOutput', JSON.stringify(e, null, '\t'));
439
mobx.runInAction(() => {
440
const hasIncompleteTests = this.currentRunTests.size === 0 || Array.from(this.currentRunTests).some(
441
name => {
442
const status = this.runningTestStatus.get(name);
443
return !status || status.runs.length < status.expectedRuns;
444
}
445
);
446
if (hasIncompleteTests) {
447
this.terminationReason = typeof e === 'string' ? e : e instanceof Error ? (e.stack ?? e.message) : String(e);
448
}
449
for (const [_, status] of this.runningTestStatus) {
450
if (status.runs.length < status.expectedRuns) {
451
status.isCancelled = true;
452
}
453
}
454
});
455
} finally {
456
await fs.promises.writeFile(stdoutFile, JSON.stringify(entries, null, '\t'));
457
this.currentCancellationTokenSource = undefined;
458
mobx.runInAction(() => {
459
this.state = State.Stopped();
460
});
461
}
462
}
463
464
/** @remarks MUST be called within `mobx.runInAction` */
465
private interpretOutputEntry(writtenFilesBaseDir: string, entry: RunOutput): void {
466
switch (entry.type) {
467
case OutputType.initialTestSummary:
468
for (const testName of entry.testsToRun) {
469
this.currentRunTests.add(testName);
470
this.runningTestStatus.set(testName, new RunnerTestStatus(testName, entry.nRuns, []));
471
}
472
return;
473
case OutputType.testRunStart:
474
this.runningTestStatus.get(entry.name)!.isNowRunning++;
475
return;
476
case OutputType.testRunEnd:
477
this.runningTestStatus.get(entry.name)!.isNowRunning--;
478
this.runningTestStatus.get(entry.name)!.addRun(new TestRun(
479
entry.runNumber,
480
entry.pass,
481
entry.explicitScore,
482
entry.error,
483
entry.duration,
484
writtenFilesBaseDir,
485
entry.writtenFiles,
486
entry.averageRequestDuration,
487
entry.requestCount,
488
entry.hasCacheMiss,
489
entry.annotations
490
));
491
return;
492
case OutputType.skippedTest:
493
this.runningTestStatus.get(entry.name)!.isSkipped = true;
494
return;
495
case OutputType.terminated:
496
this.terminationReason = entry.reason;
497
return;
498
case OutputType.deviceCodeCallback:
499
ipcRenderer.send('open-link', entry.url);
500
return;
501
}
502
}
503
}
504
505
function findInitialTestSummary(runOutput: RunOutput[]): IInitialTestSummaryOutput | undefined {
506
for (const entry of runOutput) {
507
if (entry.type === OutputType.initialTestSummary) {
508
return entry;
509
}
510
}
511
return undefined;
512
}
513
514