feat(scripts): add sorted scripts provider with new runAfter field

This scripts provider provides scripts sorted by priority and taking
into account the new runAfter field for scripts.

Bug: twpowertools:226
Change-Id: I40e39121f5c18a04eeff932c30dc2c4277993bde
diff --git a/src/common/architecture/scripts/FakeScript.ts b/src/common/architecture/scripts/FakeScript.ts
new file mode 100644
index 0000000..8f3f504
--- /dev/null
+++ b/src/common/architecture/scripts/FakeScript.ts
@@ -0,0 +1,12 @@
+import Script, { ConcreteScript } from './Script';
+
+export class FakeScript extends Script {
+  runAfter: ConcreteScript[] = [];
+  priority: number = 2 ** 31;
+  page: never;
+  environment: never;
+  runPhase: never;
+
+  /* istanbul ignore next */
+  execute: () => undefined;
+}
diff --git a/src/common/architecture/scripts/Script.ts b/src/common/architecture/scripts/Script.ts
index 0e33e7e..8a8e9de 100644
--- a/src/common/architecture/scripts/Script.ts
+++ b/src/common/architecture/scripts/Script.ts
@@ -53,6 +53,12 @@
 
 export default abstract class Script {
   /**
+   * Used to indicate that this script should run after the members of the
+   * provided array.
+   */
+  readonly runAfter: ConcreteScript[] = [];
+
+  /**
    * Priority with which the script is executed. Scripts with a lower value are
    * executed first.
    */
diff --git a/src/infrastructure/presentation/scripts/ScriptSorter.adapter.test.ts b/src/infrastructure/presentation/scripts/ScriptSorter.adapter.test.ts
new file mode 100644
index 0000000..8d9eb39
--- /dev/null
+++ b/src/infrastructure/presentation/scripts/ScriptSorter.adapter.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from '@jest/globals';
+import { FakeScript } from '../../../common/architecture/scripts/FakeScript';
+import ScriptSorterAdapter from './ScriptSorter.adapter';
+import Script, {
+  ConcreteScript,
+} from '../../../common/architecture/scripts/Script';
+import ScriptSorterCycleDetectedError from '../../../presentation/scripts/errors/ScriptSorterCycleDetected.error';
+import ScriptSorterRepeatedScriptError from '../../../presentation/scripts/errors/ScriptSorterRepeatedScript.error';
+
+describe('ScriptSorterAdapter', () => {
+  const checkSort = (scripts: Script[], expectedOrder: ConcreteScript[]) => {
+    const sut = new ScriptSorterAdapter();
+    const result = sut.sort(scripts);
+
+    expect(result.map((script) => script.constructor)).toEqual(expectedOrder);
+  };
+
+  describe('Regarding only script priority', () => {
+    it('should sort scripts by priority in ascending order', () => {
+      class A extends FakeScript {
+        priority = 1;
+      }
+      class B extends FakeScript {
+        priority = 1000;
+      }
+      class C extends FakeScript {
+        priority = 0;
+      }
+      class D extends FakeScript {
+        priority = 2 ** 31;
+      }
+
+      const scripts = [new A(), new B(), new C(), new D()];
+      const expectedOrder = [C, A, B, D];
+      checkSort(scripts, expectedOrder);
+    });
+
+    it('should not reorder scripts which have the same priority', () => {
+      class A extends FakeScript {
+        priority = 1;
+        runAfter: ConcreteScript[] = [];
+      }
+      class B extends FakeScript {
+        priority = 10;
+        runAfter: ConcreteScript[] = [];
+      }
+      class C extends FakeScript {
+        priority = 1;
+        runAfter: ConcreteScript[] = [];
+      }
+
+      const scripts = [new A(), new B(), new C()];
+      const expectedOrder = [A, C, B];
+      checkSort(scripts, expectedOrder);
+    });
+  });
+
+  describe('Regarding only runAfter', () => {
+    it('should place scripts which appear in runAfter before the script which includes them, in the order set in runAfter', () => {
+      // Dependency tree (arrows symbolize scripts in `runAfter`):
+      // A
+      // --> B
+      // --> E
+      // C
+      // D
+      // --> F
+      //     --> G
+      //         --> H
+      class A extends FakeScript {
+        runAfter = [B, E];
+      }
+      class B extends FakeScript {}
+      class C extends FakeScript {}
+      class D extends FakeScript {
+        runAfter = [F];
+      }
+      class E extends FakeScript {}
+      class F extends FakeScript {
+        runAfter = [G];
+      }
+      class G extends FakeScript {
+        runAfter = [H];
+      }
+      class H extends FakeScript {}
+
+      const scripts = [
+        new A(),
+        new B(),
+        new C(),
+        new D(),
+        new E(),
+        new F(),
+        new G(),
+        new H(),
+      ];
+      const expectedOrder = [B, E, A, C, H, G, F, D];
+      checkSort(scripts, expectedOrder);
+    });
+
+    it('should not return a script multiple times when it is included in runAfter in multiple scripts', () => {
+      // Dependency tree (arrows symbolize scripts in `runAfter`):
+      // A
+      // --> C
+      // B
+      // --> C
+      class C extends FakeScript {}
+      class A extends FakeScript {
+        runAfter = [C];
+      }
+      class B extends FakeScript {
+        runAfter = [C];
+      }
+
+      const scripts = [new A(), new B(), new C()];
+      const expectedOrder = [C, A, B];
+      checkSort(scripts, expectedOrder);
+    });
+
+    it("should return ScriptSorterCycleDetectedError when there's a cycle", () => {
+      // Dependency tree (arrows symbolize scripts in `runAfter`):
+      // A
+      // --> B
+      //     --> A
+      //         --> ...
+      class A extends FakeScript {
+        runAfter = [B];
+      }
+      class B extends FakeScript {
+        runAfter = [A];
+      }
+
+      const scripts = [new A(), new B()];
+
+      const sut = new ScriptSorterAdapter();
+      expect(() => sut.sort(scripts)).toThrow(ScriptSorterCycleDetectedError);
+    });
+  });
+
+  describe('Combining priority and runAfter configurations', () => {
+    it('should order by priority first and then alter this order when a script needs to run after other scripts which, according to their priority, should have been ran later', () => {
+      class A extends FakeScript {
+        priority = 1;
+        runAfter = [B];
+      }
+      class B extends FakeScript {
+        priority = 2;
+      }
+      class C extends FakeScript {
+        priority = 0;
+      }
+
+      const scripts = [new A(), new B(), new C()];
+      const expectedOrder = [C, B, A];
+      checkSort(scripts, expectedOrder);
+    });
+  });
+
+  describe('Regarding edge cases', () => {
+    it('should not throw an error when there are multiple script instances from the same class', () => {
+      class A extends FakeScript {}
+      const scripts = [new A(), new A()];
+
+      const sut = new ScriptSorterAdapter();
+      expect(() => sut.sort(scripts)).not.toThrow();
+    });
+
+    it('should throw an error when a script instance is repeated', () => {
+      class A extends FakeScript {}
+      const script = new A();
+      const scripts = [script, script];
+
+      const sut = new ScriptSorterAdapter();
+      expect(() => sut.sort(scripts)).toThrow(ScriptSorterRepeatedScriptError);
+    });
+  });
+});
diff --git a/src/infrastructure/presentation/scripts/ScriptSorter.adapter.ts b/src/infrastructure/presentation/scripts/ScriptSorter.adapter.ts
new file mode 100644
index 0000000..f34426a
--- /dev/null
+++ b/src/infrastructure/presentation/scripts/ScriptSorter.adapter.ts
@@ -0,0 +1,106 @@
+import Script from '../../../common/architecture/scripts/Script';
+import ScriptSorterCycleDetectedError from '../../../presentation/scripts/errors/ScriptSorterCycleDetected.error';
+import ScriptSorterRepeatedScriptError from '../../../presentation/scripts/errors/ScriptSorterRepeatedScript.error';
+import { ScriptSorterPort } from '../../../presentation/scripts/ScriptSorter.port';
+
+export default class ScriptSorterAdapter implements ScriptSorterPort {
+  /**
+   * Sorts scripts based on the `runAfter` and `priority` fields.
+   *
+   * It initially sorts scripts based on the `priority` field, and then looks at
+   * script dependencies set in the `runAfter` field. If there are any, it places
+   * them before the including script (if they didn't appear before) in the order
+   * they appear in the `runAfter` field.
+   *
+   * Take a look at ScriptSorter.adapter.test.ts for the full spec of how the
+   * scripts are sorted.
+   */
+  sort(scripts: Script[]): Script[] {
+    scripts = this.sortByPriority(scripts);
+
+    const childrenMap = this.getChildrenMap(scripts);
+    if (hasCycles(childrenMap)) {
+      throw new ScriptSorterCycleDetectedError();
+    }
+
+    const sortedScripts: Script[] = [];
+    for (const script of scripts) {
+      this.traverse(script, childrenMap, sortedScripts);
+    }
+    return sortedScripts;
+  }
+
+  private sortByPriority(scripts: Script[]): Script[] {
+    return scripts.sort((a, b) => {
+      if (a.priority < b.priority) {
+        return -1;
+      } else if (a.priority > b.priority) {
+        return 1;
+      } else {
+        return 0;
+      }
+    });
+  }
+
+  getChildrenMap(scripts: Script[]): Map<Script, Script[]> {
+    const childrenMap = new Map<Script, Script[]>();
+    for (const script of scripts) {
+      if (childrenMap.has(script)) {
+        throw new ScriptSorterRepeatedScriptError();
+      }
+      childrenMap.set(script, this.getChildren(script, scripts));
+    }
+    return childrenMap;
+  }
+
+  private getChildren(script: Script, scripts: Script[]): Script[] {
+    const children: Script[] = [];
+    for (const childConstructor of script.runAfter) {
+      const runAfterScripts = scripts.filter(
+        (it) => it.constructor === childConstructor,
+      );
+      for (const it of runAfterScripts) {
+        if (!children.includes(it)) {
+          children.push(it);
+        }
+      }
+    }
+    return children;
+  }
+
+  private traverse(
+    script: Script,
+    childrenMap: Map<Script, Script[]>,
+    sortedScripts: Script[],
+  ) {
+    if (sortedScripts.includes(script)) return;
+
+    for (const child of childrenMap.get(script)) {
+      this.traverse(child, childrenMap, sortedScripts);
+    }
+    sortedScripts.push(script);
+  }
+}
+
+function hasCycles<T>(childrenMap: Map<T, T[]>): boolean {
+  const visited = new Set<T>();
+  for (const node of childrenMap.keys()) {
+    const hasCycles = dfs(node, visited, childrenMap);
+    if (hasCycles) return true;
+  }
+
+  return false;
+}
+
+function dfs<T>(node: T, visited: Set<T>, childrenMap: Map<T, T[]>) {
+  if (visited.has(node)) return true;
+
+  visited.add(node);
+  for (const child of childrenMap.get(node)) {
+    const hasCycles = dfs(child, visited, childrenMap);
+    if (hasCycles) return true;
+  }
+  visited.delete(node);
+
+  return false;
+}
diff --git a/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.test.ts b/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.test.ts
new file mode 100644
index 0000000..8d41ea9
--- /dev/null
+++ b/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.test.ts
@@ -0,0 +1,17 @@
+import { describe, expect, it } from "@jest/globals";
+import { FakeScript } from "../../../common/architecture/scripts/FakeScript";
+import { SortedScriptsProviderAdapter } from "./SortedScriptsProvider.adapter";
+import ScriptSorterAdapter from "./ScriptSorter.adapter";
+
+describe('SortedScriptsProvider', () => {
+  describe('getScripts', () => {
+    it('should not throw', () => {
+      class A extends FakeScript {}
+      const scripts = [new A()];
+
+      const sut = new SortedScriptsProviderAdapter(scripts, new ScriptSorterAdapter());
+
+      expect(() => sut.getScripts()).not.toThrow();
+    });
+  });
+});
diff --git a/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.ts b/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.ts
new file mode 100644
index 0000000..f93cc1c
--- /dev/null
+++ b/src/infrastructure/presentation/scripts/SortedScriptsProvider.adapter.ts
@@ -0,0 +1,19 @@
+import Script from '../../../common/architecture/scripts/Script';
+import { ScriptProviderPort } from '../../../presentation/scripts/ScriptProvider.port';
+import { ScriptSorterPort } from '../../../presentation/scripts/ScriptSorter.port';
+
+export class SortedScriptsProviderAdapter implements ScriptProviderPort {
+  private sortedScripts: Script[] | undefined;
+
+  constructor(
+    private scripts: Script[],
+    private scriptSorter: ScriptSorterPort,
+  ) {}
+
+  getScripts() {
+    if (this.sortedScripts === undefined) {
+      this.sortedScripts = this.scriptSorter.sort(this.scripts);
+    }
+    return this.sortedScripts;
+  }
+}
diff --git a/src/presentation/scripts/ScriptProvider.port.ts b/src/presentation/scripts/ScriptProvider.port.ts
new file mode 100644
index 0000000..be45215
--- /dev/null
+++ b/src/presentation/scripts/ScriptProvider.port.ts
@@ -0,0 +1,5 @@
+import Script from "../../common/architecture/scripts/Script";
+
+export interface ScriptProviderPort {
+  getScripts(): Script[];
+}
diff --git a/src/presentation/scripts/ScriptSorter.port.ts b/src/presentation/scripts/ScriptSorter.port.ts
new file mode 100644
index 0000000..74db45c
--- /dev/null
+++ b/src/presentation/scripts/ScriptSorter.port.ts
@@ -0,0 +1,5 @@
+import Script from "../../common/architecture/scripts/Script";
+
+export interface ScriptSorterPort {
+  sort(scripts: Script[]): Script[];
+}
diff --git a/src/presentation/scripts/errors/ScriptSorterCycleDetected.error.ts b/src/presentation/scripts/errors/ScriptSorterCycleDetected.error.ts
new file mode 100644
index 0000000..3289541
--- /dev/null
+++ b/src/presentation/scripts/errors/ScriptSorterCycleDetected.error.ts
@@ -0,0 +1,3 @@
+export default class ScriptSorterCycleDetectedError extends Error {
+  name: 'script-sorter-cycle-detected-error';
+}
diff --git a/src/presentation/scripts/errors/ScriptSorterRepeatedScript.error.ts b/src/presentation/scripts/errors/ScriptSorterRepeatedScript.error.ts
new file mode 100644
index 0000000..8e9a219
--- /dev/null
+++ b/src/presentation/scripts/errors/ScriptSorterRepeatedScript.error.ts
@@ -0,0 +1,3 @@
+export default class ScriptSorterRepeatedScriptError extends Error {
+  name: 'script-sorter-repeated-script-error';
+}