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';
+}