Path: blob/main/extensions/copilot/test/simulation/workbench/stores/simulationRunner.ts
13399 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 { ipcRenderer } from 'electron';6import * as fs from 'fs';7import minimist from 'minimist';8import * as mobx from 'mobx';9import * as path from 'path';10import { Result } from '../../../../src/util/common/result';11import { AsyncIterableObject } from '../../../../src/util/vs/base/common/async';12import { CancellationTokenSource } from '../../../../src/util/vs/base/common/cancellation';13import { Disposable } from '../../../../src/util/vs/base/common/lifecycle';14import { IInitialTestSummaryOutput, OutputType, RunOutput, SIMULATION_FOLDER_NAME, STDOUT_FILENAME, generateOutputFolderName } from '../../shared/sharedTypes';15import { spawnSimulationFromMainProcess } from '../utils/simulationExec';16import { ObservablePromise, REPO_ROOT } from '../utils/utils';17import { CacheMode, RunnerOptions } from './runnerOptions';18import { RunnerTestStatus } from './runnerTestStatus';19import { SimulationStorage, SimulationStorageValue } from './simulationStorage';20import { TestRun } from './testRun';2122const SIMULATION_FOLDER_PATH = path.join(REPO_ROOT, SIMULATION_FOLDER_NAME);2324export interface RunConfig {25grep: string;26cacheMode: CacheMode;27n: number;28noFetch: boolean;29additionalArgs: string;30/** NES external scenarios path. When set, `--nes=external` and `--external-scenarios` are added. */31nesExternalScenariosPath?: string;32}3334export const enum StateKind {35Initializing,36Running,37Stopped38}3940type State =41| { kind: StateKind.Initializing }42| { kind: StateKind.Running }43| { kind: StateKind.Stopped };4445/** Constructors for {@link State} discriminated union */46const State = {47Initializing: () => ({ kind: StateKind.Initializing }),48Running: () => ({ kind: StateKind.Running }),49Stopped: () => ({ kind: StateKind.Stopped }),50};5152export class TestRuns {53constructor(54public readonly name: string,55public readonly runs: TestRun[],56public readonly simulationInputPath?: string,57public activeEditorLanguageId?: string,58) { }59}6061class DeserialisedTestRuns {6263constructor(64public readonly name: string,65public readonly expectedRuns: number,66public readonly runs: TestRun[] = []67) { }6869public addRun(run: TestRun) {70this.runs.push(run);71this.runs.sort((a, b) => {72return (a.runNumber ?? 0) - (b.runNumber ?? 0);73});74}75}7677export class SimulationRunner extends Disposable {7879public static async readFromPreviousRun(outputFolderName: string): Promise<TestRuns[]> {80const outputFolder = path.join(SIMULATION_FOLDER_PATH, outputFolderName);81const stdoutFilePath = path.join(outputFolder, STDOUT_FILENAME);82return SimulationRunner.readFromStdoutJSON(stdoutFilePath);83}8485public static async readFromStdoutJSON(stdoutFilePath: string, simulationInputPath?: string): Promise<TestRuns[]> {86const entries = JSON.parse(await fs.promises.readFile(stdoutFilePath, 'utf8')) as RunOutput[];87const testRuns = SimulationRunner.createFromRunOutput(stdoutFilePath, entries);88return testRuns.map(tr => new TestRuns(tr.name, tr.runs));89}9091public static createFromRunOutput(stdoutFilePath: string, runOutput: RunOutput[],): DeserialisedTestRuns[] {92const summaryEntry = findInitialTestSummary(runOutput);93const nRuns = summaryEntry?.nRuns ?? 1;94const allTestRuns = new Map<string, DeserialisedTestRuns>();95for (const entry of runOutput) {96if (entry.type !== OutputType.testRunEnd) {97continue;98}99let testRuns = allTestRuns.get(entry.name);100if (!testRuns) {101testRuns = new DeserialisedTestRuns(entry.name, nRuns);102allTestRuns.set(entry.name, testRuns);103}104105testRuns.addRun(new TestRun(106entry.runNumber,107entry.pass,108entry.explicitScore,109entry.error,110entry.duration,111path.dirname(stdoutFilePath),112entry.writtenFiles,113entry.averageRequestDuration,114entry.requestCount,115entry.hasCacheMiss,116entry.annotations,117));118}119return Array.from(allTestRuns.values());120}121122private _selectedRun: SelectedRun;123private _diskSelectedRun: DiskSelectedRun;124private _simulationExecutor: SimulationExecutor;125126@mobx.computed127public get selectedRun(): string {128return this._selectedRun.name;129}130131@mobx.computed132public get state(): State {133return this._simulationExecutor.state;134}135136@mobx.computed137public get maybeTestStatus(): Result<readonly RunnerTestStatus[], Error> {138return (139this._selectedRun.isFromDisk140? this._diskSelectedRun.testStatus141: this._simulationExecutor.testStatus142);143}144145@mobx.computed146public get testStatus(): readonly RunnerTestStatus[] {147if (this.maybeTestStatus.isOk()) {148return this.maybeTestStatus.val;149}150return [];151}152153@mobx.computed154public get terminationReason(): string | undefined {155return (156this._selectedRun.isFromDisk157? this._diskSelectedRun.terminationReason158: this._simulationExecutor.terminationReason159);160}161162constructor(storage: SimulationStorage, private readonly runnerOptions: RunnerOptions) {163super();164165// TODO: add support for init args (parseInitEventArgs)166this._selectedRun = new SelectedRun(storage);167this._diskSelectedRun = new DiskSelectedRun(this._selectedRun);168this._simulationExecutor = new SimulationExecutor(this._selectedRun);169170mobx.makeObservable(this);171}172173public setSelectedRunFromDisk(name: string) {174mobx.runInAction(() => {175this._selectedRun.set(name, true);176});177}178179public startRunningFromRunnerOptions(): Result<string, 'AlreadyRunning'> {180return this._simulationExecutor.startRunning({181grep: this.runnerOptions.grep.value,182cacheMode: this.runnerOptions.cacheMode.value,183n: parseInt(this.runnerOptions.n.value),184noFetch: this.runnerOptions.noFetch.value,185additionalArgs: this.runnerOptions.additionalArgs.value,186});187}188189public startRunning(runConfig: RunConfig): Result<string, 'AlreadyRunning'> {190return this._simulationExecutor.startRunning(runConfig);191}192193public stopRunning(): void {194this._simulationExecutor.stopRunning();195}196197public async renameRun(oldName: string, newName: string): Promise<boolean> {198if (oldName === '' || newName === '') {199console.log('Cannot rename: old or new name is empty', { oldName, newName });200return false;201}202203const oldPath = path.join(SIMULATION_FOLDER_PATH, oldName);204const newPath = path.join(SIMULATION_FOLDER_PATH, newName);205206try {207// Check if old path exists and new path doesn't208const oldExists = await fs.promises.stat(oldPath).then(() => true).catch(() => false);209const newExists = await fs.promises.stat(newPath).then(() => true).catch(() => false);210211if (!oldExists) {212console.log('Cannot rename: old path does not exist', oldPath);213return false;214}215if (newExists) {216console.log('Cannot rename: new path already exists', newPath);217return false;218}219220// Rename the directory221console.log('Renaming directory from', oldPath, 'to', newPath);222await fs.promises.rename(oldPath, newPath);223console.log('Successfully renamed directory');224225// Update selected run if it was the renamed one226if (this._selectedRun.name === oldName) {227console.log('Updating selected run name from', oldName, 'to', newName);228mobx.runInAction(() => {229this._selectedRun.set(newName, true);230});231}232233return true;234} catch (e) {235console.error('Failed to rename run:', e);236return false;237}238}239}240241class SelectedRun {242private _name: SimulationStorageValue<string>;243244@mobx.observable245public isFromDisk: boolean = true;246247@mobx.computed248public get name(): string {249return this._name.value;250}251252constructor(storage: SimulationStorage) {253// TODO: add support for init args (parseInitEventArgs)254this._name = new SimulationStorageValue(storage, 'selectedRun', '');255256mobx.makeObservable(this);257}258259/**260* Should be called in a MobX action.261*/262set(name: string, isFromDisk: boolean): void {263this._name.value = name;264this.isFromDisk = isFromDisk;265}266}267268class DiskSelectedRun {269270@mobx.computed271public get runOutput(): ObservablePromise<Result<RunOutput[], Error>> {272return new ObservablePromise((async () => {273if (!this._selectedRun.isFromDisk) {274return Result.fromString(`This run is not from disk!`);275}276if (this._selectedRun.name === '') {277return Result.ok([]);278}279const outputFolderPath = path.join(SIMULATION_FOLDER_PATH, this._selectedRun.name);280const stdoutFile = path.join(outputFolderPath, STDOUT_FILENAME);281try {282const stdoutFileContents = await fs.promises.readFile(stdoutFile, 'utf8');283return Result.ok(JSON.parse(stdoutFileContents) as RunOutput[]);284} catch (e) {285return Result.error(e);286}287})(), Result.ok([]));288}289290@mobx.computed291public get testStatus(): Result<readonly RunnerTestStatus[], Error> {292293if (!this._selectedRun.isFromDisk) {294return Result.fromString(`This run is not from disk!`);295}296297if (!this.runOutput.value.isOk()) {298return this.runOutput.value;299}300301const entries = this.runOutput.value.val;302for (const entry of entries) {303if (entry.type === OutputType.terminated) {304return Result.fromString(`Terminated: ${entry.reason}`);305}306}307308const outputFolderPath = path.join(SIMULATION_FOLDER_PATH, this._selectedRun.name);309const stdoutFilePath = path.join(outputFolderPath, STDOUT_FILENAME);310const testRuns = SimulationRunner.createFromRunOutput(stdoutFilePath, entries);311const testStatus = testRuns.map(tr => new RunnerTestStatus(tr.name, tr.expectedRuns, tr.runs));312return Result.ok(testStatus);313}314315@mobx.computed316public get terminationReason(): string | undefined {317if (!this.testStatus.isOk()) {318return this.testStatus.err.stack;319}320return undefined;321}322323constructor(324private readonly _selectedRun: SelectedRun325) {326mobx.makeObservable(this);327}328}329330class SimulationExecutor {331332private currentCancellationTokenSource: CancellationTokenSource | undefined;333334@mobx.observable335public state: State = State.Initializing();336337@mobx.observable338public terminationReason: string | undefined = undefined;339340@mobx.observable341public runningTestStatus: Map<string, RunnerTestStatus> = new Map<string, RunnerTestStatus>();342343/** Tests registered for the current run via `initialTestSummary`. Used to scope incompleteness checks. */344private currentRunTests: Set<string> = new Set();345346@mobx.computed347public get testStatus(): Result<readonly RunnerTestStatus[], Error> {348return Result.ok(Array.from(this.runningTestStatus.values()));349}350351constructor(352private readonly _selectedRun: SelectedRun353) {354mobx.makeObservable(this);355}356357public startRunning(runConfig: RunConfig): Result<string, 'AlreadyRunning'> {358if (this.state.kind === StateKind.Running) {359return Result.error('AlreadyRunning');360}361const isNesExternal = !!runConfig.nesExternalScenariosPath;362const outputFolder = path.join(REPO_ROOT, SIMULATION_FOLDER_NAME, generateOutputFolderName(isNesExternal ? 'external' : undefined));363const stdoutFile = path.join(outputFolder, STDOUT_FILENAME);364365this.currentCancellationTokenSource = new CancellationTokenSource();366mobx.runInAction(() => {367this.state = State.Running();368this.terminationReason = undefined;369this.currentRunTests = new Set();370this._selectedRun.set(path.basename(outputFolder), false);371});372373const args: string[] = ['--json'];374if (runConfig.grep) {375args.push(`--grep=${runConfig.grep}`);376}377switch (runConfig.cacheMode) {378case CacheMode.Disable:379args.push(`--skip-cache`);380break;381case CacheMode.Regenerate:382args.push(`--regenerate-cache`);383break;384case CacheMode.Require:385args.push(`--require-cache`);386break;387}388if (runConfig.n) {389args.push(`--n=${runConfig.n}`);390}391if (runConfig.noFetch) {392args.push(`--no-fetch`);393}394args.push(`--output=${outputFolder}`);395if (runConfig.nesExternalScenariosPath) {396args.push(`--nes=external`);397args.push(`--external-scenarios=${runConfig.nesExternalScenariosPath}`);398}399Object.entries(minimist(runConfig.additionalArgs.split(' '))).filter(([k]) => k !== '_' && k !== '--').forEach(([k, v]) => {400args.push(v !== undefined ? `--${k}=${v}` : `--${k}`);401});402const stream = spawnSimulationFromMainProcess<RunOutput>({ args }, this.currentCancellationTokenSource.token);403this.interpretOutput(stream, stdoutFile);404405return Result.ok(outputFolder);406}407408public stopRunning(): void {409if (this.state.kind !== StateKind.Running) {410return;411}412if (this.currentCancellationTokenSource === undefined) {413console.warn('currentCancellationTokenSource is undefined');414return;415}416try { this.currentCancellationTokenSource!.cancel(); } catch (_) { } // to avoid unhandled promise rejection417mobx.runInAction(() => {418this.state = State.Stopped();419for (const [_, status] of this.runningTestStatus) {420if (status.runs.length < status.expectedRuns) {421status.isCancelled = true;422}423}424});425this.currentCancellationTokenSource = undefined;426}427428private async interpretOutput(stream: AsyncIterableObject<RunOutput>, stdoutFile: string): Promise<void> {429const writtenFilesBaseDir = path.dirname(stdoutFile);430const entries: RunOutput[] = [];431try {432for await (const entry of stream) {433entries.push(entry);434mobx.runInAction(() => this.interpretOutputEntry(writtenFilesBaseDir, entry)); // TODO@ulugbekna: we should batch updates435}436} catch (e) {437console.error('interpretOutput', JSON.stringify(e, null, '\t'));438mobx.runInAction(() => {439const hasIncompleteTests = this.currentRunTests.size === 0 || Array.from(this.currentRunTests).some(440name => {441const status = this.runningTestStatus.get(name);442return !status || status.runs.length < status.expectedRuns;443}444);445if (hasIncompleteTests) {446this.terminationReason = typeof e === 'string' ? e : e instanceof Error ? (e.stack ?? e.message) : String(e);447}448for (const [_, status] of this.runningTestStatus) {449if (status.runs.length < status.expectedRuns) {450status.isCancelled = true;451}452}453});454} finally {455await fs.promises.writeFile(stdoutFile, JSON.stringify(entries, null, '\t'));456this.currentCancellationTokenSource = undefined;457mobx.runInAction(() => {458this.state = State.Stopped();459});460}461}462463/** @remarks MUST be called within `mobx.runInAction` */464private interpretOutputEntry(writtenFilesBaseDir: string, entry: RunOutput): void {465switch (entry.type) {466case OutputType.initialTestSummary:467for (const testName of entry.testsToRun) {468this.currentRunTests.add(testName);469this.runningTestStatus.set(testName, new RunnerTestStatus(testName, entry.nRuns, []));470}471return;472case OutputType.testRunStart:473this.runningTestStatus.get(entry.name)!.isNowRunning++;474return;475case OutputType.testRunEnd:476this.runningTestStatus.get(entry.name)!.isNowRunning--;477this.runningTestStatus.get(entry.name)!.addRun(new TestRun(478entry.runNumber,479entry.pass,480entry.explicitScore,481entry.error,482entry.duration,483writtenFilesBaseDir,484entry.writtenFiles,485entry.averageRequestDuration,486entry.requestCount,487entry.hasCacheMiss,488entry.annotations489));490return;491case OutputType.skippedTest:492this.runningTestStatus.get(entry.name)!.isSkipped = true;493return;494case OutputType.terminated:495this.terminationReason = entry.reason;496return;497case OutputType.deviceCodeCallback:498ipcRenderer.send('open-link', entry.url);499return;500}501}502}503504function findInitialTestSummary(runOutput: RunOutput[]): IInitialTestSummaryOutput | undefined {505for (const entry of runOutput) {506if (entry.type === OutputType.initialTestSummary) {507return entry;508}509}510return undefined;511}512513514