Repository: exadel-inc/esl
Pull request #2279 (2024-03-12 16:58:32)

add the ability to use additional attributes in loadScript utility
Closes: #2278 



Patch:
diff --git a/src/modules/esl-utils/dom/script.ts b/src/modules/esl-utils/dom/script.ts
index 43e77f275..bcafa727e 100644
--- a/src/modules/esl-utils/dom/script.ts
+++ b/src/modules/esl-utils/dom/script.ts
@@ -1,11 +1,20 @@
+import {setAttr} from './attr';
+
+export interface LoadScriptAttributes {
+  crossorigin?: boolean | 'anonymous' | 'use-credentials' | '';
+  integrity?: string;
+  referrerpolicy?: ReferrerPolicy;
+}
+
 /**
- * Creates new async script tag by id and src
+ * Creates new script element with specified attributes
+ * Note: as the script will be added dynamically, it will be treated as async by the browser
  */
-const createAsyncScript = (id: string, src: string): HTMLScriptElement => {
+const createScript = (id: string, src: string, attrs: LoadScriptAttributes): HTMLScriptElement => {
   const script = document.createElement('script');
   script.id = id;
-  script.async = true;
   script.src = src;
+  Object.entries(attrs).forEach(([name, value]) => setAttr(script, name, value));
   return script;
 };
 
@@ -13,11 +22,12 @@ const createAsyncScript = (id: string, src: string): HTMLScriptElement => {
  * Common function that loads script async
  * @param id - unique script id that is used as a marker to prevent future load
  * @param src - script src (url) to load
+ * @param attrs - additional attributes to set on the script tag
  */
-export function loadScript(id: string, src: string): Promise<Event> {
+export function loadScript(id: string, src: string, attrs: LoadScriptAttributes = {}): Promise<Event> {
   return new Promise((resolve, reject) => {
     const script: HTMLScriptElement =
-      (document.getElementById(id) || createAsyncScript(id, src)) as HTMLScriptElement;
+      (document.getElementById(id) || createScript(id, src, attrs)) as HTMLScriptElement;
     const state = script.getAttribute('state');
 
     switch (state) {


Test patch:
diff --git a/src/modules/esl-utils/dom/test/script.test.ts b/src/modules/esl-utils/dom/test/script.test.ts
new file mode 100644
index 000000000..a8439c8d2
--- /dev/null
+++ b/src/modules/esl-utils/dom/test/script.test.ts
@@ -0,0 +1,105 @@
+import {loadScript} from '../script';
+
+describe('loadScript', () => {
+  beforeEach(() => {
+    document.title = 'loadScript test page';
+  });
+  afterEach(() => {
+    document.head.innerHTML = '';
+  });
+
+  test('should create a script element with a specified id in the head', () => {
+    loadScript('test-script', 'http://example.com');
+    const script = document.querySelector('head > script#test-script');
+    expect(script).toBeInstanceOf(HTMLScriptElement);
+  });
+  test('should create a script element with a specified URL as src', () => {
+    loadScript('test-script', 'http://example.org');
+    const script = document.querySelector('head > script#test-script') as HTMLScriptElement;
+    expect(script && script.getAttribute('src')).toBe('http://example.org');
+  });
+  test('should use the existing script element for the same content instead of creating a new one', () => {
+    loadScript('test-script', 'http://example.com');
+    loadScript('test-script', 'http://example.com');
+    loadScript('test-script', 'http://example.com');
+    const scripts = document.querySelectorAll('head > script[src="http://example.com"]');
+    expect(scripts).toHaveLength(1);
+  });
+
+  describe('should create attributes on the script element according to the passed list', () => {
+    test('should create an attribute with value when passed string value', () => {
+      loadScript('test', 'http://example.com', {crossorigin: 'anonymous'});
+      const script = document.querySelector('head > script#test') as HTMLScriptElement;
+      expect(script && script.getAttribute('crossorigin')).toBe('anonymous');
+    });
+    test('should create an attribute without value when passed a true boolean value', () => {
+      loadScript('test', 'http://example.com', {crossorigin: true});
+      const script = document.querySelector('head > script#test') as HTMLScriptElement;
+      expect(script && script.getAttribute('crossorigin')).toBe('');
+    });
+    test('should not create an attribute when passed a false boolean value', () => {
+      loadScript('test', 'http://example.com', {crossorigin: false});
+      const script = document.querySelector('head > script#test') as HTMLScriptElement;
+      expect(script && script.getAttribute('crossorigin')).toBe(null);
+    });
+    test('should have all attributes specified by the list', () => {
+      loadScript('test', 'http://example.com', {
+        crossorigin: true,
+        integrity: 'sha384-abcde...',
+        referrerpolicy: 'no-referrer'
+      });
+      const script = document.querySelector('head > script#test') as HTMLScriptElement;
+      expect(script && script.hasAttribute('crossorigin')).toBe(true);
+      expect(script && script.hasAttribute('integrity')).toBe(true);
+      expect(script && script.hasAttribute('referrerpolicy')).toBe(true);
+    });
+  });
+
+  describe('should return a Promise', () => {
+    let promise: Promise<Event>;
+    let script: HTMLScriptElement | null;
+    beforeEach(() => {
+      promise = loadScript('test', 'http://example.com/');
+      script = document.querySelector('head > script#test');
+    });
+    test('should return Promise', () => {
+      return expect(promise).toBeInstanceOf(Promise);
+    });
+
+    test('should return a Promise that resolves with instance of Event on successful script loading', () => {
+      script?.dispatchEvent(new Event('load'));
+      return expect(promise).resolves.toBeInstanceOf(Event);
+    });
+    test('should return a Promise that resolves with load type Event on successful script loading', () => {
+      script?.dispatchEvent(new Event('load'));
+      return expect(promise).resolves.toMatchObject({type: 'load'});
+    });
+    test('should return a Promise that rejects with instance of Event if the script fails to load', () => {
+      script?.dispatchEvent(new Event('error'));
+      return expect(promise).rejects.toBeInstanceOf(Event);
+    });
+    test('should return a Promise that resolves with error type Event if the script fails to load', () => {
+      script?.dispatchEvent(new Event('error'));
+      return expect(promise).rejects.toMatchObject({type: 'error'});
+    });
+  });
+
+  describe('should have proper state attribute', () => {
+    let script: HTMLScriptElement | null;
+    beforeEach(() => {
+      loadScript('test', 'http://example.com/').catch(() => {});
+      script = document.querySelector('head > script#test');
+    });
+    test('should not have a state attribute on pending script loading', () => {
+      expect(script?.getAttribute('state')).toBe(null);
+    });
+    test('should have state="success" attribute on successful script loading', () => {
+      script?.dispatchEvent(new Event('load'));
+      expect(script?.getAttribute('state')).toBe('success');
+    });
+    test('should have state="error" attribute on failed script loading', async () => {
+      script?.dispatchEvent(new Event('error'));
+      return expect(script?.getAttribute('state')).toBe('error');
+    });
+  });
+});