xref: /expo/tools/src/TasksRunner.ts (revision a272999e)
1import JsonFile, { JSONObject } from '@expo/json-file';
2import chalk from 'chalk';
3import fs from 'fs-extra';
4
5import Git from './Git';
6import logger from './Logger';
7
8/**
9 * Descriptor of single task. Defines class members and the main function.
10 */
11export type TaskDescriptor<Args extends any[]> = {
12  /**
13   * Name of the task.
14   */
15  name: string;
16
17  /**
18   * A list of other tasks this task depends on. All these tasks will be executed before this one.
19   */
20  dependsOn?: Task<Args>[] | Task<Args>;
21
22  /**
23   * File paths to stage in the repository.
24   */
25  filesToStage?: string[] | string;
26
27  /**
28   * Task is required and thus will be run even if restored from the backup.
29   */
30  required?: boolean;
31
32  /**
33   * Whether it makes sense to save a backup after this task is completed.
34   */
35  backupable?: boolean;
36};
37
38/**
39 * Handy return type for methods that might be asynchronous.
40 */
41type Promiseable<T> = T | Promise<T>;
42
43/**
44 * An object that is being passed to TaskRunner constructor and provides some customization.
45 */
46export type TaskRunnerDescriptor<Args extends any[], BackupDataType = null> = {
47  tasks: Task<Args>[] | Task<Args>;
48  backupFilePath?: string | null;
49  backupExpirationTime?: number;
50  validateBackup?: (backup) => Promiseable<boolean>;
51  shouldUseBackup?: (backup) => Promiseable<boolean>;
52  restoreBackup?: (backup, ...args: Args) => Promiseable<void>;
53  createBackupData?: (task, ...args: Args) => Promiseable<BackupDataType | null>;
54  backupValidationFailed?: (backup) => void;
55  taskSucceeded?: (task: Task<Args>) => void;
56  taskFailed?: (task: Task<Args>, error: any) => void;
57};
58
59/**
60 * An object that is being stored in the backup file.
61 */
62export type TasksRunnerBackup<DataType extends JSONObject | null = null> = {
63  tasks: string[];
64  resolvedTasks: string[];
65  lastTask: string;
66  timestamp: number;
67  data: DataType | null;
68};
69
70/**
71 * Signature of the function is being executed as part of the task.
72 */
73export type TaskFunction<Args extends any[]> = (...args: Args) => Promise<void | symbol | Args>;
74
75/**
76 * Class of error that might be thrown when running tasks.
77 */
78export class TaskError<TaskType extends { name: string }> extends Error {
79  readonly stderr?: string;
80  readonly stack?: string;
81
82  constructor(readonly task: TaskType, error: Error) {
83    super(error.message);
84    this.stderr = (error as any).stderr;
85    this.stack = error.stack;
86  }
87}
88
89/**
90 * Task runner, as its name suggests, runs given task. One task can depend on other tasks
91 * and the runner makes sure they all are being run. Runner also provides an easy way to
92 * backup and restore tasks' state.
93 */
94export class TaskRunner<Args extends any[], BackupDataType extends JSONObject | null = null>
95  implements TaskRunnerDescriptor<Args, BackupDataType>
96{
97  // Descriptor properties
98  readonly tasks: Task<Args>[];
99
100  readonly backupFilePath: string | null = null;
101
102  readonly backupExpirationTime: number = 60 * 60 * 1000;
103
104  readonly validateBackup: (backup: TasksRunnerBackup<BackupDataType>) => Promiseable<boolean> =
105    () => true;
106
107  readonly shouldUseBackup: (backup: TasksRunnerBackup<BackupDataType>) => Promiseable<boolean> =
108    () => true;
109
110  readonly restoreBackup: (
111    backup: TasksRunnerBackup<BackupDataType>,
112    ...args: Args
113  ) => Promiseable<void> = () => {};
114
115  readonly createBackupData: (task, ...args: Args) => BackupDataType | null = () => null;
116
117  readonly backupValidationFailed?: (backup) => void;
118
119  readonly taskSucceeded?: (task: Task<Args>) => any;
120
121  readonly taskFailed?: (task: Task<Args>, error: Error) => any;
122
123  readonly resolvedTasks: Task<Args>[];
124
125  constructor(descriptor: TaskRunnerDescriptor<Args, BackupDataType>) {
126    const { tasks, ...rest } = descriptor;
127
128    this.tasks = ([] as Task<Args>[]).concat(tasks);
129    this.resolvedTasks = resolveTasksList(this.tasks);
130
131    Object.assign(this, rest);
132  }
133
134  /**
135   * Resolves to a boolean value determining whether the backup file exists.
136   */
137  async backupExistsAsync(): Promise<boolean> {
138    if (!this.backupFilePath) {
139      return false;
140    }
141    try {
142      await fs.access(this.backupFilePath, fs.constants.R_OK);
143      return true;
144    } catch {
145      return false;
146    }
147  }
148
149  /**
150   * Returns action's backup if it exists and is still valid, `null` otherwise.
151   */
152  async getBackupAsync(): Promise<TasksRunnerBackup<BackupDataType> | null> {
153    if (!this.backupFilePath || !(await this.backupExistsAsync())) {
154      return null;
155    }
156    const backup = await JsonFile.readAsync<TasksRunnerBackup<BackupDataType>>(this.backupFilePath);
157
158    if (!(await this.isBackupValid(backup))) {
159      await this.backupValidationFailed?.(backup);
160      return null;
161    }
162    return !this.shouldUseBackup || (await this.shouldUseBackup(backup)) ? backup : null;
163  }
164
165  /**
166   * Validates backup compatibility with options passed to the command.
167   */
168  async isBackupValid(backup: TasksRunnerBackup<BackupDataType>): Promise<boolean> {
169    const tasksComparator = (a, b) => a === b.name;
170
171    if (
172      Date.now() - backup.timestamp < this.backupExpirationTime &&
173      arraysCompare(backup.resolvedTasks, this.resolvedTasks, tasksComparator) &&
174      arraysCompare(backup.tasks, this.tasks, tasksComparator)
175    ) {
176      return (await this.validateBackup?.(backup)) ?? true;
177    }
178    return false;
179  }
180
181  /**
182   * Saves backup of tasks state.
183   */
184  async saveBackup(task: Task<Args>, ...args: Args) {
185    if (!this.backupFilePath) {
186      return;
187    }
188
189    const data = await this.createBackupData(task, ...args);
190    const backup: TasksRunnerBackup<BackupDataType> = {
191      timestamp: Date.now(),
192      tasks: this.tasks.map((task) => task.name),
193      resolvedTasks: this.resolvedTasks.map((task) => task.name),
194      lastTask: task.name,
195      data,
196    };
197    await fs.outputFile(this.backupFilePath, JSON.stringify(backup, null, 2));
198  }
199
200  /**
201   * Removes backup file if specified. Must be synchronous.
202   */
203  invalidateBackup() {
204    if (this.backupFilePath) {
205      fs.removeSync(this.backupFilePath);
206    }
207  }
208
209  /**
210   * Restores backup if possible and executes tasks until they stop, throw or finish. Re-throws task errors.
211   */
212  async runAsync(...args: Args): Promise<Args> {
213    const backup = await this.getBackupAsync();
214    const startingIndex = backup
215      ? this.resolvedTasks.findIndex((task) => task.name === backup.lastTask) + 1
216      : 0;
217
218    if (backup) {
219      await this.restoreBackup(backup, ...args);
220    }
221
222    // Filter tasks to run: required ones and all those after last backup.
223    const tasks = this.resolvedTasks.filter((task, taskIndex) => {
224      return task.required || taskIndex >= startingIndex;
225    });
226
227    let nextArgs: Args = args;
228
229    for (const task of tasks) {
230      try {
231        const result = await task.taskFunction?.(...nextArgs);
232
233        // The task has stopped further tasks execution.
234        if (result === Task.STOP) {
235          break;
236        }
237        if (Array.isArray(result)) {
238          nextArgs = result;
239        }
240
241        // Stage declared files in local repository. This is also a part of the backup.
242        await Git.addFilesAsync(task.filesToStage);
243      } catch (error) {
244        // Discard unstaged changes in declared files.
245        await Git.discardFilesAsync(task.filesToStage);
246
247        this.taskFailed?.(task, error);
248        throw new TaskError<Task<Args>>(task, error);
249      }
250
251      this.taskSucceeded?.(task);
252
253      if (task.backupable) {
254        // Make a backup after each successful backupable task.
255        await this.saveBackup(task, ...args);
256      }
257    }
258
259    // If we reach here - we're done and backup should be invalidated.
260    this.invalidateBackup();
261
262    return nextArgs;
263  }
264
265  /**
266   * Same as `runAsync` but handles caught errors and calls `process.exit`.
267   */
268  async runAndExitAsync(...args: Args): Promise<void> {
269    try {
270      await this.runAsync(...args);
271      process.exit(0);
272    } catch (error) {
273      logger.error();
274
275      if (error instanceof TaskError) {
276        logger.error(`�� Execution failed for task ${chalk.cyan(error.task.name)}.`);
277      }
278
279      logger.error('�� Error:', error.message);
280
281      if (error.stack) {
282        const stack = error.stack.split(`${error.message}\n`);
283        logger.debug(stack[1]);
284      }
285
286      error.stderr && logger.error('�� stderr output:\n', chalk.reset(error.stderr));
287      process.exit(1);
288    }
289  }
290}
291
292export class Task<Args extends any[] = []> implements TaskDescriptor<Args> {
293  static STOP: symbol = Symbol();
294
295  readonly name: string;
296  readonly dependsOn: Task<Args>[] = [];
297  readonly filesToStage: string[] = [];
298  readonly required: boolean = false;
299  readonly backupable: boolean = true;
300  readonly taskFunction?: TaskFunction<Args>;
301
302  constructor(descriptor: TaskDescriptor<Args> | string, taskFunction?: TaskFunction<Args>) {
303    if (typeof descriptor === 'string') {
304      this.name = descriptor;
305    } else {
306      const { name, dependsOn, filesToStage, required, backupable } = descriptor;
307      this.name = name;
308      this.dependsOn = dependsOn ? ([] as Task<Args>[]).concat(dependsOn) : [];
309      this.filesToStage = filesToStage ? ([] as string[]).concat(filesToStage) : [];
310      this.required = required ?? this.required;
311      this.backupable = backupable ?? this.backupable;
312    }
313    this.taskFunction = taskFunction;
314  }
315}
316
317function resolveTasksList<Args extends any[]>(tasks: Task<Args>[]): Task<Args>[] {
318  const list = new Set<Task<Args>>();
319
320  function iterateThroughDependencies(task: Task<Args>) {
321    for (const dependency of task.dependsOn) {
322      iterateThroughDependencies(dependency);
323    }
324    list.add(task);
325  }
326
327  tasks.forEach((task) => iterateThroughDependencies(task));
328
329  return [...list];
330}
331
332function arraysCompare(arr1, arr2, comparator = (a, b) => a === b): boolean {
333  return arr1.length === arr2.length && arr1.every((item, index) => comparator(item, arr2[index]));
334}
335