xref: /expo/tools/src/TasksRunner.ts (revision 040cc41c)
1import fs from 'fs-extra';
2import chalk from 'chalk';
3import JsonFile, { JSONObject } from '@expo/json-file';
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  // Descriptor properties
97  readonly tasks: Task<Args>[];
98
99  readonly backupFilePath: string | null = null;
100
101  readonly backupExpirationTime: number = 60 * 60 * 1000;
102
103  readonly validateBackup: (
104    backup: TasksRunnerBackup<BackupDataType>
105  ) => Promiseable<boolean> = () => true;
106
107  readonly shouldUseBackup: (
108    backup: TasksRunnerBackup<BackupDataType>
109  ) => Promiseable<boolean> = () => true;
110
111  readonly restoreBackup: (
112    backup: TasksRunnerBackup<BackupDataType>,
113    ...args: Args
114  ) => Promiseable<void> = () => {};
115
116  readonly createBackupData: (task, ...args: Args) => BackupDataType | null = () => null;
117
118  readonly backupValidationFailed?: (backup) => void;
119
120  readonly taskSucceeded?: (task: Task<Args>) => any;
121
122  readonly taskFailed?: (task: Task<Args>, error: Error) => any;
123
124  readonly resolvedTasks: Task<Args>[];
125
126  constructor(descriptor: TaskRunnerDescriptor<Args, BackupDataType>) {
127    const { tasks, ...rest } = descriptor;
128
129    this.tasks = ([] as Task<Args>[]).concat(tasks);
130    this.resolvedTasks = resolveTasksList(this.tasks);
131
132    Object.assign(this, rest);
133  }
134
135  /**
136   * Resolves to a boolean value determining whether the backup file exists.
137   */
138  async backupExistsAsync(): Promise<boolean> {
139    if (!this.backupFilePath) {
140      return false;
141    }
142    try {
143      await fs.access(this.backupFilePath, fs.constants.R_OK);
144      return true;
145    } catch (error) {
146      return false;
147    }
148  }
149
150  /**
151   * Returns action's backup if it exists and is still valid, `null` otherwise.
152   */
153  async getBackupAsync(): Promise<TasksRunnerBackup<BackupDataType> | null> {
154    if (!this.backupFilePath || !(await this.backupExistsAsync())) {
155      return null;
156    }
157    const backup = await JsonFile.readAsync<TasksRunnerBackup<BackupDataType>>(this.backupFilePath);
158
159    if (!(await this.isBackupValid(backup))) {
160      await this.backupValidationFailed?.(backup);
161      return null;
162    }
163    return !this.shouldUseBackup || (await this.shouldUseBackup(backup)) ? backup : null;
164  }
165
166  /**
167   * Validates backup compatibility with options passed to the command.
168   */
169  async isBackupValid(backup: TasksRunnerBackup<BackupDataType>): Promise<boolean> {
170    const tasksComparator = (a, b) => a === b.name;
171
172    if (
173      Date.now() - backup.timestamp < this.backupExpirationTime &&
174      arraysCompare(backup.resolvedTasks, this.resolvedTasks, tasksComparator) &&
175      arraysCompare(backup.tasks, this.tasks, tasksComparator)
176    ) {
177      return (await this.validateBackup?.(backup)) ?? true;
178    }
179    return false;
180  }
181
182  /**
183   * Saves backup of tasks state.
184   */
185  async saveBackup(task: Task<Args>, ...args: Args) {
186    if (!this.backupFilePath) {
187      return;
188    }
189
190    const data = await this.createBackupData(task, ...args);
191    const backup: TasksRunnerBackup<BackupDataType> = {
192      timestamp: Date.now(),
193      tasks: this.tasks.map((task) => task.name),
194      resolvedTasks: this.resolvedTasks.map((task) => task.name),
195      lastTask: task.name,
196      data,
197    };
198    await fs.outputFile(this.backupFilePath, JSON.stringify(backup, null, 2));
199  }
200
201  /**
202   * Removes backup file if specified. Must be synchronous.
203   */
204  invalidateBackup() {
205    if (this.backupFilePath) {
206      fs.removeSync(this.backupFilePath);
207    }
208  }
209
210  /**
211   * Restores backup if possible and executes tasks until they stop, throw or finish. Re-throws task errors.
212   */
213  async runAsync(...args: Args): Promise<Args> {
214    const backup = await this.getBackupAsync();
215    const startingIndex = backup
216      ? this.resolvedTasks.findIndex((task) => task.name === backup.lastTask) + 1
217      : 0;
218
219    if (backup) {
220      await this.restoreBackup(backup, ...args);
221    }
222
223    // Filter tasks to run: required ones and all those after last backup.
224    const tasks = this.resolvedTasks.filter((task, taskIndex) => {
225      return task.required || taskIndex >= startingIndex;
226    });
227
228    let nextArgs: Args = args;
229
230    for (const task of tasks) {
231      try {
232        const result = await task.taskFunction?.(...nextArgs);
233
234        // The task has stopped further tasks execution.
235        if (result === Task.STOP) {
236          break;
237        }
238        if (Array.isArray(result)) {
239          nextArgs = result;
240        }
241
242        // Stage declared files in local repository. This is also a part of the backup.
243        await Git.addFilesAsync(task.filesToStage);
244      } catch (error) {
245        // Discard unstaged changes in declared files.
246        await Git.discardFilesAsync(task.filesToStage);
247
248        this.taskFailed?.(task, error);
249        throw new TaskError<Task<Args>>(task, error);
250      }
251
252      this.taskSucceeded?.(task);
253
254      if (task.backupable) {
255        // Make a backup after each successful backupable task.
256        await this.saveBackup(task, ...args);
257      }
258    }
259
260    // If we reach here - we're done and backup should be invalidated.
261    this.invalidateBackup();
262
263    return nextArgs;
264  }
265
266  /**
267   * Same as `runAsync` but handles caught errors and calls `process.exit`.
268   */
269  async runAndExitAsync(...args: Args): Promise<void> {
270    try {
271      await this.runAsync(...args);
272      process.exit(0);
273    } catch (error) {
274      logger.error();
275
276      if (error instanceof TaskError) {
277        logger.error(`�� Execution failed for task ${chalk.cyan(error.task.name)}.`);
278      }
279
280      logger.error('�� Error:', error.message);
281
282      if (error.stack) {
283        const stack = error.stack.split(`${error.message}\n`);
284        logger.debug(stack[1]);
285      }
286
287      error.stderr && logger.error('�� stderr output:\n', chalk.reset(error.stderr));
288      process.exit(1);
289    }
290  }
291}
292
293export class Task<Args extends any[] = []> implements TaskDescriptor<Args> {
294  static STOP: symbol = Symbol();
295
296  readonly name: string;
297  readonly dependsOn: Task<Args>[] = [];
298  readonly filesToStage: string[] = [];
299  readonly required: boolean = false;
300  readonly backupable: boolean = true;
301  readonly taskFunction?: TaskFunction<Args>;
302
303  constructor(descriptor: TaskDescriptor<Args> | string, taskFunction?: TaskFunction<Args>) {
304    if (typeof descriptor === 'string') {
305      this.name = descriptor;
306    } else {
307      const { name, dependsOn, filesToStage, required, backupable } = descriptor;
308      this.name = name;
309      this.dependsOn = dependsOn ? ([] as Task<Args>[]).concat(dependsOn) : [];
310      this.filesToStage = filesToStage ? ([] as string[]).concat(filesToStage) : [];
311      this.required = required ?? this.required;
312      this.backupable = backupable ?? this.backupable;
313    }
314    this.taskFunction = taskFunction;
315  }
316}
317
318function resolveTasksList<Args extends any[]>(tasks: Task<Args>[]): Task<Args>[] {
319  const list = new Set<Task<Args>>();
320
321  function iterateThroughDependencies(task: Task<Args>) {
322    for (const dependency of task.dependsOn) {
323      iterateThroughDependencies(dependency);
324    }
325    list.add(task);
326  }
327
328  tasks.forEach((task) => iterateThroughDependencies(task));
329
330  return [...list];
331}
332
333function arraysCompare(arr1, arr2, comparator = (a, b) => a === b): boolean {
334  return arr1.length === arr2.length && arr1.every((item, index) => comparator(item, arr2[index]));
335}
336