Skip to content

Commit

Permalink
Merge a60102f into ab4e5c6
Browse files Browse the repository at this point in the history
  • Loading branch information
connectdotz committed Apr 4, 2024
2 parents ab4e5c6 + a60102f commit 5efae9e
Show file tree
Hide file tree
Showing 15 changed files with 582 additions and 204 deletions.
7 changes: 2 additions & 5 deletions src/JestExt/process-listeners.ts
@@ -1,7 +1,7 @@
import * as vscode from 'vscode';
import { JestTotalResults, RunnerEvent } from 'jest-editor-support';
import { cleanAnsi, toErrorString } from '../helpers';
import { JestProcess } from '../JestProcessManagement';
import { JestProcess, ProcessStatus } from '../JestProcessManagement';
import { ListenerSession, ListTestFilesCallback } from './process-session';
import { Logging } from '../logging';
import { JestRunEvent } from './types';
Expand Down Expand Up @@ -279,10 +279,7 @@ export class RunTestListener extends AbstractProcessListener {

// watch process should not exit unless we request it to be closed
private handleWatchProcessCrash(process: JestProcess): string | undefined {
if (
(process.request.type === 'watch-tests' || process.request.type === 'watch-all-tests') &&
process.stopReason !== 'on-demand'
) {
if (process.isWatchMode && process.status !== ProcessStatus.Cancelled) {
const msg = `Jest process "${process.request.type}" ended unexpectedly`;
this.logging('warn', msg);

Expand Down
64 changes: 50 additions & 14 deletions src/JestProcessManagement/JestProcess.ts
Expand Up @@ -4,7 +4,7 @@ import { Runner, RunnerEvent, Options } from 'jest-editor-support';
import { JestExtContext, WatchMode } from '../JestExt/types';
import { extensionId } from '../appGlobals';
import { Logging } from '../logging';
import { JestProcessInfo, JestProcessRequest, UserDataType } from './types';
import { JestProcessInfo, JestProcessRequest, ProcessStatus, UserDataType } from './types';
import { requestString } from './helper';
import { toFilePath, removeSurroundingQuote, escapeRegExp, shellQuote } from '../helpers';

Expand All @@ -23,20 +23,18 @@ interface RunnerTask {
reject: (reason: unknown) => unknown;
runner: Runner;
}
export type StopReason = 'on-demand' | 'process-end';

let SEQ = 0;

export class JestProcess implements JestProcessInfo {
static readonly stopHangTimeout = 500;

private task?: RunnerTask;
private extContext: JestExtContext;
private logging: Logging;
private _stopReason?: StopReason;
public readonly id: string;
private desc: string;
public readonly request: JestProcessRequest;
public _status: ProcessStatus;
private autoStopTimer?: NodeJS.Timeout;

constructor(
extContext: JestExtContext,
Expand All @@ -48,10 +46,11 @@ export class JestProcess implements JestProcessInfo {
this.logging = extContext.loggingFactory.create(`JestProcess ${request.type}`);
this.id = `${request.type}-${SEQ++}`;
this.desc = `id: ${this.id}, request: ${requestString(request)}`;
this._status = ProcessStatus.Pending;
}

public get stopReason(): StopReason | undefined {
return this._stopReason;
public get status(): ProcessStatus {
return this._status;
}

private get watchMode(): WatchMode {
Expand All @@ -64,15 +63,39 @@ export class JestProcess implements JestProcessInfo {
return WatchMode.None;
}

public get isWatchMode(): boolean {
return this.watchMode !== WatchMode.None;
}

public toString(): string {
return `JestProcess: ${this.desc}; stopReason: ${this.stopReason}`;
return `JestProcess: ${this.desc}; status: "${this.status}"`;
}
public start(): Promise<void> {
this._stopReason = undefined;
return this.startRunner();

/**
* To prevent zombie process, this method will automatically stops the Jest process if it is running for too long. The process will be marked as "Cancelled" and stopped.
* Warning: This should only be called when you are certain the process should end soon, for example a non-watch mode process should end after the test results have been processed.
* @param delay The delay in milliseconds after which the process will be considered hung and stopped. Default is 30000 milliseconds (30 seconds ).
*/
public autoStop(delay = 30000, onStop?: (process: JestProcessInfo) => void): void {
if (this.status === ProcessStatus.Running) {
if (this.autoStopTimer) {
clearTimeout(this.autoStopTimer);
}
this.autoStopTimer = setTimeout(() => {
if (this.status === ProcessStatus.Running) {
console.warn(
`Jest Process "${this.id}": will be force closed due to the autoStop Timer (${delay} msec) `
);
this.stop();
onStop?.(this);
}
}, delay);
}
}

public stop(): Promise<void> {
this._stopReason = 'on-demand';
this._status = ProcessStatus.Cancelled;

if (!this.task) {
this.logging('debug', 'nothing to stop, no pending runner/promise');
this.taskDone();
Expand All @@ -99,12 +122,19 @@ export class JestProcess implements JestProcessInfo {
return `"${removeSurroundingQuote(aString)}"`;
}

private startRunner(): Promise<void> {
public start(): Promise<void> {
if (this.status === ProcessStatus.Cancelled) {
this.logging('warn', `the runner task has been cancelled!`);
return Promise.resolve();
}

if (this.task) {
this.logging('warn', `the runner task has already started!`);
return this.task.promise;
}

this._status = ProcessStatus.Running;

const options: Options = {
noColor: false,
reporters: ['default', `"${this.getReporterPath()}"`],
Expand Down Expand Up @@ -196,7 +226,13 @@ export class JestProcess implements JestProcessInfo {
if (event === 'processClose' || event === 'processExit') {
this.task?.resolve();
this.task = undefined;
this._stopReason = this._stopReason ?? 'process-end';

clearTimeout(this.autoStopTimer);
this.autoStopTimer = undefined;

if (this._status !== ProcessStatus.Cancelled) {
this._status = ProcessStatus.Done;
}
}
this.request.listener.onEvent(this, event, ...args);
}
Expand Down
11 changes: 7 additions & 4 deletions src/JestProcessManagement/JestProcessManager.ts
Expand Up @@ -6,6 +6,7 @@ import {
Task,
JestProcessInfo,
UserDataType,
ProcessStatus,
} from './types';
import { Logging } from '../logging';
import { createTaskQueue, TaskQueue } from './task-queue';
Expand Down Expand Up @@ -78,11 +79,13 @@ export class JestProcessManager implements TaskArrayFunctions<JestProcess> {
return;
}
const process = task.data;

try {
const promise = process.start();
this.extContext.onRunEvent.fire({ type: 'process-start', process });
await promise;
// process could be cancelled before it starts, so check before starting
if (process.status === ProcessStatus.Pending) {
const promise = process.start();
this.extContext.onRunEvent.fire({ type: 'process-start', process });
await promise;
}
} catch (e) {
this.logging('error', `${queue.name}: process failed to start:`, process, e);
this.extContext.onRunEvent.fire({
Expand Down
13 changes: 13 additions & 0 deletions src/JestProcessManagement/types.ts
Expand Up @@ -15,12 +15,25 @@ export interface UserDataType {
testError?: boolean;
testItem?: vscode.TestItem;
}
export enum ProcessStatus {
Pending = 'pending',
Running = 'running',
Cancelled = 'cancelled',
// process exited not because of cancellation
Done = 'done',
}

export interface JestProcessInfo {
readonly id: string;
readonly request: JestProcessRequest;
// user data is a way to store data that is outside of the process managed by the processManager.
// subsequent use of this data is up to the user but should be aware that multiple components might contribute to this data.
userData?: UserDataType;
stop: () => Promise<void>;
status: ProcessStatus;
isWatchMode: boolean;
// starting a timer to automatically kill the process after x milliseconds if the process is still running.
autoStop: (delay?: number, onStop?: (process: JestProcessInfo) => void) => void;
}

export type TaskStatus = 'running' | 'pending';
Expand Down
83 changes: 59 additions & 24 deletions src/test-provider/jest-test-run.ts
@@ -1,19 +1,24 @@
import * as vscode from 'vscode';
import { JestExtOutput, JestOutputTerminal, OutputOptions } from '../JestExt/output-terminal';
import { JestTestProviderContext } from './test-provider-context';
import { JestProcessInfo } from '../JestProcessManagement';

export type TestRunProtocol = Pick<
vscode.TestRun,
'name' | 'enqueued' | 'started' | 'errored' | 'failed' | 'passed' | 'skipped' | 'end'
>;

export type CreateTestRun = (request: vscode.TestRunRequest, name: string) => vscode.TestRun;
export type EndProcessOption = { pid: string; delay?: number; reason?: string };
export type EndProcessOption = { process: JestProcessInfo; delay?: number; reason?: string };
export type EndOption = EndProcessOption | { reason: string };
const isEndProcessOption = (arg?: EndOption): arg is EndProcessOption =>
arg != null && 'pid' in arg;
arg != null && 'process' in arg;
let SEQ = 0;

interface ProcessInfo {
process: JestProcessInfo;
timeoutId?: NodeJS.Timeout;
}
/**
* A wrapper class for vscode.TestRun to support
* 1. JIT creation of TestRun
Expand All @@ -23,11 +28,12 @@ let SEQ = 0;
export class JestTestRun implements JestExtOutput, TestRunProtocol {
private output: JestOutputTerminal;
private _run?: vscode.TestRun;
private processes: Map<string, NodeJS.Timeout | undefined>;
private processes: Map<string, ProcessInfo>;
private verbose: boolean;
private runCount = 0;
public readonly name: string;
private ignoreSkipped = false;
private isCancelled = false;

constructor(
name: string,
Expand All @@ -50,16 +56,16 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {
return !this._run;
}

public addProcess(pid: string): void {
if (!this.processes.has(pid)) {
this.processes.set(pid, undefined);
public addProcess(process: JestProcessInfo): void {
if (!this.processes.has(process.id)) {
this.processes.set(process.id, { process });
}
}
/**
* returns the underlying vscode.TestRun, if no run then create one.
**/
private vscodeRun(): vscode.TestRun {
if (!this._run) {
private vscodeRun(): vscode.TestRun | undefined {
if (!this._run && !this.isCancelled) {
const runName = `${this.name} (${this.runCount++})`;

this._run = this.createRun(this.request, runName);
Expand All @@ -78,33 +84,33 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {

// TestRunProtocol
public enqueued = (test: vscode.TestItem): void => {
this.vscodeRun().enqueued(test);
this.vscodeRun()?.enqueued(test);
};
public started = (test: vscode.TestItem): void => {
this.vscodeRun().started(test);
this.vscodeRun()?.started(test);
};
public errored = (
test: vscode.TestItem,
message: vscode.TestMessage | readonly vscode.TestMessage[],
duration?: number | undefined
): void => {
const _msg = this.context.ext.settings.runMode.config.showInlineError ? message : [];
this.vscodeRun().errored(test, _msg, duration);
this.vscodeRun()?.errored(test, _msg, duration);
};
public failed = (
test: vscode.TestItem,
message: vscode.TestMessage | readonly vscode.TestMessage[],
duration?: number | undefined
): void => {
const _msg = this.context.ext.settings.runMode.config.showInlineError ? message : [];
this.vscodeRun().failed(test, _msg, duration);
this.vscodeRun()?.failed(test, _msg, duration);
};
public passed = (test: vscode.TestItem, duration?: number | undefined): void => {
this.vscodeRun().passed(test, duration);
this.vscodeRun()?.passed(test, duration);
};
public skipped = (test: vscode.TestItem): void => {
if (!this.ignoreSkipped) {
this.vscodeRun().skipped(test);
this.vscodeRun()?.skipped(test);
}
};
public end = (options?: EndOption): void => {
Expand All @@ -113,10 +119,11 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {
}
const runName = this._run.name;
if (isEndProcessOption(options)) {
const { pid, delay, reason } = options;
let timeoutId = this.processes.get(pid);
if (timeoutId) {
clearTimeout(timeoutId);
const { process, delay, reason } = options;
const pid = process.id;
const pInfo = this.processes.get(pid);
if (pInfo?.timeoutId) {
clearTimeout(pInfo?.timeoutId);
}

if (!delay) {
Expand All @@ -125,7 +132,7 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {
console.log(`JestTestRun "${runName}": process "${pid}" ended because: ${reason}`);
}
} else {
timeoutId = setTimeout(() => {
const timeoutId = setTimeout(() => {
if (this.verbose) {
console.log(
`JestTestRun "${runName}": process "${pid}" ended after ${delay} msec delay because: ${reason}`
Expand All @@ -136,7 +143,7 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {
reason: `last process "${pid}" ended by ${reason}`,
});
}, delay);
this.processes.set(pid, timeoutId);
this.processes.set(pid, { process, timeoutId });
if (this.verbose) {
console.log(
`JestTestRun "${runName}": starting a ${delay} msec timer to end process "${pid}" because: ${reason}`
Expand All @@ -148,14 +155,42 @@ export class JestTestRun implements JestExtOutput, TestRunProtocol {
if (this.processes.size > 0) {
return;
}
this._run.end();
this._run = undefined;
this.endVscodeRun(options?.reason ?? 'all processes are done');
};

endVscodeRun(reason: string): void {
/* istanbul ignore next */
if (!this._run) {
return;
}
if (this.verbose) {
console.log(`JestTestRun "${runName}": TestRun ended because: ${options?.reason}.`);
console.log(`JestTestRun "${this._run.name}": TestRun ended because: ${reason}.`);
}
};
this._run.end();
this._run = undefined;
}

// set request for next time the underlying run needed to be created
updateRequest(request: vscode.TestRunRequest): void {
this.request = request;
}
cancel(): void {
if (!this._run) {
return;
}
this.write(`\r\nTestRun "${this._run.name}" cancelled\r\n`, 'warn');

// close all processes and timer associated with this testRun
for (const p of this.processes.values()) {
p.process.stop();
console.log(`process ${p.process.id} stopped because of user cancellation`);

if (p.timeoutId) {
clearTimeout(p.timeoutId);
}
}
this.processes.clear();
this.isCancelled = true;
this.endVscodeRun('user cancellation');
}
}

0 comments on commit 5efae9e

Please sign in to comment.